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/features/chat/index.ts | 1 + src/features/chat/ui/Composer.svelte | 37 ++++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) (limited to 'src/features/chat') diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index ddb094d..5419b89 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -24,6 +24,7 @@ export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export type { CompactNowResult, SaveCompactPercentResult } from "./ui/CompactionView.svelte"; export { default as CompactionView } from "./ui/CompactionView.svelte"; +export type { ComposerStatus } from "./ui/Composer.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 2f3d820..2484225 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -27,10 +27,17 @@ contextSize?: number | undefined; /** Per-model context window (max tokens) from `GET /models` modelInfo. */ contextWindow?: number | undefined; - // Coarse agent status for the status-bar icon. - status?: "idle" | "running" | "error"; + /** + * Coarse agent status for the status-bar icon. `queued` = the turn is in + * flight but waiting for a concurrency slot (CR-13) — shown as a loading + * RING (vs the loading DOTS of `running`/actively generating). Behaves like + * `running` for the send button (steer/stop). + */ + status?: ComposerStatus; } = $props(); + export type ComposerStatus = "idle" | "running" | "queued" | "error"; + let text = $state(""); let inputEl: HTMLTextAreaElement | undefined; @@ -41,15 +48,22 @@ // One button, three modes: // - idle → "Send" (starts a turn via chat.send) - // - running + text → "Queue" (steers via chat.queue) - // - running + empty → "Stop" (aborts via POST /stop) + // - running/queued + text → "Queue" (steers via chat.queue) + // - running/queued + empty → "Stop" (aborts via POST /stop) + // (`queued` behaves like `running` — the turn is in flight, just waiting for a + // concurrency slot; the user can still steer or stop it.) + const inFlight = $derived(status === "running" || status === "queued"); const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { - if (status === "running" && !hasText && onStop !== undefined) return "stop"; - if (status === "running" && hasText && onQueue !== undefined) return "queue"; + if (inFlight && !hasText && onStop !== undefined) return "stop"; + if (inFlight && hasText && onQueue !== undefined) return "queue"; return "send"; }); const placeholder = $derived( - status === "running" ? "Steer the conversation..." : "Type a message...", + status === "queued" + ? "Queued for a slot…" + : status === "running" + ? "Steer the conversation..." + : "Type a message...", ); // As the window fills, escalate color: calm → warning → danger. @@ -135,7 +149,14 @@
- {#if status === "running"} + {#if status === "queued"} + + + {:else if status === "running"} {:else if status === "error"}