From fd3b62ce6fee9f5244e7e8de36d37448be3b2d4a Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 17:43:10 +0200 Subject: [PATCH] refactor: stop persisting workspace state in the browser --- frontend/src/api/sessions.test.ts | 28 ++-- frontend/src/api/sessions.ts | 4 +- .../src/shell/AppShell.new-session.test.tsx | 4 +- frontend/src/shell/NewSessionDialog.test.tsx | 7 +- frontend/src/shell/SteerInput.test.tsx | 33 +++-- frontend/src/shell/SteerInput.tsx | 2 +- frontend/src/test/setup.ts | 2 + frontend/src/workspaces/drafts.test.ts | 139 ++---------------- frontend/src/workspaces/drafts.ts | 123 ---------------- frontend/src/workspaces/preferences.test.ts | 32 +++- frontend/src/workspaces/preferences.ts | 37 ++--- 11 files changed, 101 insertions(+), 310 deletions(-) diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index a79e8249..996bb93c 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -6,8 +6,9 @@ import { renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, getSessionDocuments, } from "./sessions"; +import { workspacePreferences } from "../workspaces/preferences"; -test("createSession migrates legacy selections and POSTs browser preferences", async () => { +test("createSession seeds ephemeral selections and posts them", async () => { localStorage.clear(); let body: unknown = null; server.use( @@ -33,15 +34,16 @@ test("createSession migrates legacy selections and POSTs browser preferences", a 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({ + expect(workspacePreferences.load()).toEqual({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", }); + expect(localStorage.getItem("thothii.workspace-registry.v1.preferences")).toBeNull(); }); test("createSession does not POST when a selected summary aliases another workspace name", async () => { - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", - })); + }); let posted = false; server.use( http.get("/api/workspaces", () => HttpResponse.json([{ @@ -74,9 +76,9 @@ test.each([ ["an unknown revision field", { generation: 1 }], ["a malformed revision", { commit: "not-a-commit" }], ])("createSession does not POST when the selected summary revision has %s", async (_case, revisionPatch) => { - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", - })); + }); const revision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", ...revisionPatch, @@ -107,10 +109,10 @@ test.each([ expect(posted).toBe(false); }); -test("createSession replaces a stale browser workspace with the current installation default", async () => { - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ +test("createSession replaces a stale ephemeral workspace with the current installation default", async () => { + workspacePreferences.save({ workspaceId: "retired-workspace", provider: "zai", model: "glm-5.2", thinking: "low", - })); + }); let body: unknown; server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), @@ -137,15 +139,15 @@ test("createSession replaces a stale browser workspace with the current installa question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", }); - expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({ + expect(workspacePreferences.load()).toEqual({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", }); }); test("createSession preserves a local legacy selection when the registry is empty", async () => { - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "legacy-workspace", provider: "zai", model: "glm-5.2", thinking: "low", - })); + }); let body: unknown; server.use( http.get("/api/workspaces", () => HttpResponse.json([])), @@ -182,7 +184,7 @@ test("createSession rejects a workspace summary that omits the canonical revisio ); await expect(createSession({ question: "q" })).rejects.toMatchObject({ - message: "Could not load selected workspace policy. Please retry.", + message: "Could not load workspace registry. Please retry.", }); expect(posted).toBe(false); }); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 508f5d94..30e918e1 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -15,8 +15,8 @@ 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. + // A direct new-session entry point can run before the composer has mounted. Seed current + // application memory from the installation defaults once, then keep choices ephemeral. const legacy = await getSettings(); return workspacePreferences.save({ workspaceId: saved.workspaceId ?? legacy.workspace, diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index a7c5a14d..737c91a1 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -6,6 +6,7 @@ import { server } from "../test/msw"; import { FakeEventSource } from "../test/fakeEventSource"; import { useSessionStore } from "../store/sessionStore"; import { AppShell } from "./AppShell"; +import { workspacePreferences } from "../workspaces/preferences"; function renderShell() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -143,8 +144,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p ]); }); await userEvent.selectOptions(select, "qwen3.6-35b-a3b"); - await waitFor(() => expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)) - .toEqual({ + await waitFor(() => expect(workspacePreferences.load()).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 3d48f423..9c376b05 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { NewSessionDialog } from "./NewSessionDialog"; +import { workspacePreferences } from "../workspaces/preferences"; function renderDialog() { const onCreated = vi.fn(); @@ -28,7 +29,7 @@ test("the form has only a question field (no workspace/model/provider/thinking)" expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument(); }); -test("submitting includes browser-local migrated preferences and calls onCreated", async () => { +test("submitting includes ephemeral migrated preferences and calls onCreated", async () => { localStorage.clear(); let body: unknown = null; server.use( @@ -71,9 +72,9 @@ test("first-run direct dialog creation waits for registry policy without a mount const revision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }; - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); + }); server.use( http.get("/api/workspaces", () => { summaryRequestStarted = true; diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 6366e058..903810db 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; import { canonicalWorkspaceFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { useSessionStore } from "../store/sessionStore"; +import { workspacePreferences } from "../workspaces/preferences"; import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput"; beforeEach(() => { @@ -17,11 +18,11 @@ beforeEach(() => { ); }); -test("new sessions send the browser-selected workspace, model, provider, and thinking", async () => { +test("new sessions send the ephemeral workspace, model, provider, and thinking", async () => { let body: unknown; - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", - })); + }); server.use( http.get("/api/workspaces", () => HttpResponse.json([{ ...workspaceSummaryFixture("psd-clinical", { @@ -203,9 +204,9 @@ test("footer limits model choices to the selected workspace policy", async () => test("switching workspaces replaces an out-of-policy model before session creation", async () => { let body: unknown; - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); + }); const revision = (id: string) => ({ id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); @@ -256,9 +257,9 @@ test("immediate submit waits for a switched workspace policy before creating a s let releasePolicy!: () => void; let policyRequestStarted = false; const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); + }); const revision = (id: string) => ({ id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); @@ -312,9 +313,9 @@ test("initial restored workspace waits for its delayed policy before creating a let releasePolicy!: () => void; let policyRequestStarted = false; const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); + }); const revision = (id: string) => ({ id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); @@ -359,9 +360,9 @@ test("initial restored workspace waits for its delayed policy before creating a test("initial submit rejects a workspace summary that omits the canonical revision", async () => { let body: unknown; let failure: string | undefined; - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "broken-workspace", provider: "zai", model: "glm-5.2", thinking: "medium", - })); + }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })), http.get("/api/workspaces", () => HttpResponse.json([{ @@ -384,7 +385,7 @@ test("initial submit rejects a workspace summary that omits the canonical revisi await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); await userEvent.click(screen.getByRole("button", { name: /send/i })); - await waitFor(() => expect(failure).toBe("Could not load selected workspace policy. Please retry.")); + await waitFor(() => expect(failure).toBe("Could not load workspace registry. Please retry.")); expect(body).toBeUndefined(); }); @@ -392,9 +393,9 @@ test("failed workspace summaries block creation and report a safe error", async let body: unknown; let failure: string | undefined; let summaryRequestFailed = false; - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", - })); + }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), http.get("/api/workspaces", () => { @@ -432,9 +433,9 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone let bPolicyRequestStarted = false; let cPolicyRequestStarted = false; const cPolicyMayFinish = new Promise((resolve) => { releaseC = resolve; }); - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); + }); const revision = (id: string) => ({ id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 59194b92..0e4c721a 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -156,7 +156,7 @@ export function SteerInput({ /** * Status strip beneath the composer. Left: the active workspace selector. - * Right: live context usage plus browser-local workspace/model/thinking selectors. + * Right: live context usage plus ephemeral workspace/model/thinking selectors. */ export function ComposerFooter() { const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings }); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index ed2ee3d1..31816c3c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,5 +1,6 @@ import "@testing-library/jest-dom/vitest"; import { server } from "./msw"; +import { workspacePreferences } from "../workspaces/preferences"; // Node 25 ships an experimental global `localStorage` that shadows jsdom's Storage and // lacks spec methods like clear(), so tests calling localStorage.clear()/setItem() throw. @@ -37,5 +38,6 @@ if (!window.matchMedia) { } beforeAll(() => server.listen()); +beforeEach(() => workspacePreferences.reset()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts index 96a177c4..3d00e05f 100644 --- a/frontend/src/workspaces/drafts.test.ts +++ b/frontend/src/workspaces/drafts.test.ts @@ -1,129 +1,18 @@ -import { beforeEach, expect, test } from "vitest"; -import type { CanonicalWorkspace } from "../api/workspaces"; -import { - sanitizeCanonicalWorkspace, - workspaceBootstrapDrafts, - workspacePreferences, - type WorkspaceBootstrapDraft, -} from "./drafts"; +import { expect, test } from "vitest"; +import { canonicalWorkspaceFixture } from "../test/workspace-fixtures"; +import * as workspaceModule from "./drafts"; -const workspace: CanonicalWorkspace = { - workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, - dwh: { - engine: "postgres", database: "clinical", schema: "datawarehouse", - supported_transports: ["postgres_direct"], - }, - semantic_index: { - vector_store: { - engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine", - }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, -}; - -const bootstrapDraft: WorkspaceBootstrapDraft = { - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - workspace, - updatedAt: "2026-08-04T10:00:00.000Z", -}; - -const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 }; - -const evidenceWorkspace: CanonicalWorkspace = { - ...workspace, - evidence: { - source: { - type: "filesystem", - uri: "psd-clinical/evidence", - patterns: ["documents/**/*.pdf", "notes/*.md"], - max_bytes: 12_000_000, - }, - policy, - }, -}; - -beforeEach(() => localStorage.clear()); - -test("keeps an anonymous user's model selection in browser storage", () => { - workspacePreferences.save({ - workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", - }); - - expect(workspacePreferences.load()).toMatchObject({ model: "glm-5.2" }); +test("workspace module exposes validation and in-memory coordination but no draft storage", () => { + expect(workspaceModule.sanitizeCanonicalWorkspace(canonicalWorkspaceFixture("psd-clinical"))) + .toEqual(canonicalWorkspaceFixture("psd-clinical")); + expect(workspaceModule).not.toHaveProperty("workspaceBootstrapDrafts"); + expect(workspaceModule).not.toHaveProperty("workspaceDrafts"); + expect(workspaceModule).not.toHaveProperty("workspaceDeletionDrafts"); }); -test("reloads a bootstrap draft from the v2 browser-storage key and discards it by workspace ID", () => { - workspaceBootstrapDrafts.save(bootstrapDraft); - - expect(workspaceBootstrapDrafts.load("psd-clinical")).toEqual(bootstrapDraft); - expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).toContain('"baseCommit"'); - expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).not.toContain("baseBlob"); - - workspaceBootstrapDrafts.discard("psd-clinical"); - expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); -}); - -test("purges known v1 update and deletion keys without touching unrelated localStorage", () => { - localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({ - workspaceId: "psd-clinical", - baseCommit: "a".repeat(40), - baseBlob: "b".repeat(40), - workspace, - updatedAt: "2026-08-04T10:00:00.000Z", - })); - localStorage.setItem("thothii.workspace-registry.v1.delete.psd-clinical", JSON.stringify({ - id: "psd-clinical", - baseCommit: "a".repeat(40), - baseBlob: "b".repeat(40), - updatedAt: "2026-08-04T10:00:00.000Z", - })); - localStorage.setItem("unrelated", "keep-me"); - - expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); - expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); - expect(localStorage.getItem("thothii.workspace-registry.v1.delete.psd-clinical")).toBeNull(); - expect(localStorage.getItem("unrelated")).toBe("keep-me"); -}); - -test("rejects draft payloads that still carry baseBlob from the removed update flow", () => { - localStorage.setItem("thothii.workspace-registry.v2.bootstrap.psd-clinical", JSON.stringify({ - ...bootstrapDraft, - baseBlob: "b".repeat(40), - })); - - expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); -}); - -test("accepts only the workspace directory evidence root for filesystem sources", () => { - const sanitized = sanitizeCanonicalWorkspace(evidenceWorkspace); - - expect(sanitized).toEqual(evidenceWorkspace); - expect(sanitized).not.toBe(evidenceWorkspace); - expect(sanitized?.evidence).not.toBe(evidenceWorkspace.evidence); -}); - -test("rejects filesystem Evidence that still points at workspace-content", () => { - const invalid: CanonicalWorkspace = { - ...evidenceWorkspace, - evidence: { - source: { - type: "filesystem", - uri: "workspace-content/psd-clinical/evidence", - patterns: ["documents/**/*.pdf", "notes/*.md"], - max_bytes: 12_000_000, - }, - policy, - }, - }; - - expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined(); - workspaceBootstrapDrafts.save({ ...bootstrapDraft, workspace: invalid }); - expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined(); -}); - -test("continues to sanitize workspaces without Evidence", () => { - expect(sanitizeCanonicalWorkspace(workspace)).toEqual(workspace); - expect(sanitizeCanonicalWorkspace(workspace)).not.toHaveProperty("evidence"); +test("canonical sanitizer rejects unknown fields rather than persisting them", () => { + expect(workspaceModule.sanitizeCanonicalWorkspace({ + ...canonicalWorkspaceFixture("psd-clinical"), + localDraft: { secret: "must-not-survive" }, + })).toBeUndefined(); }); diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 0e701025..148b7d97 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -4,16 +4,6 @@ import type { } from "../api/workspaces"; export { workspacePreferences, type WorkspacePreference } from "./preferences"; -export interface WorkspaceBootstrapDraft { - workspaceId: string; - baseCommit: string; - workspace: CanonicalWorkspace; - updatedAt: string; -} - -/** @deprecated Use WorkspaceBootstrapDraft. */ -export type WorkspaceDraft = WorkspaceBootstrapDraft; - export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry."; export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry."; @@ -117,14 +107,6 @@ export const workspacePolicyGate = { }, }; -const LEGACY_PREFIX = "thothii.workspace-registry.v1"; -const PREFIX = "thothii.workspace-registry.v2"; -const DRAFT_PREFIX = `${PREFIX}.bootstrap.`; - -function storage(): Storage | undefined { - try { return window.localStorage; } catch { return undefined; } -} - function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -473,108 +455,3 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | ...(evidence ? { evidence } : {}), }; } - -function repairBootstrapWorkspace(value: unknown, id: string): CanonicalWorkspace | undefined { - const direct = sanitizeCanonicalWorkspace(value); - if (direct) return direct; - const source = exactRecord(value, [ - "workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence", - ]); - const evidence = exactRecord(source?.evidence, ["source", "policy"]); - const evidenceSource = exactRecord(evidence?.source, ["type", "uri", "patterns", "max_bytes"]); - if ( - source - && evidence - && evidenceSource?.type === "filesystem" - && typeof evidenceSource.uri === "string" - && /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(evidenceSource.uri) - ) { - return sanitizeCanonicalWorkspace({ - ...source, - evidence: { - ...evidence, - source: { - ...evidenceSource, - uri: `${id}/evidence`, - }, - }, - }); - } - return undefined; -} - -function normalize(value: unknown): WorkspaceBootstrapDraft | undefined { - const source = exactRecord(value, ["workspaceId", "baseCommit", "workspace", "updatedAt"]); - const id = workspaceId(source?.workspaceId); - const workspace = id ? repairBootstrapWorkspace(source?.workspace, id) : undefined; - const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit) ? source.baseCommit : undefined; - const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt)) ? source.updatedAt : undefined; - if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || !updatedAt) { - return undefined; - } - return { - workspaceId: id, - baseCommit, - workspace, - updatedAt, - }; -} - -function key(id: string): string { - return `${DRAFT_PREFIX}${encodeURIComponent(id)}`; -} - -function purgeLegacyRegistryDrafts(): void { - const store = storage(); - if (!store) return; - const keys = Array.from({ length: store.length }, (_, index) => store.key(index)).filter((value): value is string => value !== null); - for (const entry of keys) { - if (entry.startsWith(`${LEGACY_PREFIX}.draft.`) || entry.startsWith(`${LEGACY_PREFIX}.delete.`)) { - store.removeItem(entry); - } - } -} - -/** Browser-only bootstrap drafts. Saving or editing one never calls the server. */ -export const workspaceBootstrapDrafts = { - load(id: string): WorkspaceBootstrapDraft | undefined { - purgeLegacyRegistryDrafts(); - try { - const raw = storage()?.getItem(key(id)); - return raw ? normalize(JSON.parse(raw)) : undefined; - } catch { - return undefined; - } - }, - - save(draft: WorkspaceBootstrapDraft): void { - purgeLegacyRegistryDrafts(); - const safe = normalize(draft); - if (!safe) return; - try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ } - }, - - discard(id: string): void { - purgeLegacyRegistryDrafts(); - try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ } - }, -}; - -/** @deprecated Use workspaceBootstrapDrafts. */ -export const workspaceDrafts = workspaceBootstrapDrafts; - - -/** @deprecated Removed in P1.1; existing workspaces are curator-owned and deletions are not drafted in-browser. */ -export interface WorkspaceDeletionDraft { - id: string; - baseCommit: string; - baseBlob: string; - updatedAt: string; -} - -/** @deprecated Removed in P1.1; kept temporarily so legacy imports compile during the UI transition. */ -export const workspaceDeletionDrafts = { - load(_id: string): WorkspaceDeletionDraft | undefined { return undefined; }, - save(_draft: WorkspaceDeletionDraft): void {}, - discard(_id: string): void {}, -}; diff --git a/frontend/src/workspaces/preferences.test.ts b/frontend/src/workspaces/preferences.test.ts index a4616a4b..8a7aabcd 100644 --- a/frontend/src/workspaces/preferences.test.ts +++ b/frontend/src/workspaces/preferences.test.ts @@ -1,15 +1,39 @@ -import { expect, test } from "vitest"; +import { beforeEach, expect, test } from "vitest"; import { workspacePreferences } from "./preferences"; -test("migrates legacy selections once and keeps later browser choices", () => { +beforeEach(() => { localStorage.clear(); + workspacePreferences.reset(); +}); +test("keeps selections only in current application memory", () => { + localStorage.setItem("unrelated", "keep-me"); + workspacePreferences.save({ + workspaceId: "research", + provider: "deepseek", + model: "deepseek-v4-pro", + thinking: "high", + }); + + expect(workspacePreferences.load()).toEqual({ + workspaceId: "research", + provider: "deepseek", + model: "deepseek-v4-pro", + thinking: "high", + }); + expect(localStorage.length).toBe(1); + expect(localStorage.getItem("unrelated")).toBe("keep-me"); + expect(localStorage.getItem("thothii.workspace-registry.v1.preferences")).toBeNull(); +}); + +test("seeds memory from backend settings once and returns defensive copies", () => { 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" }); + const snapshot = workspacePreferences.load(); + snapshot.workspaceId = "mutated-copy"; expect(workspacePreferences.migrate({ workspaceId: "legacy" })).toMatchObject({ - workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high", + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", }); }); diff --git a/frontend/src/workspaces/preferences.ts b/frontend/src/workspaces/preferences.ts index 53c159b4..ad0764f1 100644 --- a/frontend/src/workspaces/preferences.ts +++ b/frontend/src/workspaces/preferences.ts @@ -5,36 +5,31 @@ export interface WorkspacePreference { thinking?: string; } -const STORAGE_KEY = "thothii.workspace-registry.v1.preferences"; +let current: WorkspacePreference | undefined; -function storage(): Storage | undefined { - try { return window.localStorage; } catch { return undefined; } +function snapshot(value: WorkspacePreference | undefined): WorkspacePreference { + return value === undefined ? {} : { ...value }; } -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. */ +/** Ephemeral workspace choices for the current application process. */ export const workspacePreferences = { load(): WorkspacePreference { - return parse(storage()?.getItem(STORAGE_KEY) ?? null) ?? {}; + return snapshot(current); }, save(value: WorkspacePreference): WorkspacePreference { - try { storage()?.setItem(STORAGE_KEY, JSON.stringify(value)); } catch { /* storage is optional */ } - return value; + current = { ...value }; + return snapshot(current); }, - /** 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(); + /** Seed current memory from backend installation settings only before the first local choice. */ + migrate(settings: WorkspacePreference): WorkspacePreference { + if (current === undefined) current = { ...settings }; + return snapshot(current); + }, + + /** Clear ephemeral state on application/test lifecycle reset. */ + reset(): void { + current = undefined; }, };