# 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 `. Frontend test command: `cd frontend && npx vitest run `. - 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` - 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 { 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 { 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((_, rej) => setTimeout(() => rej(new Error("pi model list timeout")), 8000)), ]); const raw = (resp?.data?.models ?? []) as Array>; 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`; `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 = {}, 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; /** * List YAML workspace configs found in /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 `. 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`; `putSettings(s: Settings): Promise`. - `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"); export const putSettings = (s: Settings) => apiFetch("/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( , ); 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(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 ( Nuova sessione