Files
ThothII/docs/superpowers/plans/2026-06-28-settings-menu.md
T
2026-06-28 15:59:47 +02:00

1507 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.