diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index c532c329..608c6571 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -338,6 +338,47 @@ test("initial restored workspace waits for its delayed policy before creating a })); }); +test("initial submit waits for delayed workspace summaries before allowing a confirmed legacy workspace", async () => { + let body: unknown; + let releaseSummaries!: () => void; + let summaryRequestStarted = false; + const summariesMayFinish = new Promise((resolve) => { releaseSummaries = resolve; }); + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", + })); + server.use( + http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })), + http.get("http://localhost:8787/workspaces", async () => { + summaryRequestStarted = true; + await summariesMayFinish; + return HttpResponse.json([{ + id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace", + }]); + }), + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + { 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(summaryRequestStarted).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(); + releaseSummaries(); + + await waitFor(() => expect(body).toEqual({ + question: "q", workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", + })); +}); + test("submit follows a rapid workspace switch instead of waiting for an abandoned policy", async () => { let body: unknown; let releaseC!: () => void; diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index ec54932b..91f95936 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -156,7 +156,9 @@ export function SteerInput({ */ export function ComposerFooter() { const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings }); - const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces }); + const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ + queryKey: ["workspaces"], queryFn: listWorkspaces, + }); const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels }); const models = modelsData?.models ?? []; const tokenUsage = useSessionStore((state) => state.tokenUsage); @@ -190,9 +192,16 @@ export function ComposerFooter() { : models; useEffect(() => { - if (selectedWorkspace?.revision) workspacePolicyGate.select(workspace); - else workspacePolicyGate.clear(); - }, [selectedWorkspace, workspace]); + if (!workspace) { + workspacePolicyGate.clear(); + } else if (workspacesLoading) { + workspacePolicyGate.beginSummary(workspace); + } else if (selectedWorkspace?.revision) { + workspacePolicyGate.select(workspace); + } else { + workspacePolicyGate.allowLegacy(workspace); + } + }, [selectedWorkspace, workspace, workspacesLoading]); useEffect(() => { if (!allowedModels?.length) return; @@ -222,11 +231,11 @@ export function ComposerFooter() { }, [selectedWorkspace, workspace, workspacePolicyError, workspaceRecord]); function update(patch: WorkspacePreference) { - if ( - patch.workspaceId - && patch.workspaceId !== workspace - && workspaces.some((candidate) => candidate.id === patch.workspaceId && candidate.revision) - ) workspacePolicyGate.select(patch.workspaceId); + if (patch.workspaceId && patch.workspaceId !== workspace) { + const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId); + if (selected?.revision) workspacePolicyGate.select(patch.workspaceId); + else workspacePolicyGate.allowLegacy(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 c8725e69..afac013f 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -12,7 +12,7 @@ export interface WorkspaceDraft { type PolicySelection = { workspaceId: string; generation: number; - state: "pending" | "ready" | "error"; + state: "summary" | "pending" | "ready" | "error"; error?: Error; settled: Promise; settle: () => void; @@ -23,13 +23,13 @@ type PolicySelection = { let selection: PolicySelection | undefined; let nextGeneration = 0; -function pendingSelection(workspaceId: string): PolicySelection { +function pendingSelection(workspaceId: string, state: "summary" | "pending"): PolicySelection { let settle!: () => void; let supersede!: () => void; return { workspaceId, generation: ++nextGeneration, - state: "pending", + state, settled: new Promise((resolve) => { settle = resolve; }), settle, superseded: new Promise((resolve) => { supersede = resolve; }), @@ -39,10 +39,30 @@ function pendingSelection(workspaceId: string): PolicySelection { /** In-memory coordination between the footer's selected policy and session creation. */ export const workspacePolicyGate = { - select(workspaceId: string): void { - if (selection?.workspaceId === workspaceId) return; + beginSummary(workspaceId: string): void { + if (selection?.workspaceId === workspaceId && selection.state === "summary") return; selection?.supersede(); - selection = pendingSelection(workspaceId); + selection = pendingSelection(workspaceId, "summary"); + }, + + select(workspaceId: string): void { + if (selection?.workspaceId === workspaceId) { + if (selection.state === "summary") selection.state = "pending"; + if (selection.state === "pending") return; + } + selection?.supersede(); + selection = pendingSelection(workspaceId, "pending"); + }, + + allowLegacy(workspaceId: string): void { + if (selection?.workspaceId !== workspaceId) { + this.clear(); + return; + } + if (selection.state === "summary") { + selection.state = "ready"; + selection.settle(); + } }, clear(): void {