summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 19:04:26 +0900
committerAdam Malczewski <[email protected]>2026-06-27 19:04:26 +0900
commit19b6b29b1a82b11c64c8b05c97cf8f687fd495f6 (patch)
tree753c584e651eb0cfed46a949266ab5d9b80fef98 /src/app
parent93b6ac11a83b8f4ec38bb957a5be5cfefa2b1240 (diff)
downloaddispatch-web-19b6b29b1a82b11c64c8b05c97cf8f687fd495f6.tar.gz
dispatch-web-19b6b29b1a82b11c64c8b05c97cf8f687fd495f6.zip
feat(concurrency): loading-ring for queued chats (CR-13 consumed)
Backend shipped "queued" ConversationStatus (additive to [email protected]): 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.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte20
-rw-r--r--src/app/store.svelte.ts10
-rw-r--r--src/app/store.test.ts68
3 files changed, 90 insertions, 8 deletions
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<ComposerStatus>(() => {
+ 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}
/>
</div>
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();
+ });
});