fix: gate creation until workspace summaries load

This commit is contained in:
2026-08-04 06:01:25 +02:00
parent b3ff39fc26
commit 41c558e06c
3 changed files with 85 additions and 15 deletions
+41
View File
@@ -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<void>((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(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} /></QueryClientProvider>);
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;
+18 -9
View File
@@ -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);
}
+26 -6
View File
@@ -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<void>;
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<void>((resolve) => { settle = resolve; }),
settle,
superseded: new Promise<void>((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 {