1507 lines
54 KiB
Markdown
1507 lines
54 KiB
Markdown
# Settings Menu Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Move workspace/provider/model/thinking out of the "Nuova domanda" form into a global, backend-persisted **Settings** dialog reachable from a new sidebar menu item; populate the choices from real sources (workspace YAMLs and Pi's `get_available_models`).
|
||
|
||
**Architecture:** The backend persists settings to a JSON file and applies them when creating a session (the frontend no longer sends these per-question). A new ephemeral-Pi model lister answers `GET /models` with real `{provider,id,name,reasoning}` objects. The frontend gets a `SettingsDialog` (4 selects) and the `NewSessionDialog` shrinks to a single question field.
|
||
|
||
**Tech Stack:** Backend = Fastify 5 + TypeScript (ESM, `.js` import specifiers), Vitest. Frontend = React 18 + @tanstack/react-query + @base-ui/react + Tailwind, Vitest + Testing Library + MSW.
|
||
|
||
## Global Constraints
|
||
|
||
- Backend is ESM/NodeNext: **all relative imports use `.js` specifiers** even from `.ts` files (e.g. `import { loadSettings } from "../settings/settings-store.js"`).
|
||
- `tht` CLI `--config`/`-c` is **per-command**, appended AFTER the subcommand — already handled by `ThtRunner`; do not change that.
|
||
- Pi RPC `get_available_models` reply envelope is `{ id, type:"response", command:"get_available_models", success:true, data:{ models:[...] } }`. Each model is `{ provider:string, id:string, name:string, reasoning:boolean, ... }`. Read `resp.data.models`.
|
||
- Pi `set_model` takes `{ type:"set_model", provider, modelId }` (model id, not name). Thinking via `{ type:"set_thinking_level", level }`.
|
||
- Thinking levels exposed in the UI: exactly `low`, `medium`, `high` (Pi also supports off/minimal/xhigh — do NOT expose them here).
|
||
- Settings file default path: `data/settings.json` relative to the backend process cwd; override via `SETTINGS_FILE` env. It must be gitignored.
|
||
- Backend test command: `cd backend && npx vitest run <file>`. Frontend test command: `cd frontend && npx vitest run <file>`.
|
||
- Run tests from inside the respective package dir (`backend/` or `frontend/`), never the repo root.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Backend**
|
||
- Create `backend/src/settings/settings-store.ts` — load/save settings JSON + effective-defaults merge.
|
||
- Create `backend/src/pi/list-models.ts` — ephemeral Pi spawn → `get_available_models` → `PiModel[]`, with TTL cache.
|
||
- Create `backend/src/routes/settings.ts` — `GET /settings`, `PUT /settings`.
|
||
- Modify `backend/src/config.ts` — add `settingsFile`.
|
||
- Modify `backend/src/routes/meta.ts` — export `listWorkspaces`; `ListModelsFn` returns `PiModel[]`.
|
||
- Modify `backend/src/app.ts` — wire settings store, real model lister, settings routes, inject `getSettings` into session routes.
|
||
- Modify `backend/src/routes/sessions.ts` — `POST /sessions` reads settings instead of body params.
|
||
- Modify `backend/.gitignore` (or repo `.gitignore`) — ignore the settings file.
|
||
- Tests: `backend/test/settings-store.test.ts`, `backend/test/list-models.test.ts`, `backend/test/routes-settings.test.ts`; update `backend/test/routes-sessions.test.ts` and `backend/test/routes-sql-meta.test.ts`.
|
||
|
||
**Frontend**
|
||
- Modify `frontend/src/api/models.ts` — typed `PiModel`, `listModels` returns `{ models: PiModel[] }`.
|
||
- Create `frontend/src/api/settings.ts` — `getSettings`, `putSettings`.
|
||
- Modify `frontend/src/api/sessions.ts` — `createSession({ question, name? })`.
|
||
- Modify `frontend/src/shell/NewSessionDialog.tsx` — question-only form.
|
||
- Create `frontend/src/shell/SettingsDialog.tsx` — the settings form.
|
||
- Modify `frontend/src/shell/AppShell.tsx` — add the Settings button.
|
||
- Tests: `frontend/src/shell/SettingsDialog.test.tsx`; update `frontend/src/shell/NewSessionDialog.test.tsx` and `frontend/src/api/sessions.test.ts`.
|
||
|
||
---
|
||
|
||
## Task 1: Backend settings store + config
|
||
|
||
**Files:**
|
||
- Create: `backend/src/settings/settings-store.ts`
|
||
- Modify: `backend/src/config.ts`
|
||
- Modify: `backend/.gitignore` (create if missing)
|
||
- Test: `backend/test/settings-store.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AppConfig` from `config.ts`.
|
||
- Produces:
|
||
- `interface Settings { workspace?: string; provider?: string; model?: string; thinking?: string }`
|
||
- `loadSettings(cfg: AppConfig): Settings` — reads `cfg.settingsFile` JSON; `{}` if missing/invalid.
|
||
- `saveSettings(cfg: AppConfig, s: Settings): Settings` — `mkdir -p` parent, write pretty JSON, return `s`.
|
||
- `config.ts` `AppConfig` gains `settingsFile: string`; `loadConfig` sets it from `env.SETTINGS_FILE ?? "data/settings.json"`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/test/settings-store.test.ts`:
|
||
|
||
```ts
|
||
import { test, expect } from "vitest";
|
||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { loadSettings, saveSettings } from "../src/settings/settings-store.js";
|
||
import { loadConfig } from "../src/config.js";
|
||
|
||
function cfgWith(file: string) {
|
||
return loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: file });
|
||
}
|
||
|
||
test("loadSettings returns {} when the file does not exist", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "tht-set-"));
|
||
try {
|
||
expect(loadSettings(cfgWith(join(dir, "settings.json")))).toEqual({});
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("saveSettings writes the file and loadSettings reads it back", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "tht-set-"));
|
||
try {
|
||
const cfg = cfgWith(join(dir, "nested", "settings.json"));
|
||
const saved = saveSettings(cfg, { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" });
|
||
expect(saved.model).toBe("glm-5.2");
|
||
expect(loadSettings(cfg)).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" });
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("loadSettings returns {} on corrupt JSON (no throw)", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "tht-set-"));
|
||
try {
|
||
const file = join(dir, "settings.json");
|
||
writeFileSync(file, "{ not json");
|
||
expect(loadSettings(cfgWith(file))).toEqual({});
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("loadConfig sets settingsFile from SETTINGS_FILE, default data/settings.json", () => {
|
||
expect(loadConfig({}).settingsFile).toBe("data/settings.json");
|
||
expect(loadConfig({ SETTINGS_FILE: "/x/y.json" }).settingsFile).toBe("/x/y.json");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/settings-store.test.ts`
|
||
Expected: FAIL — cannot find module `../src/settings/settings-store.js` and `settingsFile` undefined.
|
||
|
||
- [ ] **Step 3: Add `settingsFile` to config**
|
||
|
||
In `backend/src/config.ts`, add the field to the interface and to the returned object:
|
||
|
||
```ts
|
||
export interface AppConfig {
|
||
port: number; harnessDir: string; thtBin: string; piBin: string;
|
||
authMode: "none" | "mock" | "oidc";
|
||
defaults: { provider?: string; model?: string; thinking?: string };
|
||
maxPiProcesses: number;
|
||
settingsFile: string;
|
||
}
|
||
```
|
||
|
||
and inside the returned object in `loadConfig`, after `maxPiProcesses`:
|
||
|
||
```ts
|
||
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
||
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the store**
|
||
|
||
Create `backend/src/settings/settings-store.ts`:
|
||
|
||
```ts
|
||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
import { dirname } from "node:path";
|
||
import type { AppConfig } from "../config.js";
|
||
|
||
export interface Settings {
|
||
workspace?: string;
|
||
provider?: string;
|
||
model?: string;
|
||
thinking?: string;
|
||
}
|
||
|
||
/** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */
|
||
export function loadSettings(cfg: AppConfig): Settings {
|
||
try {
|
||
const raw = readFileSync(cfg.settingsFile, "utf8");
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object") return parsed as Settings;
|
||
return {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
|
||
export function saveSettings(cfg: AppConfig, s: Settings): Settings {
|
||
mkdirSync(dirname(cfg.settingsFile), { recursive: true });
|
||
writeFileSync(cfg.settingsFile, JSON.stringify(s, null, 2) + "\n", "utf8");
|
||
return s;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Ignore the settings file**
|
||
|
||
Append to `backend/.gitignore` (create the file if it does not exist):
|
||
|
||
```
|
||
data/settings.json
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/settings-store.test.ts`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/src/settings/settings-store.ts backend/src/config.ts backend/.gitignore backend/test/settings-store.test.ts
|
||
git commit -m "feat(backend): persistent settings store + settingsFile config"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Ephemeral Pi model lister
|
||
|
||
**Files:**
|
||
- Create: `backend/src/pi/list-models.ts`
|
||
- Test: `backend/test/list-models.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AppConfig`; `RpcClient` from `../rpc/rpc-client.js`; Node `spawn`.
|
||
- Produces:
|
||
- `interface PiModel { provider: string; id: string; name: string; reasoning: boolean }`
|
||
- `createPiModelLister(cfg: AppConfig, opts?: { spawnFn?: () => ChildProcessWithoutNullStreams; ttlMs?: number; nowMs?: () => number }): () => Promise<PiModel[]>`
|
||
- The returned function spawns `pi --mode rpc`, sends `get_available_models`, parses `resp.data.models`, maps to `PiModel[]`, kills the child, and caches the result for `ttlMs` (default 60000). On timeout (8s) or error it rejects (callers handle the fallback).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/test/list-models.test.ts`. It drives the lister against the existing fake Pi (`harness/tests/fake_pi/fake_pi_rpc.mjs`), which already answers `get_available_models` with `{type:"response", command, id, success:true, data:{models: script.available_models}}`.
|
||
|
||
```ts
|
||
import { test, expect } from "vitest";
|
||
import { spawn } from "node:child_process";
|
||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import path from "node:path";
|
||
import { createPiModelLister } from "../src/pi/list-models.js";
|
||
import { loadConfig } from "../src/config.js";
|
||
|
||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||
|
||
function scriptWith(models: unknown[]): string {
|
||
const dir = mkdtempSync(join(tmpdir(), "tht-models-"));
|
||
const file = join(dir, "models.json");
|
||
writeFileSync(file, JSON.stringify({ available_models: models }));
|
||
return file;
|
||
}
|
||
|
||
test("createPiModelLister returns mapped PiModel[] from get_available_models", async () => {
|
||
const script = scriptWith([
|
||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true, extra: "ignored" },
|
||
{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true },
|
||
]);
|
||
try {
|
||
const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
spawnFn: () => spawn("node", [FAKE, script]) as any,
|
||
});
|
||
const models = await lister();
|
||
expect(models).toEqual([
|
||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
||
{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true },
|
||
]);
|
||
} finally {
|
||
rmSync(path.dirname(script), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("createPiModelLister caches within ttl (spawns once for two calls)", async () => {
|
||
const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]);
|
||
try {
|
||
let spawns = 0;
|
||
const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
spawnFn: () => { spawns++; return spawn("node", [FAKE, script]) as any; },
|
||
ttlMs: 10_000,
|
||
nowMs: () => 1000,
|
||
});
|
||
await lister();
|
||
await lister();
|
||
expect(spawns).toBe(1);
|
||
} finally {
|
||
rmSync(path.dirname(script), { recursive: true, force: true });
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/list-models.test.ts`
|
||
Expected: FAIL — cannot find module `../src/pi/list-models.js`.
|
||
|
||
- [ ] **Step 3: Implement the lister**
|
||
|
||
Create `backend/src/pi/list-models.ts`:
|
||
|
||
```ts
|
||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||
import { join } from "node:path";
|
||
import type { AppConfig } from "../config.js";
|
||
import { RpcClient } from "../rpc/rpc-client.js";
|
||
|
||
export interface PiModel {
|
||
provider: string;
|
||
id: string;
|
||
name: string;
|
||
reasoning: boolean;
|
||
}
|
||
|
||
interface Opts {
|
||
spawnFn?: () => ChildProcessWithoutNullStreams;
|
||
ttlMs?: number;
|
||
nowMs?: () => number;
|
||
}
|
||
|
||
/**
|
||
* Returns a function that lists Pi's available models (those with auth
|
||
* configured) via an ephemeral `pi --mode rpc` process. Result is cached for
|
||
* `ttlMs`. The returned function rejects on timeout/error; callers degrade.
|
||
*/
|
||
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
||
const ttlMs = opts.ttlMs ?? 60_000;
|
||
const now = opts.nowMs ?? (() => Date.now());
|
||
const spawnFn =
|
||
opts.spawnFn ??
|
||
(() => {
|
||
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
|
||
return nodeSpawn(cfg.piBin, ["--mode", "rpc"], {
|
||
cwd: cfg.harnessDir,
|
||
env: { ...process.env, PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}` },
|
||
}) as ChildProcessWithoutNullStreams;
|
||
});
|
||
|
||
let cache: { at: number; models: PiModel[] } | null = null;
|
||
|
||
return async function listModels(): Promise<PiModel[]> {
|
||
if (cache && now() - cache.at < ttlMs) return cache.models;
|
||
|
||
const child = spawnFn();
|
||
child.stderr.resume();
|
||
const rpc = new RpcClient(child);
|
||
try {
|
||
const resp = await Promise.race([
|
||
rpc.request({ type: "get_available_models" }),
|
||
new Promise<never>((_, rej) => setTimeout(() => rej(new Error("pi model list timeout")), 8000)),
|
||
]);
|
||
const raw = (resp?.data?.models ?? []) as Array<Record<string, unknown>>;
|
||
const models: PiModel[] = raw.map((m) => ({
|
||
provider: String(m.provider ?? ""),
|
||
id: String(m.id ?? ""),
|
||
name: String(m.name ?? m.id ?? ""),
|
||
reasoning: Boolean(m.reasoning),
|
||
}));
|
||
cache = { at: now(), models };
|
||
return models;
|
||
} finally {
|
||
child.kill();
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/list-models.test.ts`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/pi/list-models.ts backend/test/list-models.test.ts
|
||
git commit -m "feat(backend): ephemeral Pi model lister (get_available_models) with TTL cache"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `/models` real shape + `/settings` routes + app wiring
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/routes/meta.ts`
|
||
- Create: `backend/src/routes/settings.ts`
|
||
- Modify: `backend/src/app.ts`
|
||
- Modify: `backend/test/routes-sql-meta.test.ts` (update `/models` expectations to objects)
|
||
- Test: `backend/test/routes-settings.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `loadSettings`/`saveSettings`/`Settings` (Task 1), `PiModel` + `createPiModelLister` (Task 2), `AppConfig`.
|
||
- Produces:
|
||
- `meta.ts`: `export function listWorkspaces(harnessDir: string): { name: string; file: string }[]`; `ListModelsFn = () => Promise<PiModel[]>`; `GET /models` returns `{ models: PiModel[] }`.
|
||
- `settings.ts`: `export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings`; `export function settingsRoutes(app, deps: { cfg: AppConfig; listModels: ListModelsFn }): void` registering `GET /settings` and `PUT /settings`.
|
||
- `effectiveSettings` fills: `workspace ?? firstWorkspace(cfg)`, `provider ?? cfg.defaults.provider`, `model ?? cfg.defaults.model`, `thinking ?? cfg.defaults.thinking`.
|
||
- `app.ts` `BuildAppDeps` gains `getSettings?: () => Settings`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/test/routes-settings.test.ts`:
|
||
|
||
```ts
|
||
import { test, expect } from "vitest";
|
||
import { mkdtempSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { buildApp } from "../src/app.js";
|
||
import { loadConfig } from "../src/config.js";
|
||
|
||
function appWithTmpSettings(extraEnv: Record<string, string> = {}, deps = {}) {
|
||
const dir = mkdtempSync(join(tmpdir(), "tht-set-route-"));
|
||
const app = buildApp(
|
||
loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: join(dir, "settings.json"), ...extraEnv }),
|
||
{ thtRunner: {} as any, ...deps },
|
||
);
|
||
return { app, dir };
|
||
}
|
||
|
||
test("GET /settings returns effective defaults (env provider/model/thinking, first workspace)", async () => {
|
||
const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, {
|
||
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||
});
|
||
try {
|
||
const res = await app.inject({ method: "GET", url: "/settings" });
|
||
expect(res.statusCode).toBe(200);
|
||
const body = res.json();
|
||
expect(body.provider).toBe("zai");
|
||
expect(body.model).toBe("glm-5.2");
|
||
expect(body.thinking).toBe("medium");
|
||
expect(typeof body.workspace).toBe("string"); // first workspace from ../harness/workspaces
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("PUT /settings persists and GET reads it back", async () => {
|
||
const { app, dir } = appWithTmpSettings({}, {
|
||
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||
});
|
||
try {
|
||
const put = await app.inject({
|
||
method: "PUT", url: "/settings",
|
||
payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" },
|
||
});
|
||
expect(put.statusCode).toBe(200);
|
||
const got = await app.inject({ method: "GET", url: "/settings" });
|
||
expect(got.json()).toMatchObject({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" });
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("PUT /settings rejects an unknown model when a model list is available", async () => {
|
||
const { app, dir } = appWithTmpSettings({}, {
|
||
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||
});
|
||
try {
|
||
const put = await app.inject({
|
||
method: "PUT", url: "/settings",
|
||
payload: { workspace: "psd", provider: "zai", model: "does-not-exist", thinking: "low" },
|
||
});
|
||
expect(put.statusCode).toBe(400);
|
||
expect(put.json()).toMatchObject({ error: expect.stringMatching(/model/i) });
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("PUT /settings allows any model when model list is empty (Pi unavailable)", async () => {
|
||
const { app, dir } = appWithTmpSettings({}, { listModels: async () => [] });
|
||
try {
|
||
const put = await app.inject({
|
||
method: "PUT", url: "/settings",
|
||
payload: { workspace: "psd", model: "whatever", thinking: "low" },
|
||
});
|
||
expect(put.statusCode).toBe(200);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-settings.test.ts`
|
||
Expected: FAIL — no `/settings` route (404), `settings.ts` missing.
|
||
|
||
- [ ] **Step 3: Update meta.ts (export listWorkspaces, PiModel-typed models)**
|
||
|
||
Replace `backend/src/routes/meta.ts` with:
|
||
|
||
```ts
|
||
import { readdirSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import type { FastifyInstance } from "fastify";
|
||
import type { PiModel } from "../pi/list-models.js";
|
||
|
||
export type ListModelsFn = () => Promise<PiModel[]>;
|
||
|
||
/**
|
||
* List YAML workspace configs found in <harnessDir>/workspaces/*.yaml.
|
||
* Returns [{name, file}] — no database credentials or secrets.
|
||
*/
|
||
export function listWorkspaces(harnessDir: string): { name: string; file: string }[] {
|
||
const dir = join(harnessDir, "workspaces");
|
||
let entries: string[];
|
||
try {
|
||
entries = readdirSync(dir);
|
||
} catch {
|
||
return [];
|
||
}
|
||
return entries
|
||
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
||
.map((f) => ({ name: f.replace(/\.ya?ml$/, ""), file: f }));
|
||
}
|
||
|
||
export function metaRoutes(
|
||
app: FastifyInstance,
|
||
deps: { harnessDir: string; listModels?: ListModelsFn },
|
||
): void {
|
||
app.get("/workspaces", async () => {
|
||
return listWorkspaces(deps.harnessDir);
|
||
});
|
||
|
||
app.get("/models", async () => {
|
||
const fn = deps.listModels ?? (async () => []);
|
||
try {
|
||
return { models: await fn() };
|
||
} catch {
|
||
// Graceful fallback: Pi may not be running; don't crash the server.
|
||
return { models: [] as PiModel[] };
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create settings.ts**
|
||
|
||
Create `backend/src/routes/settings.ts`:
|
||
|
||
```ts
|
||
import type { FastifyInstance } from "fastify";
|
||
import type { AppConfig } from "../config.js";
|
||
import { loadSettings, saveSettings, type Settings } from "../settings/settings-store.js";
|
||
import { listWorkspaces, type ListModelsFn } from "./meta.js";
|
||
|
||
/** Merge stored settings over env/first-workspace defaults. */
|
||
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
||
const workspaces = listWorkspaces(cfg.harnessDir);
|
||
return {
|
||
workspace: stored.workspace ?? (workspaces[0]?.name),
|
||
provider: stored.provider ?? cfg.defaults.provider,
|
||
model: stored.model ?? cfg.defaults.model,
|
||
thinking: stored.thinking ?? cfg.defaults.thinking,
|
||
};
|
||
}
|
||
|
||
export function settingsRoutes(
|
||
app: FastifyInstance,
|
||
deps: { cfg: AppConfig; listModels: ListModelsFn },
|
||
): void {
|
||
app.get("/settings", async () => {
|
||
return effectiveSettings(deps.cfg, loadSettings(deps.cfg));
|
||
});
|
||
|
||
app.put("/settings", async (req, reply) => {
|
||
const b = (req.body ?? {}) as Settings;
|
||
if (b.model) {
|
||
let available: { id: string }[] = [];
|
||
try {
|
||
available = await deps.listModels();
|
||
} catch {
|
||
available = [];
|
||
}
|
||
// Only validate when Pi gave us a non-empty list; otherwise allow (degraded).
|
||
if (available.length > 0 && !available.some((m) => m.id === b.model)) {
|
||
return reply.code(400).send({ error: `Unknown model: ${b.model}` });
|
||
}
|
||
}
|
||
const next: Settings = {
|
||
workspace: b.workspace,
|
||
provider: b.provider,
|
||
model: b.model,
|
||
thinking: b.thinking,
|
||
};
|
||
saveSettings(deps.cfg, next);
|
||
return effectiveSettings(deps.cfg, next);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Wire app.ts**
|
||
|
||
In `backend/src/app.ts`, add imports near the existing route imports:
|
||
|
||
```ts
|
||
import { settingsRoutes } from "./routes/settings.js";
|
||
import { createPiModelLister } from "./pi/list-models.js";
|
||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||
import { effectiveSettings } from "./routes/settings.js";
|
||
```
|
||
|
||
Extend `BuildAppDeps`:
|
||
|
||
```ts
|
||
export interface BuildAppDeps {
|
||
thtRunner?: ThtRunner;
|
||
spawnFn?: () => any;
|
||
listModels?: ListModelsFn;
|
||
getSettings?: () => Settings;
|
||
}
|
||
```
|
||
|
||
Inside `buildApp`, after `const hub = new SseHub();` and before the route registrations, resolve the model lister and the settings accessor:
|
||
|
||
```ts
|
||
const listModels = deps?.listModels ?? createPiModelLister(config);
|
||
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config)));
|
||
```
|
||
|
||
Change the route registrations to:
|
||
|
||
```ts
|
||
sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings });
|
||
sqlRoutes(app, { tht: tht as ThtRunner });
|
||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||
settingsRoutes(app, { cfg: config, listModels });
|
||
```
|
||
|
||
(Leave the existing `const tht = ...`, `const mgr = ...` lines unchanged. `getSettings` is consumed by `sessionRoutes` in Task 4 — adding it to the deps object now is harmless.)
|
||
|
||
- [ ] **Step 6: Update the existing `/models` expectations**
|
||
|
||
In `backend/test/routes-sql-meta.test.ts`, the three `/models` tests assert the old string-array shape. Replace the first of them (the injected-stub test, lines ~123–133) with the object shape:
|
||
|
||
```ts
|
||
test("GET /models returns {models:[...]} from injected listModels stub", async () => {
|
||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
thtRunner: {} as any,
|
||
listModels: async () => [
|
||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
||
],
|
||
});
|
||
|
||
const res = await app.inject({ method: "GET", url: "/models" });
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()).toEqual({
|
||
models: [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||
});
|
||
});
|
||
```
|
||
|
||
The two fallback tests (`listModels throws` and `no listModels injected`) already expect `{ models: [] }` and stay valid.
|
||
|
||
- [ ] **Step 7: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts`
|
||
Expected: PASS (all settings tests + the updated meta tests).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add backend/src/routes/meta.ts backend/src/routes/settings.ts backend/src/app.ts backend/test/routes-settings.test.ts backend/test/routes-sql-meta.test.ts
|
||
git commit -m "feat(backend): /settings GET+PUT, /models PiModel shape, app wiring"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `POST /sessions` applies settings (not body params)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/routes/sessions.ts`
|
||
- Modify: `backend/test/routes-sessions.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings: () => Settings` (added to deps in Task 3 app wiring).
|
||
- Produces: `sessionRoutes(app, d: { mgr; tht; hub; getSettings })`. `POST /sessions` body is `{ question: string; name?: string }`. Workspace/provider/model/thinking come from `d.getSettings()`.
|
||
|
||
- [ ] **Step 1: Update the failing test**
|
||
|
||
Replace the first test in `backend/test/routes-sessions.test.ts` (the "POST /sessions crea e avvia…" test, lines 10–25) with a version where settings — not the body — supply the workspace:
|
||
|
||
```ts
|
||
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
|
||
let sessionNewArg: any;
|
||
let spawnArg: any;
|
||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
thtRunner: {
|
||
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
|
||
sessionList: async () => [{ id: "s1" }],
|
||
} as any,
|
||
getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }),
|
||
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
|
||
});
|
||
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||
expect(created.json()).toEqual({ id: "s1" });
|
||
expect(sessionNewArg.workspace).toBe("w");
|
||
expect(sessionNewArg.provider).toBe("zai");
|
||
expect(sessionNewArg.model).toBe("glm-5.2");
|
||
expect(sessionNewArg.thinking).toBe("high");
|
||
expect(sessionNewArg.question).toBe("q");
|
||
const list = await app.inject({ method: "GET", url: "/sessions" });
|
||
expect(list.json()).toEqual([{ id: "s1" }]);
|
||
});
|
||
```
|
||
|
||
In the second test ("POST /sessions/:id/response inoltra al bridge"), add a `getSettings` stub to the deps so the route has its settings source, and drop `workspace` from the payload:
|
||
|
||
```ts
|
||
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any,
|
||
getSettings: () => ({ workspace: "w" }),
|
||
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
|
||
});
|
||
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||
const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
|
||
payload: { ui_response: { id: "u1", choices: ["a"] } } });
|
||
expect(res.statusCode).toBe(204);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-sessions.test.ts`
|
||
Expected: FAIL — route still reads `b.workspace` (undefined now), so `sessionNewArg.workspace` is `undefined`, assertion fails.
|
||
|
||
- [ ] **Step 3: Update the route**
|
||
|
||
In `backend/src/routes/sessions.ts`, update the signature and the `POST /sessions` handler. Add `Settings` import at the top:
|
||
|
||
```ts
|
||
import type { Settings } from "../settings/settings-store.js";
|
||
```
|
||
|
||
Change the function signature:
|
||
|
||
```ts
|
||
export function sessionRoutes(
|
||
app: FastifyInstance,
|
||
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings },
|
||
) {
|
||
```
|
||
|
||
Replace the `POST /sessions` handler body (lines 8–18) with:
|
||
|
||
```ts
|
||
app.post("/sessions", async (req, reply) => {
|
||
const b = req.body as { question: string; name?: string };
|
||
const s = d.getSettings();
|
||
// Settings (global) supply workspace/provider/model/thinking. The new-question
|
||
// form sends only the question text. `workspace` selects the tht `-c <config>`.
|
||
const { id } = await d.tht.sessionNew({
|
||
question: b.question,
|
||
name: b.name,
|
||
workspace: s.workspace,
|
||
provider: s.provider,
|
||
model: s.model,
|
||
thinking: s.thinking,
|
||
});
|
||
const rt = await d.mgr.spawnFor(id, {
|
||
provider: s.provider,
|
||
model: s.model,
|
||
thinking: s.thinking,
|
||
author: getUser(req).id,
|
||
});
|
||
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
|
||
return { id };
|
||
});
|
||
```
|
||
|
||
(`reply` stays in the signature to match the existing style; it is unused here as before.)
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-sessions.test.ts`
|
||
Expected: PASS (both tests).
|
||
|
||
- [ ] **Step 5: Run the whole backend suite (no regressions)**
|
||
|
||
Run: `cd backend && npx vitest run`
|
||
Expected: PASS for all files. If `routes-sessions` e2e or other tests reference `workspace` in the POST body, none should remain after this task — confirm green.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/routes/sessions.ts backend/test/routes-sessions.test.ts
|
||
git commit -m "feat(backend): POST /sessions applies global settings, body is question-only"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Frontend API layer (models type, settings api, simplified createSession)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/models.ts`
|
||
- Create: `frontend/src/api/settings.ts`
|
||
- Modify: `frontend/src/api/sessions.ts`
|
||
- Modify: `frontend/src/api/sessions.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `models.ts`: `export interface PiModel { provider: string; id: string; name: string; reasoning: boolean }`; `listModels(): Promise<{ models: PiModel[] }>`.
|
||
- `settings.ts`: `export interface Settings { workspace?: string; provider?: string; model?: string; thinking?: string }`; `getSettings(): Promise<Settings>`; `putSettings(s: Settings): Promise<Settings>`.
|
||
- `sessions.ts`: `createSession(i: { question: string; name?: string }): Promise<{ id: string }>`.
|
||
|
||
- [ ] **Step 1: Update the failing test**
|
||
|
||
Replace `frontend/src/api/sessions.test.ts` with:
|
||
|
||
```ts
|
||
import { http, HttpResponse } from "msw";
|
||
import { server } from "../test/msw";
|
||
import { createSession, listSessions } from "./sessions";
|
||
|
||
test("createSession POSTs only {question} and returns the id", async () => {
|
||
let body: unknown = null;
|
||
server.use(
|
||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||
body = await request.json();
|
||
return HttpResponse.json({ id: "s1" });
|
||
}),
|
||
);
|
||
expect(await createSession({ question: "q" })).toEqual({ id: "s1" });
|
||
expect(body).toEqual({ question: "q" });
|
||
});
|
||
|
||
test("listSessions GETs the array", async () => {
|
||
server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }])));
|
||
const rows = await listSessions();
|
||
expect(rows[0].id).toBe("s1");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/api/sessions.test.ts`
|
||
Expected: FAIL — `createSession` currently requires `workspace`; `body` includes `workspace`.
|
||
|
||
- [ ] **Step 3: Simplify createSession**
|
||
|
||
In `frontend/src/api/sessions.ts`, replace the `createSession` definition with:
|
||
|
||
```ts
|
||
export const createSession = (i: { question: string; name?: string }) =>
|
||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
||
```
|
||
|
||
(Leave the rest of the file unchanged.)
|
||
|
||
- [ ] **Step 4: Type models.ts**
|
||
|
||
Replace `frontend/src/api/models.ts` with:
|
||
|
||
```ts
|
||
import { apiFetch } from "./client";
|
||
|
||
export interface PiModel {
|
||
provider: string;
|
||
id: string;
|
||
name: string;
|
||
reasoning: boolean;
|
||
}
|
||
|
||
export const listModels = () => apiFetch<{ models: PiModel[] }>("/models");
|
||
```
|
||
|
||
- [ ] **Step 5: Create settings.ts**
|
||
|
||
Create `frontend/src/api/settings.ts`:
|
||
|
||
```ts
|
||
import { apiFetch } from "./client";
|
||
|
||
export interface Settings {
|
||
workspace?: string;
|
||
provider?: string;
|
||
model?: string;
|
||
thinking?: string;
|
||
}
|
||
|
||
export const getSettings = () => apiFetch<Settings>("/settings");
|
||
|
||
export const putSettings = (s: Settings) =>
|
||
apiFetch<Settings>("/settings", { method: "PUT", body: JSON.stringify(s) });
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/api/sessions.test.ts`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/models.ts frontend/src/api/settings.ts frontend/src/api/sessions.ts frontend/src/api/sessions.test.ts
|
||
git commit -m "feat(frontend): settings api, typed PiModel, question-only createSession"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Shrink NewSessionDialog to question-only
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/shell/NewSessionDialog.tsx`
|
||
- Modify: `frontend/src/shell/NewSessionDialog.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createSession({ question })` (Task 5).
|
||
- Produces: `NewSessionDialog` renders only a question textarea + Crea/Annulla; on submit calls `createSession({ question })` and `onCreated(id)`.
|
||
|
||
- [ ] **Step 1: Replace the test**
|
||
|
||
Replace `frontend/src/shell/NewSessionDialog.test.tsx` with:
|
||
|
||
```tsx
|
||
// frontend/src/shell/NewSessionDialog.test.tsx
|
||
import { render, screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { http, HttpResponse } from "msw";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { server } from "../test/msw";
|
||
import { NewSessionDialog } from "./NewSessionDialog";
|
||
|
||
function renderDialog() {
|
||
const onCreated = vi.fn();
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
render(
|
||
<QueryClientProvider client={client}>
|
||
<NewSessionDialog onCreated={onCreated} />
|
||
</QueryClientProvider>,
|
||
);
|
||
return { onCreated };
|
||
}
|
||
|
||
test("the form has only a question field (no workspace/model/provider/thinking)", async () => {
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||
expect(await screen.findByLabelText(/domanda/i)).toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/workspace/i)).not.toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/modello/i)).not.toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/provider/i)).not.toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
||
});
|
||
|
||
test("submitting posts only { question } and calls onCreated", async () => {
|
||
let body: unknown = null;
|
||
server.use(
|
||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||
body = await request.json();
|
||
return HttpResponse.json({ id: "s1" });
|
||
}),
|
||
);
|
||
const { onCreated } = renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||
await userEvent.type(await screen.findByLabelText(/domanda/i), "Quante vendite nel 2025?");
|
||
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||
|
||
await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" }));
|
||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||
});
|
||
|
||
test("empty question shows a validation error and does not submit", async () => {
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||
expect(await screen.findByRole("alert")).toHaveTextContent(/vuota/i);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/NewSessionDialog.test.tsx`
|
||
Expected: FAIL — current dialog still renders workspace/model/provider/thinking and posts `{ workspace, question }`.
|
||
|
||
- [ ] **Step 3: Rewrite the component**
|
||
|
||
Replace `frontend/src/shell/NewSessionDialog.tsx` with:
|
||
|
||
```tsx
|
||
// frontend/src/shell/NewSessionDialog.tsx
|
||
import { useState } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../components/ui/dialog";
|
||
import { Button } from "../components/ui/button";
|
||
import { createSession } from "../api/sessions";
|
||
|
||
interface Props {
|
||
onCreated: (id: string) => void;
|
||
}
|
||
|
||
export function NewSessionDialog({ onCreated }: Props) {
|
||
const [open, setOpen] = useState(false);
|
||
const [question, setQuestion] = useState("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
if (!question.trim()) {
|
||
setError("La domanda non può essere vuota.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const { id } = await createSession({ question: question.trim() });
|
||
setOpen(false);
|
||
setQuestion("");
|
||
onCreated(id);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Errore nella creazione della sessione.");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => setOpen(true)}
|
||
>
|
||
Nuova domanda
|
||
</Button>
|
||
<DialogContent showCloseButton={false}>
|
||
<DialogHeader>
|
||
<DialogTitle>Nuova sessione</DialogTitle>
|
||
</DialogHeader>
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium mb-1" htmlFor="nsd-question">
|
||
Domanda
|
||
</label>
|
||
<textarea
|
||
id="nsd-question"
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
rows={3}
|
||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 resize-none"
|
||
placeholder="Inserisci la tua domanda…"
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-xs text-destructive" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setOpen(false)}
|
||
>
|
||
Annulla
|
||
</Button>
|
||
<Button type="submit" size="sm" disabled={busy}>
|
||
{busy ? "Creazione…" : "Crea"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/NewSessionDialog.test.tsx`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/NewSessionDialog.tsx frontend/src/shell/NewSessionDialog.test.tsx
|
||
git commit -m "feat(frontend): NewSessionDialog reduced to question-only"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: SettingsDialog component
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/shell/SettingsDialog.tsx`
|
||
- Test: `frontend/src/shell/SettingsDialog.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `listWorkspaces` (api/workspaces), `listModels` + `PiModel` (api/models), `getSettings`/`putSettings`/`Settings` (api/settings), Dialog/Button UI components, `@tanstack/react-query`.
|
||
- Produces: `export function SettingsDialog(): JSX.Element` — a sidebar button "Settings" (variant outline) that opens a dialog with four controls (workspace select, provider select, model select filtered by provider, thinking select low/medium/high), loads current values via `getSettings`, saves via `putSettings`. Degrades to free-text provider/model inputs when the models list is empty.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `frontend/src/shell/SettingsDialog.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { http, HttpResponse } from "msw";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { server } from "../test/msw";
|
||
import { SettingsDialog } from "./SettingsDialog";
|
||
|
||
function renderDialog() {
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
render(
|
||
<QueryClientProvider client={client}>
|
||
<SettingsDialog />
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
server.use(
|
||
http.get("http://localhost:8787/workspaces", () =>
|
||
HttpResponse.json([
|
||
{ name: "psd", file: "psd.yaml" },
|
||
{ name: "tht-test", file: "tht-test.yaml" },
|
||
]),
|
||
),
|
||
http.get("http://localhost:8787/models", () =>
|
||
HttpResponse.json({
|
||
models: [
|
||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
||
{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true },
|
||
],
|
||
}),
|
||
),
|
||
http.get("http://localhost:8787/settings", () =>
|
||
HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||
),
|
||
);
|
||
});
|
||
|
||
test("opens and pre-selects current settings", async () => {
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||
|
||
const workspace = (await screen.findByLabelText(/workspace/i)) as HTMLSelectElement;
|
||
expect(workspace.value).toBe("psd");
|
||
const thinking = screen.getByLabelText(/thinking/i) as HTMLSelectElement;
|
||
expect(thinking.value).toBe("medium");
|
||
// thinking options are exactly low/medium/high
|
||
expect(Array.from(thinking.options).map((o) => o.value)).toEqual(["low", "medium", "high"]);
|
||
});
|
||
|
||
test("provider change filters the model list", async () => {
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||
|
||
const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement;
|
||
await userEvent.selectOptions(provider, "anthropic");
|
||
const model = screen.getByLabelText(/modello/i) as HTMLSelectElement;
|
||
const optionValues = Array.from(model.options).map((o) => o.value);
|
||
expect(optionValues).toContain("claude-opus-4-8");
|
||
expect(optionValues).not.toContain("glm-5.2");
|
||
});
|
||
|
||
test("Salva PUTs the selected settings", async () => {
|
||
let body: unknown = null;
|
||
server.use(
|
||
http.put("http://localhost:8787/settings", async ({ request }) => {
|
||
body = await request.json();
|
||
return HttpResponse.json(await request.json());
|
||
}),
|
||
);
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||
await screen.findByLabelText(/workspace/i);
|
||
await userEvent.selectOptions(screen.getByLabelText(/thinking/i), "high");
|
||
await userEvent.click(screen.getByRole("button", { name: /salva/i }));
|
||
|
||
await waitFor(() =>
|
||
expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }),
|
||
);
|
||
});
|
||
|
||
test("degrades to free-text provider/model when models list is empty", async () => {
|
||
server.use(
|
||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||
http.get("http://localhost:8787/settings", () =>
|
||
HttpResponse.json({ workspace: "psd", provider: "", model: "", thinking: "low" }),
|
||
),
|
||
);
|
||
renderDialog();
|
||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||
expect(await screen.findByTestId("provider-freetext")).toHaveProperty("tagName", "INPUT");
|
||
expect(screen.getByTestId("model-freetext")).toHaveProperty("tagName", "INPUT");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SettingsDialog.test.tsx`
|
||
Expected: FAIL — `./SettingsDialog` does not exist.
|
||
|
||
- [ ] **Step 3: Implement the component**
|
||
|
||
Create `frontend/src/shell/SettingsDialog.tsx`:
|
||
|
||
```tsx
|
||
// frontend/src/shell/SettingsDialog.tsx
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../components/ui/dialog";
|
||
import { Button } from "../components/ui/button";
|
||
import { listWorkspaces } from "../api/workspaces";
|
||
import { listModels } from "../api/models";
|
||
import { getSettings, putSettings } from "../api/settings";
|
||
|
||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||
|
||
export function SettingsDialog() {
|
||
const [open, setOpen] = useState(false);
|
||
const [workspace, setWorkspace] = useState("");
|
||
const [provider, setProvider] = useState("");
|
||
const [model, setModel] = useState("");
|
||
const [thinking, setThinking] = useState("medium");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const queryClient = useQueryClient();
|
||
|
||
const { data: workspaces = [] } = useQuery({
|
||
queryKey: ["workspaces"],
|
||
queryFn: listWorkspaces,
|
||
enabled: open,
|
||
});
|
||
const { data: modelsData } = useQuery({
|
||
queryKey: ["models"],
|
||
queryFn: listModels,
|
||
enabled: open,
|
||
});
|
||
const { data: current } = useQuery({
|
||
queryKey: ["settings"],
|
||
queryFn: getSettings,
|
||
enabled: open,
|
||
});
|
||
|
||
const models = useMemo(() => modelsData?.models ?? [], [modelsData]);
|
||
const hasModels = models.length > 0;
|
||
const providers = useMemo(
|
||
() => Array.from(new Set(models.map((m) => m.provider))),
|
||
[models],
|
||
);
|
||
const modelsForProvider = useMemo(
|
||
() => models.filter((m) => !provider || m.provider === provider),
|
||
[models, provider],
|
||
);
|
||
|
||
// Hydrate the form once the current settings arrive.
|
||
useEffect(() => {
|
||
if (!current) return;
|
||
setWorkspace(current.workspace ?? "");
|
||
setProvider(current.provider ?? "");
|
||
setModel(current.model ?? "");
|
||
setThinking(current.thinking && THINKING_LEVELS.includes(current.thinking as never)
|
||
? current.thinking
|
||
: "medium");
|
||
}, [current]);
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
setBusy(true);
|
||
try {
|
||
await putSettings({
|
||
workspace: workspace || undefined,
|
||
provider: provider || undefined,
|
||
model: model || undefined,
|
||
thinking,
|
||
});
|
||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||
setOpen(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Errore nel salvataggio dei settings.");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => setOpen(true)}
|
||
>
|
||
Settings
|
||
</Button>
|
||
<DialogContent showCloseButton={false}>
|
||
<DialogHeader>
|
||
<DialogTitle>Settings</DialogTitle>
|
||
</DialogHeader>
|
||
<form onSubmit={handleSave} className="flex flex-col gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium mb-1" htmlFor="set-workspace">
|
||
Workspace
|
||
</label>
|
||
<select
|
||
id="set-workspace"
|
||
value={workspace}
|
||
onChange={(e) => setWorkspace(e.target.value)}
|
||
className="w-full border rounded px-2 py-1 text-sm"
|
||
>
|
||
{workspaces.length === 0 ? (
|
||
<option value="">—</option>
|
||
) : (
|
||
workspaces.map((w) => (
|
||
<option key={w.name} value={w.name}>
|
||
{w.name}
|
||
</option>
|
||
))
|
||
)}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium mb-1" htmlFor="set-provider">
|
||
Provider
|
||
</label>
|
||
{hasModels ? (
|
||
<select
|
||
id="set-provider"
|
||
value={provider}
|
||
onChange={(e) => {
|
||
setProvider(e.target.value);
|
||
setModel(""); // reset model when provider changes
|
||
}}
|
||
className="w-full border rounded px-2 py-1 text-sm"
|
||
>
|
||
<option value="">— tutti —</option>
|
||
{providers.map((p) => (
|
||
<option key={p} value={p}>
|
||
{p}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
id="set-provider"
|
||
type="text"
|
||
value={provider}
|
||
onChange={(e) => setProvider(e.target.value)}
|
||
placeholder="es. anthropic"
|
||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1"
|
||
data-testid="provider-freetext"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium mb-1" htmlFor="set-model">
|
||
Modello
|
||
</label>
|
||
{hasModels ? (
|
||
<select
|
||
id="set-model"
|
||
value={model}
|
||
onChange={(e) => setModel(e.target.value)}
|
||
className="w-full border rounded px-2 py-1 text-sm"
|
||
>
|
||
<option value="">— default —</option>
|
||
{modelsForProvider.map((m) => (
|
||
<option key={`${m.provider}/${m.id}`} value={m.id}>
|
||
{m.provider}/{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
id="set-model"
|
||
type="text"
|
||
value={model}
|
||
onChange={(e) => setModel(e.target.value)}
|
||
placeholder="es. glm-5.2"
|
||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1"
|
||
data-testid="model-freetext"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium mb-1" htmlFor="set-thinking">
|
||
Thinking
|
||
</label>
|
||
<select
|
||
id="set-thinking"
|
||
value={thinking}
|
||
onChange={(e) => setThinking(e.target.value)}
|
||
className="w-full border rounded px-2 py-1 text-sm"
|
||
>
|
||
{THINKING_LEVELS.map((lvl) => (
|
||
<option key={lvl} value={lvl}>
|
||
{lvl}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{!hasModels && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero.
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p className="text-xs text-destructive" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||
Annulla
|
||
</Button>
|
||
<Button type="submit" size="sm" disabled={busy}>
|
||
{busy ? "Salvataggio…" : "Salva"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SettingsDialog.test.tsx`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/SettingsDialog.tsx frontend/src/shell/SettingsDialog.test.tsx
|
||
git commit -m "feat(frontend): SettingsDialog with workspace/provider/model/thinking selects"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Wire the Settings menu into the sidebar
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/shell/AppShell.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SettingsDialog` (Task 7).
|
||
- Produces: the sidebar renders `<SettingsDialog />` directly under `<NewSessionDialog />`.
|
||
|
||
- [ ] **Step 1: Add the import and render the button**
|
||
|
||
In `frontend/src/shell/AppShell.tsx`, add to the imports:
|
||
|
||
```ts
|
||
import { SettingsDialog } from "./SettingsDialog";
|
||
```
|
||
|
||
and in the left `<aside>`, place it right after `NewSessionDialog`:
|
||
|
||
```tsx
|
||
<aside className="w-48 border-r p-2 flex flex-col gap-2">
|
||
<NewSessionDialog onCreated={handleCreated} />
|
||
<SettingsDialog />
|
||
<NavSessions
|
||
activeSessionId={activeSessionId}
|
||
onSessionSelect={handleSessionSelect}
|
||
/>
|
||
</aside>
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check and run the full frontend suite**
|
||
|
||
Run: `cd frontend && npx tsc -b && npx vitest run`
|
||
Expected: type-check clean; all tests PASS (App, NavSessions, NewSessionDialog, SettingsDialog, sessions api, etc.).
|
||
|
||
- [ ] **Step 3: Manual verification (real stack)**
|
||
|
||
Start the three layers (per `scripts/run-stack.sh` if present, or each manually) and open http://localhost:5173. Confirm:
|
||
- The sidebar shows **Nuova domanda** then **Settings**.
|
||
- "Settings" opens the dialog; workspace lists the YAMLs from `harness/workspaces`; provider/model come from Pi (or degrade to free-text with the notice if Pi has no keys); thinking shows low/medium/high; current values are pre-selected.
|
||
- Saving persists (reopen shows the saved values; `backend/data/settings.json` exists).
|
||
- "Nuova domanda" now shows only the question field; creating a question starts a session using the saved settings.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/AppShell.tsx
|
||
git commit -m "feat(frontend): add Settings menu item under Nuova domanda"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (completed during planning)
|
||
|
||
**Spec coverage:**
|
||
- Workspace from YAML → Task 3 (`/workspaces` reused) + Task 7 (workspace select). ✓
|
||
- Models/providers from Pi via real query → Task 2 (lister) + Task 3 (`/models`) + Task 7 (selects). ✓
|
||
- Thinking low/medium/high → Task 7 (`THINKING_LEVELS`, test asserts exact options). ✓
|
||
- Settings = global, backend file → Task 1 (store) + Task 3 (routes) + Task 4 (applied on session create). ✓
|
||
- New-question form only text → Task 6. ✓
|
||
- Settings menu item under Nuova domanda → Task 8. ✓
|
||
- Degradation (Pi unavailable) → Task 3 (`/models` fallback `[]`, PUT skips validation) + Task 7 (free-text). ✓
|
||
- First-run defaults → Task 3 (`effectiveSettings`). ✓
|
||
- No YAML schema change, no per-question override → respected throughout. ✓
|
||
|
||
**Placeholder scan:** none — every code/test step contains complete content.
|
||
|
||
**Type consistency:** `Settings` (backend `settings-store.ts` / frontend `api/settings.ts`), `PiModel` (backend `pi/list-models.ts` / frontend `api/models.ts`), `ListModelsFn = () => Promise<PiModel[]>`, `getSettings: () => Settings` (sync on backend), `createSession({ question, name? })`, `set_model {provider, modelId}` consumed via `spawnFor` unchanged. Names align across tasks.
|