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

54 KiB
Raw Blame History

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:

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:

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; 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}}.

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 /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:

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 /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:

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 /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:

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: 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:

// 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 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:

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.json exists).

  • "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 (/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.