diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index b9f1ca21..98a5347d 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -23,12 +23,11 @@ async function selectedPreferences(): Promise { } export async function createSession(i: NewSessionInput) { - const preferences = await selectedPreferences(); - await workspacePolicyGate.waitFor(i.workspaceId ?? preferences.workspaceId); - const selection = workspacePreferences.load(); + await selectedPreferences(); + const selection = await workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()); return apiFetch<{ id: string }>("/sessions", { method: "POST", - body: JSON.stringify({ ...selection, ...i }), + body: JSON.stringify({ ...i, ...selection }), }); } diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index a4e29a0d..c532c329 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -289,6 +289,119 @@ test("immediate submit waits for a switched workspace policy before creating a s })); }); +test("initial restored workspace waits for its delayed policy before creating a session", async () => { + let body: unknown; + let releasePolicy!: () => void; + let policyRequestStarted = false; + const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + 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", state: "operational" as const, + }); + server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, + ])), + http.get("http://localhost:8787/workspaces/psd-clinical", async () => { + policyRequestStarted = true; + await policyMayFinish; + return HttpResponse.json({ + workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, + revision: revision("psd-clinical"), + }); + }), + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, + { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, + ] })), + http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + await waitFor(() => expect(policyRequestStarted).toBe(true)); + await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + expect(body).toBeUndefined(); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + releasePolicy(); + + await waitFor(() => expect(body).toEqual({ + question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + })); +}); + +test("submit follows a rapid workspace switch instead of waiting for an abandoned policy", async () => { + let body: unknown; + let releaseC!: () => void; + let bPolicyRequestStarted = false; + let cPolicyRequestStarted = false; + const cPolicyMayFinish = new Promise((resolve) => { releaseC = resolve; }); + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + 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", state: "operational" as const, + }); + server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, + { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", revision: revision("workspace-b") }, + { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", revision: revision("workspace-c") }, + ])), + http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({ + workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), + })), + http.get("http://localhost:8787/workspaces/workspace-b", async () => { + bPolicyRequestStarted = true; + await new Promise(() => undefined); + return HttpResponse.json({}); + }), + http.get("http://localhost:8787/workspaces/workspace-c", async () => { + cPolicyRequestStarted = true; + await cPolicyMayFinish; + return HttpResponse.json({ + workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, + revision: revision("workspace-c"), + }); + }), + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, + { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, + ] })), + http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" }); + await waitFor(() => expect(screen.getByRole("option", { name: "workspace-b" })).toBeInTheDocument()); + await userEvent.selectOptions(workspaceSelector, "workspace-b"); + await waitFor(() => expect(bPolicyRequestStarted).toBe(true)); + await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + await userEvent.selectOptions(workspaceSelector, "workspace-c"); + await waitFor(() => expect(cPolicyRequestStarted).toBe(true)); + + expect(body).toBeUndefined(); + releaseC(); + + await waitFor(() => expect(body).toEqual({ + question: "q", workspaceId: "workspace-c", provider: "zai", model: "glm-5.2", thinking: "medium", + })); +}); + test("pulses the stop dot only while the harness is working", () => { const { rerender } = render(); const dot = () => diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index cd1adba1..ec54932b 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -84,8 +84,7 @@ export function SteerInput({ setLastUserEntry({ kind: "input", text: trimmed }); onSessionCreating?.(trimmed); try { - await workspacePolicyGate.waitFor(workspacePreferences.load().workspaceId); - const { id } = await createSession({ question: trimmed, ...workspacePreferences.load() }); + const { id } = await createSession({ question: trimmed }); onSessionCreated?.(id); setText(""); } catch (error) { @@ -190,6 +189,11 @@ export function ComposerFooter() { ? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`)) : models; + useEffect(() => { + if (selectedWorkspace?.revision) workspacePolicyGate.select(workspace); + else workspacePolicyGate.clear(); + }, [selectedWorkspace, workspace]); + useEffect(() => { if (!allowedModels?.length) return; const selected = preferences.provider && preferences.model @@ -222,7 +226,7 @@ export function ComposerFooter() { patch.workspaceId && patch.workspaceId !== workspace && workspaces.some((candidate) => candidate.id === patch.workspaceId && candidate.revision) - ) workspacePolicyGate.begin(patch.workspaceId); + ) workspacePolicyGate.select(patch.workspaceId); const next = workspacePreferences.save({ ...preferences, ...patch }); setPreferences(next); } diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 6c9527a4..c8725e69 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -9,41 +9,74 @@ export interface WorkspaceDraft { updatedAt: string; } -let pendingPolicy: { workspaceId: string; promise: Promise; resolve: () => void } | undefined; -let failedPolicy: { workspaceId: string; error: Error } | undefined; +type PolicySelection = { + workspaceId: string; + generation: number; + state: "pending" | "ready" | "error"; + error?: Error; + settled: Promise; + settle: () => void; + superseded: Promise; + supersede: () => void; +}; -/** In-memory handoff between the footer's policy query and all session entry points. */ +let selection: PolicySelection | undefined; +let nextGeneration = 0; + +function pendingSelection(workspaceId: string): PolicySelection { + let settle!: () => void; + let supersede!: () => void; + return { + workspaceId, + generation: ++nextGeneration, + state: "pending", + settled: new Promise((resolve) => { settle = resolve; }), + settle, + superseded: new Promise((resolve) => { supersede = resolve; }), + supersede, + }; +} + +/** In-memory coordination between the footer's selected policy and session creation. */ export const workspacePolicyGate = { - begin(workspaceId: string): void { - if (pendingPolicy?.workspaceId === workspaceId) return; - if (failedPolicy?.workspaceId === workspaceId) failedPolicy = undefined; - let resolve!: () => void; - const promise = new Promise((onResolve) => { - resolve = onResolve; - }); - pendingPolicy = { workspaceId, promise, resolve }; + select(workspaceId: string): void { + if (selection?.workspaceId === workspaceId) return; + selection?.supersede(); + selection = pendingSelection(workspaceId); + }, + + clear(): void { + selection?.supersede(); + selection = undefined; }, resolve(workspaceId: string): void { - if (pendingPolicy?.workspaceId === workspaceId) { - pendingPolicy.resolve(); - pendingPolicy = undefined; - } - if (failedPolicy?.workspaceId === workspaceId) failedPolicy = undefined; + if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return; + selection.state = "ready"; + selection.settle(); }, reject(workspaceId: string): void { - if (pendingPolicy?.workspaceId !== workspaceId) return; - pendingPolicy.resolve(); - pendingPolicy = undefined; - failedPolicy = { workspaceId, error: new Error("Selected workspace policy could not be loaded.") }; + if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return; + selection.state = "error"; + selection.error = new Error("Selected workspace policy could not be loaded."); + selection.settle(); }, - waitFor(workspaceId: string | undefined): Promise { - const failed = failedPolicy; - if (failed && failed.workspaceId === workspaceId) return Promise.reject(failed.error); - const pending = pendingPolicy; - return pending && pending.workspaceId === workspaceId ? pending.promise : Promise.resolve(); + async waitForCurrent(read: () => T): Promise { + while (true) { + const current = read(); + const pending = selection; + if (!current.workspaceId || pending?.workspaceId !== current.workspaceId) return current; + const generation = pending.generation; + if (pending.state === "ready") return read(); + if (pending.state === "error") throw pending.error; + await Promise.race([pending.settled, pending.superseded]); + const completed = selection; + if (!completed || completed.generation !== generation || read().workspaceId !== current.workspaceId) continue; + if (completed.state === "error") throw completed.error; + if (completed.state === "ready") return read(); + } }, };