From 19b6b29b1a82b11c64c8b05c97cf8f687fd495f6 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 19:04:26 +0900 Subject: feat(concurrency): loading-ring for queued chats (CR-13 consumed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend shipped "queued" ConversationStatus (additive to wire@0.12.0): when a request blocks on a concurrency slot, conversation.statusChanged broadcasts "queued" (broadcast-only, never persisted); "active" on slot grant. FE consumes it: - WS parser (adapters/ws/logic.ts): accepts "queued" in the status set. - Store handler: "queued" updates the status map + opens a tab for a new cross-device queued conversation (like "active"). - TabList: status === "queued" -> loading-ring (spinner, aria-label "Queued"); "active" -> loading-dots (unchanged). - Composer: status type widened to ComposerStatus (idle|running|queued|error), exported from features/chat. "queued" -> a loading-ring status icon + placeholder "Queued for a slot…"; behaves like "running" for the send button (steer/stop — the turn is in flight, just waiting for a slot). - App.svelte: composerStatus derived (error > queued > running > idle) — conversationStatus === "queued" wins over generating so the corner shows a ring during the wait (turn-start fires before the slot is granted, so generating is already true while status === "queued"). - Re-mirrored .dispatch/wire.reference.md (ConversationStatus widened + header). Tests: WS parser accepts queued; store handler sets status + opens a cross-device tab + transitions queued->active->idle; TabList renders a ring for queued + dots for active. typecheck 0/0, 925 tests green (x2), biome clean, build OK. backend-handoff.md CR-13 marked RESOLVED. --- src/app/App.svelte | 20 +++++++++++---- src/app/store.svelte.ts | 10 +++++--- src/app/store.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) (limited to 'src/app') diff --git a/src/app/App.svelte b/src/app/App.svelte index 5aa103d..b46885c 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -16,6 +16,7 @@ ModelSelector, ReasoningEffortSelector, type CompactNowResult, + type ComposerStatus, type ReasoningEffortSaveResult, type SaveCompactPercentResult, } from "../features/chat"; @@ -238,6 +239,19 @@ return tab?.title ?? NEW_TAB_TITLE; }); + // The composer status-bar status. Priority: error > queued > running > idle. + // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13) + // wins over `running` so the corner shows a ring, not dots, during the wait. + // `turn-start` fires before the slot is granted, so `generating` is already + // true while `conversationStatus === "queued"`; the explicit queued check is + // what distinguishes the two. + const composerStatus = $derived.by(() => { + if (store.activeChat.error) return "error"; + const id = store.activeConversationId; + if (id !== null && store.conversationStatus(id) === "queued") return "queued"; + return store.activeChat.generating ? "running" : "idle"; + }); + // Conversation/tab switch → snap to the bottom of the new transcript. $effect(() => { void store.activeConversationId; @@ -588,11 +602,7 @@ onStop={handleStop} contextSize={store.activeChat.currentContextSize} contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={store.activeChat.error - ? "error" - : store.activeChat.generating - ? "running" - : "idle"} + status={composerStatus} /> diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 8bc1080..0506f91 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -980,10 +980,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } return; } - // active / idle — update the status map (drives the tab spinner). + // active / queued / idle — update the status map (drives the tab spinner). + // `queued` = the turn is in flight but waiting for a concurrency slot + // (broadcast-only, never persisted — CR-13); the tab shows a ring. conversationStatuses = new Map(conversationStatuses).set(conversationId, status); - // If this is a new active conversation we don't have a tab for, open one. - if (status === "active" && !chatStores.has(conversationId)) { + // If this is a new active OR queued conversation we don't have a tab for, + // open one — so a cross-device turn (incl. one waiting in the concurrency + // queue) is visible. `idle` never opens a tab. + if ((status === "active" || status === "queued") && !chatStores.has(conversationId)) { openConversation(conversationId, workspaceId); } }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 2074d9e..b47a378 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1634,4 +1634,72 @@ describe("createAppStore", () => { expect(result.providers).toEqual([]); store.dispose(); }); + + // ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ──── + + it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + const tabsBefore = store.tabs.length; + + // The backend broadcasts "queued" while the turn waits for a slot. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus(convId)).toBe("queued"); + // The tab already exists (opened on send) — no duplicate tab is opened. + expect(store.tabs.length).toBe(tabsBefore); + + // Granted → "active" (dots), then idle on turn seal. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("active"); + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "idle", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("idle"); + store.dispose(); + }); + + it("conversation.statusChanged 'queued' opens a tab for a new cross-device conversation", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + expect(store.tabs.length).toBe(0); + + // Another device's turn is waiting for a slot — broadcast "queued". + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "other-device-conv", + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus("other-device-conv")).toBe("queued"); + // A queued conversation we had no tab for opens one (like `active`). + expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true); + store.dispose(); + }); }); -- cgit v1.2.3