54 KiB
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
.jsspecifiers even from.tsfiles (e.g.import { loadSettings } from "../settings/settings-store.js"). thtCLI--config/-cis per-command, appended AFTER the subcommand — already handled byThtRunner; do not change that.- Pi RPC
get_available_modelsreply envelope is{ id, type:"response", command:"get_available_models", success:true, data:{ models:[...] } }. Each model is{ provider:string, id:string, name:string, reasoning:boolean, ... }. Readresp.data.models. - Pi
set_modeltakes{ 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.jsonrelative to the backend process cwd; override viaSETTINGS_FILEenv. 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/orfrontend/), 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— addsettingsFile. - Modify
backend/src/routes/meta.ts— exportlistWorkspaces;ListModelsFnreturnsPiModel[]. - Modify
backend/src/app.ts— wire settings store, real model lister, settings routes, injectgetSettingsinto session routes. - Modify
backend/src/routes/sessions.ts—POST /sessionsreads 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; updatebackend/test/routes-sessions.test.tsandbackend/test/routes-sql-meta.test.ts.
Frontend
- Modify
frontend/src/api/models.ts— typedPiModel,listModelsreturns{ 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; updatefrontend/src/shell/NewSessionDialog.test.tsxandfrontend/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:
AppConfigfromconfig.ts. -
Produces:
interface Settings { workspace?: string; provider?: string; model?: string; thinking?: string }loadSettings(cfg: AppConfig): Settings— readscfg.settingsFileJSON;{}if missing/invalid.saveSettings(cfg: AppConfig, s: Settings): Settings—mkdir -pparent, write pretty JSON, returns.config.tsAppConfiggainssettingsFile: string;loadConfigsets it fromenv.SETTINGS_FILE ?? "data/settings.json".
-
Step 1: Write the failing test
Create backend/test/settings-store.test.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
settingsFileto config
In backend/src/config.ts, add the field to the interface and to the returned object:
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:
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:
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
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;RpcClientfrom../rpc/rpc-client.js; Nodespawn. -
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, sendsget_available_models, parsesresp.data.models, maps toPiModel[], kills the child, and caches the result forttlMs(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}}.
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:
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
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/modelsexpectations 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 /modelsreturns{ models: PiModel[] }.settings.ts:export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings;export function settingsRoutes(app, deps: { cfg: AppConfig; listModels: ListModelsFn }): voidregisteringGET /settingsandPUT /settings.effectiveSettingsfills:workspace ?? firstWorkspace(cfg),provider ?? cfg.defaults.provider,model ?? cfg.defaults.model,thinking ?? cfg.defaults.thinking.app.tsBuildAppDepsgainsgetSettings?: () => Settings.
-
Step 1: Write the failing test
Create backend/test/routes-settings.test.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:
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:
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:
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:
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:
const listModels = deps?.listModels ?? createPiModelLister(config);
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config)));
Change the route registrations to:
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
/modelsexpectations
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:
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
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 /sessionsbody is{ question: string; name?: string }. Workspace/provider/model/thinking come fromd.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:
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:
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:
import type { Settings } from "../settings/settings-store.js";
Change the function signature:
export function sessionRoutes(
app: FastifyInstance,
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings },
) {
Replace the POST /sessions handler body (lines 8–18) with:
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
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:
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:
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:
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:
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
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:
NewSessionDialogrenders only a question textarea + Crea/Annulla; on submit callscreateSession({ question })andonCreated(id). -
Step 1: Replace the test
Replace frontend/src/shell/NewSessionDialog.test.tsx with:
// 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:
// 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
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 viagetSettings, saves viaputSettings. 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:
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:
// 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
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:
import { SettingsDialog } from "./SettingsDialog";
and in the left <aside>, place it right after NewSessionDialog:
<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.jsonexists). -
"Nuova domanda" now shows only the question field; creating a question starts a session using the saved settings.
-
Step 4: Commit
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 (
/workspacesreused) + 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 (
/modelsfallback[], 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.