From 301db4bd8516ad2f13b9f0feb2ac6ded8b060014 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 05:05:20 +0200 Subject: [PATCH] feat: pin sessions to workspace revisions --- .../task-7-report.md | 26 +++++++ backend/src/routes/sessions.ts | 45 ++++++----- backend/src/routes/settings.ts | 2 +- backend/test/routes-sessions.test.ts | 74 ++++++++++++++++++- backend/test/routes-settings.test.ts | 13 ++++ frontend/src/api/sessions.test.ts | 13 +++- frontend/src/api/sessions.ts | 28 ++++++- .../src/shell/AppShell.new-session.test.tsx | 16 ++-- frontend/src/shell/NewSessionDialog.test.tsx | 11 ++- frontend/src/shell/SteerInput.test.tsx | 20 +++++ frontend/src/shell/SteerInput.tsx | 35 +++++---- frontend/src/shell/f1-loop.test.tsx | 4 + frontend/src/workspaces/preferences.test.ts | 15 ++++ frontend/src/workspaces/preferences.ts | 40 ++++++++++ 14 files changed, 288 insertions(+), 54 deletions(-) create mode 100644 frontend/src/workspaces/preferences.test.ts create mode 100644 frontend/src/workspaces/preferences.ts diff --git a/.superpowers/sdd/2026-08-03-git-workspace-registry/task-7-report.md b/.superpowers/sdd/2026-08-03-git-workspace-registry/task-7-report.md index 740b469c..c0fc0cea 100644 --- a/.superpowers/sdd/2026-08-03-git-workspace-registry/task-7-report.md +++ b/.superpowers/sdd/2026-08-03-git-workspace-registry/task-7-report.md @@ -32,3 +32,29 @@ The unscoped backend suite was also run. The Task 7 code regressions in `test/th were fixed; the remaining failures were existing sandbox restrictions on tests that listen on `127.0.0.1` (`listen EPERM: operation not permitted` in SSE/e2e health tests), not application assertions. + +## Review fixes — round 1 + +- Every new session now resolves `workspaceId` through the registry; an omitted value uses the + configured installation default and persists both the resolved ID and revision. Callers cannot + bypass revision pinning by supplying a workspace ID. +- Browser-local preferences now migrate once from the read-only legacy settings response and hold + workspace, provider, model, and thinking. Session creation includes those selections, including + direct entry points that run before the composer mounts. The frontend no longer `PUT`s shared + settings. +- The settings compatibility endpoint honors a stored installation workspace before falling back + to the first workspace configuration. +- Resume rejects finalized and archived sessions before looking up any pinned snapshot, preserving + the read-only response even when a historical snapshot is unavailable. + +### Review verification + +- RED: the added backend tests failed for omitted-default pinning, read-only resume ordering, and + stored-default precedence; the added frontend preference tests failed because preferences were + neither stored nor included in session requests. +- GREEN: `npx vitest run test/tht-runner.test.ts test/routes-sessions.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .` + — 100 tests passed with a clean type check. +- GREEN: `npx vitest run && npx tsc -b` — 332 frontend tests passed with a clean type check. +- GREEN: `THT_HOME=/private/tmp/thothii-task7-home .venv/bin/pytest tests/test_session_documents.py tests/test_session_mutations.py -q` + — 22 tests passed (one existing testcontainers deprecation warning). +- `git diff --check` completed cleanly. diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 1783f16b..c2fab50c 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -207,29 +207,34 @@ export function sessionRoutes( let s: Settings; try { s = await d.getSettings(principal); } catch { return storageFailure(reply); } const runner = runnerFor(principal); - let workspaceConfigPath = s.workspace; + const requestedWorkspaceId = b.workspaceId ?? s.workspace; + if (!requestedWorkspaceId) { + return reply.code(409).send({ + error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, + code: "workspace_revision_unavailable", + }); + } + let workspaceConfigPath: string | undefined; let workspaceId: string | undefined; let workspaceRevision: string | undefined; let allowedModels: readonly string[] | undefined; - if (b.workspaceId) { - try { - const resolved = await d.workspaceRegistry.read(b.workspaceId); - if (resolved.revision.state !== "operational") { - return reply.code(409).send({ - error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, - code: "workspace_revision_unavailable", - }); - } - workspaceConfigPath = resolved.revision.snapshotPath; - workspaceId = resolved.revision.id; - workspaceRevision = resolved.revision.commit; - allowedModels = resolved.workspace.llm_policy.allowed; - } catch { + try { + const resolved = await d.workspaceRegistry.read(requestedWorkspaceId); + if (resolved.revision.state !== "operational") { return reply.code(409).send({ error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, code: "workspace_revision_unavailable", }); } + workspaceConfigPath = resolved.revision.snapshotPath; + workspaceId = resolved.revision.id; + workspaceRevision = resolved.revision.commit; + allowedModels = resolved.workspace.llm_policy.allowed; + } catch { + return reply.code(409).send({ + error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, + code: "workspace_revision_unavailable", + }); } const provider = b.provider ?? s.provider; const model = b.model ?? s.model; @@ -378,6 +383,11 @@ export function sessionRoutes( } catch { return storageFailure(reply); } if (!manifest) return reply.code(404).send({ error: "session not found" }); const runner = runnerFor(principal); + // Read-only contract FIRST: finalized or archived sessions never attempt compatibility + // resolution, even when their historical snapshot was subsequently pruned. + if (manifest?.status === "finalized" || manifest?.archived) { + return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); + } const saved = manifest as { provider?: string; model?: string; thinking?: string; workspace_id?: string; workspace_revision?: string; @@ -397,11 +407,6 @@ export function sessionRoutes( }); } } - // Read-only contract FIRST: a finalized/archived session must refuse resume even - // when a lingering runtime still looks active — the manifest is the truth. - if (manifest?.status === "finalized" || manifest?.archived) { - return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); - } // This check belongs inside the per-session lock: a preceding cold Resume may have // installed a running runtime while this request was waiting. const existing = d.mgr.get(id); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 31607ad4..8127b657 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -9,7 +9,7 @@ import type { PrincipalContext } from "../auth/principal.js"; export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings { const workspaces = listWorkspaces(cfg.harnessDir); return { - workspace: workspaces[0]?.name ?? stored.workspace, + workspace: stored.workspace ?? workspaces[0]?.name, provider: cfg.defaults.provider ?? stored.provider, model: cfg.defaults.model ?? stored.model, thinking: cfg.defaults.thinking ?? stored.thinking, diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 4499948a..c8cd36ca 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -3,13 +3,31 @@ import { spawn as nodeSpawn } from "node:child_process"; import path from "node:path"; import os from "node:os"; import { chmodSync, unlinkSync, writeFileSync } from "node:fs"; -import { buildApp } from "../src/app.js"; +import { buildApp as buildRealApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { SseHub } from "../src/sse/sse-hub.js"; const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json"); +const defaultWorkspaceRegistry = { + read: vi.fn(async (id: string) => ({ + workspace: { + llm_policy: { + allowed: ["zai/glm-5.2", "deepseek/deepseek-v4-pro", "local-qwen/qwen3.6-35b-a3b"], + }, + }, + revision: { + id, commit: "e".repeat(40), blob: "f".repeat(40), + snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`, state: "operational", + }, + })), +}; + +function buildApp(config: Parameters[0], deps: Record = {}) { + return buildRealApp(config, { workspaceRegistry: defaultWorkspaceRegistry as any, ...deps } as any); +} + function mutApp(thtRunner: any) { return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner }, @@ -186,6 +204,35 @@ test("creates a session from the active immutable workspace revision", async () })); }); +test("creates a session from the configured default workspace revision when workspaceId is omitted", async () => { + const sessionNew = vi.fn(async () => ({ id: "default-pinned" })); + const registry = { + read: vi.fn(async (id: string) => ({ + workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + revision: { + id, commit: "c".repeat(40), blob: "d".repeat(40), + snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, state: "operational", + }, + })), + }; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { sessionNew, searchPack: async () => {} } as any, + readiness: { ensure: async () => ({ ok: true }) } as any, + mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any, + getSettings: () => ({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" }) as any, + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + workspaceRegistry: registry as any, + }); + + await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + + expect(registry.read).toHaveBeenCalledWith("psd-clinical"); + expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({ + workspaceId: "psd-clinical", workspaceRevision: "c".repeat(40), + workspaceConfigPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/psd-clinical.yaml`, + })); +}); + test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => { const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`); writeFileSync(modelKey, "test-model-key", { mode: 0o600 }); @@ -209,7 +256,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a }); const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); expect(created.json()).toEqual({ id: "s1" }); - expect(sessionNewArg.workspaceConfigPath).toBe("w"); + expect(sessionNewArg.workspaceConfigPath).toContain(`/snapshots/${"e".repeat(40)}/w.yaml`); expect(sessionNewArg.provider).toBe("zai"); expect(sessionNewArg.model).toBe("glm-5.2"); expect(sessionNewArg.thinking).toBe("high"); @@ -465,6 +512,25 @@ test("POST /sessions/:id/resume returns a sanitized error when its retained revi expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" }); }); +test("POST /sessions/:id/resume refuses a pinned finalized session before reading its snapshot", async () => { + const readPinned = vi.fn(async () => { throw new Error("must not resolve"); }); + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + sessionShow: async () => ({ + status: "finalized", archived: false, workspace_id: "psd-clinical", workspace_revision: "a".repeat(40), + }), + } as any, + getSettings: () => ({ workspace: "legacy" }) as any, + workspaceRegistry: { readPinned } as any, + }); + + const response = await app.inject({ method: "POST", url: "/sessions/pinned-final/resume" }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ error: expect.stringMatching(/sola lettura/i) }); + expect(readPinned).not.toHaveBeenCalled(); +}); + test("GET /sessions/:id warns when a legacy manifest has no workspace revision", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { mgr: { @@ -1801,7 +1867,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { }); const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); expect(res.json()).toEqual({ id: "s1" }); - expect(ensureWs).toBe("psd"); + expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`); }); test("POST /sessions rejects an unavailable saved model before persisting a session", async () => { @@ -1842,7 +1908,7 @@ test("POST /sessions marks a persisted session failed when runtime construction sessionNew: async () => ({ id: "s-runtime-failure" }), failSession: async (id: string, workspace: string) => { expect(id).toBe("s-runtime-failure"); - expect(workspace).toBe("psd"); + expect(workspace).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`); failed += 1; }, } as any, diff --git a/backend/test/routes-settings.test.ts b/backend/test/routes-settings.test.ts index 27b8d79e..f15f4e6a 100644 --- a/backend/test/routes-settings.test.ts +++ b/backend/test/routes-settings.test.ts @@ -31,6 +31,19 @@ test("GET /settings returns effective defaults (env provider/model/thinking, fir } }); +test("GET /settings uses the stored installation workspace default before the harness fallback", async () => { + const { app, dir } = appWithTmpSettings({}); + try { + writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "psd-clinical" })); + + const response = await app.inject({ method: "GET", url: "/settings" }); + + expect(response.json()).toMatchObject({ workspace: "psd-clinical" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("PUT /settings does not persist personal workspace or LLM choices", 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 }], diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index c3491feb..40a0614f 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -6,16 +6,25 @@ import { deleteSession, getSessionDocuments, } from "./sessions"; -test("createSession POSTs only {question} and returns the id", async () => { +test("createSession migrates legacy selections and POSTs browser preferences", async () => { + localStorage.clear(); let body: unknown = null; server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ + workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + })), 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" }); + expect(body).toEqual({ + question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + }); + expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + }); }); test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => { diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 2067ecfd..3c6b4181 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,10 +1,34 @@ import { apiFetch } from "./client"; +import { getSettings } from "./settings"; +import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences"; import type { Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, } from "./types"; -export const createSession = (i: { question: string; name?: string }) => - apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) }); +type NewSessionInput = { question: string; name?: string } & Partial; + +async function selectedPreferences(): Promise { + const saved = workspacePreferences.load(); + if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved; + + // A direct new-session entry point can run before the composer has mounted. Seed its + // browser-local preferences from the legacy read-only defaults once, then keep them local. + const legacy = await getSettings(); + return workspacePreferences.save({ + workspaceId: saved.workspaceId ?? legacy.workspace, + provider: saved.provider ?? legacy.provider, + model: saved.model ?? legacy.model, + thinking: saved.thinking ?? legacy.thinking, + }); +} + +export async function createSession(i: NewSessionInput) { + const preferences = await selectedPreferences(); + return apiFetch<{ id: string }>("/sessions", { + method: "POST", + body: JSON.stringify({ ...preferences, ...i }), + }); +} /** Best-effort warm-up; callers must not await it before showing the composer. */ export const prewarmRuntime = () => diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index e1020410..407c434a 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -13,6 +13,7 @@ function renderShell() { } beforeEach(() => { + localStorage.clear(); FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); @@ -121,8 +122,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi expect(FakeEventSource.instances).toHaveLength(0); }); -test("model selector shows the three Pi-enabled models and persists the selected provider", async () => { - let saved: unknown; +test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => { server.use( http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", @@ -132,11 +132,6 @@ test("model selector shows the three Pi-enabled models and persists the selected { provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true }, { provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false }, ] })), - http.put("http://localhost:8787/settings", async ({ request }) => { - const body = await request.json() as Record; - saved = body; - return HttpResponse.json(body); - }), ); renderShell(); @@ -147,9 +142,10 @@ test("model selector shows the three Pi-enabled models and persists the selected ]); }); await userEvent.selectOptions(select, "qwen3.6-35b-a3b"); - await waitFor(() => expect(saved).toMatchObject({ - provider: "local-qwen", model: "qwen3.6-35b-a3b", - })); + await waitFor(() => expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)) + .toEqual({ + workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", + })); }); diff --git a/frontend/src/shell/NewSessionDialog.test.tsx b/frontend/src/shell/NewSessionDialog.test.tsx index aa276a3e..bee5edb5 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -27,9 +27,13 @@ test("the form has only a question field (no workspace/model/provider/thinking)" expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument(); }); -test("submitting posts only { question } and calls onCreated", async () => { +test("submitting includes browser-local migrated preferences and calls onCreated", async () => { + localStorage.clear(); let body: unknown = null; server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ + workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", + })), http.post("http://localhost:8787/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); @@ -40,7 +44,10 @@ test("submitting posts only { question } and calls onCreated", async () => { await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?"); await userEvent.click(screen.getByRole("button", { name: /^create$/i })); - await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" })); + await waitFor(() => expect(body).toEqual({ + question: "Quante vendite nel 2025?", + workspaceId: "default", provider: "zai", model: "glm-5.2", thinking: "low", + })); await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1")); }); diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index efa2d656..aa1c89b7 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -8,6 +8,7 @@ import { useSessionStore } from "../store/sessionStore"; import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput"; beforeEach(() => { + localStorage.clear(); server.use( http.post("http://localhost:8787/sessions/:id/steer", () => new HttpResponse(null, { status: 204 }), @@ -15,6 +16,25 @@ beforeEach(() => { ); }); +test("new sessions send the browser-selected workspace, model, provider, and thinking", async () => { + let body: unknown; + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", + })); + server.use(http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + })); + render(); + + await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => expect(body).toEqual({ + question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", + })); +}); + test("renders a text input and submit button", () => { render(); expect(screen.getByRole("textbox")).toBeInTheDocument(); diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index f07d59d0..7426050a 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -1,13 +1,14 @@ // frontend/src/shell/SteerInput.tsx import { useEffect, useRef, useState } from "react"; import { CornerDownLeft } from "lucide-react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; import { ApiError } from "../api/client"; -import { getSettings, putSettings, type Settings } from "../api/settings"; +import { getSettings } from "../api/settings"; import { listWorkspaces } from "../api/workspaces"; import { listModels } from "../api/models"; import { useSessionStore } from "../store/sessionStore"; +import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences"; const THINKING_LEVELS = ["low", "medium", "high"] as const; @@ -151,25 +152,33 @@ export function SteerInput({ /** * Status strip beneath the composer. Left: the active workspace selector. - * Right: live context usage plus model + thinking-level selectors (these replace - * the old Settings dialog and persist through PUT /settings). + * Right: live context usage plus browser-local workspace/model/thinking selectors. */ export function ComposerFooter() { - const qc = useQueryClient(); const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings }); const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces }); const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels }); const models = modelsData?.models ?? []; const tokenUsage = useSessionStore((state) => state.tokenUsage); + const [preferences, setPreferences] = useState(() => workspacePreferences.load()); - const workspace = settings?.workspace ?? ""; - const model = settings?.model ?? ""; - const thinking = settings?.thinking ?? "medium"; + useEffect(() => { + if (!settings) return; + setPreferences(workspacePreferences.migrate({ + workspaceId: settings.workspace, + provider: settings.provider, + model: settings.model, + thinking: settings.thinking, + })); + }, [settings]); - // PUT /settings replaces the whole object, so always send the merged settings. - async function update(patch: Partial) { - await putSettings({ workspace, provider: settings?.provider, model, thinking, ...patch }); - qc.invalidateQueries({ queryKey: ["settings"] }); + const workspace = preferences.workspaceId ?? settings?.workspace ?? ""; + const model = preferences.model ?? settings?.model ?? ""; + const thinking = preferences.thinking ?? settings?.thinking ?? "medium"; + + function update(patch: WorkspacePreference) { + const next = workspacePreferences.save({ ...preferences, ...patch }); + setPreferences(next); } function onModelChange(id: string) { @@ -185,7 +194,7 @@ export function ComposerFooter() { return (
- update({ workspace: v })}> + update({ workspaceId: v })}> {workspaces.length === 0 ? ( ) : ( diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx index 57982c69..b57eba47 100644 --- a/frontend/src/shell/f1-loop.test.tsx +++ b/frontend/src/shell/f1-loop.test.tsx @@ -9,6 +9,7 @@ import { App } from "../App"; import { useSessionStore } from "../store/sessionStore"; beforeEach(() => { + localStorage.clear(); FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); @@ -19,6 +20,9 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async server.use( http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })), http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), + http.get("http://localhost:8787/settings", () => HttpResponse.json({ + workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", + })), http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "default", file: "default.db" }]), ), diff --git a/frontend/src/workspaces/preferences.test.ts b/frontend/src/workspaces/preferences.test.ts new file mode 100644 index 00000000..a4616a4b --- /dev/null +++ b/frontend/src/workspaces/preferences.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { workspacePreferences } from "./preferences"; + +test("migrates legacy selections once and keeps later browser choices", () => { + localStorage.clear(); + + expect(workspacePreferences.migrate({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + })).toMatchObject({ workspaceId: "psd-clinical", model: "glm-5.2" }); + workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high" }); + + expect(workspacePreferences.migrate({ workspaceId: "legacy" })).toMatchObject({ + workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high", + }); +}); diff --git a/frontend/src/workspaces/preferences.ts b/frontend/src/workspaces/preferences.ts new file mode 100644 index 00000000..53c159b4 --- /dev/null +++ b/frontend/src/workspaces/preferences.ts @@ -0,0 +1,40 @@ +export interface WorkspacePreference { + workspaceId?: string; + provider?: string; + model?: string; + thinking?: string; +} + +const STORAGE_KEY = "thothii.workspace-registry.v1.preferences"; + +function storage(): Storage | undefined { + try { return window.localStorage; } catch { return undefined; } +} + +function parse(raw: string | null): WorkspacePreference | undefined { + if (raw === null) return undefined; + try { + const value = JSON.parse(raw); + return value && typeof value === "object" && !Array.isArray(value) ? value as WorkspacePreference : undefined; + } catch { + return undefined; + } +} + +/** Anonymous, non-secret browser choices for the workspace session composer. */ +export const workspacePreferences = { + load(): WorkspacePreference { + return parse(storage()?.getItem(STORAGE_KEY) ?? null) ?? {}; + }, + + save(value: WorkspacePreference): WorkspacePreference { + try { storage()?.setItem(STORAGE_KEY, JSON.stringify(value)); } catch { /* storage is optional */ } + return value; + }, + + /** Copy the pre-local-storage server values only when this browser has no saved choice. */ + migrate(legacy: WorkspacePreference): WorkspacePreference { + const current = storage()?.getItem(STORAGE_KEY); + return current === null || current === undefined ? this.save(legacy) : this.load(); + }, +};