diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 15 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 7 | ||||
| -rw-r--r-- | src/app/App.svelte | 20 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 10 | ||||
| -rw-r--r-- | src/app/store.test.ts | 68 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 37 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 31 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 9 |
9 files changed, 180 insertions, 18 deletions
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 113a731..dd2b773 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -283,6 +283,21 @@ describe("parseServerMessage", () => { }); }); + it("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + }); + it("returns null for conversation.statusChanged with missing workspaceId", () => { expect( parseServerMessage( diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index ba3e7ee..03ef763 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -126,7 +126,12 @@ export function parseServerMessage(data: string): WsServerMessage | null { case "conversation.statusChanged": { if (typeof parsed.conversationId !== "string") return null; if (typeof parsed.status !== "string") return null; - if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { + if ( + parsed.status !== "active" && + parsed.status !== "queued" && + parsed.status !== "idle" && + parsed.status !== "closed" + ) { return null; } if (typeof parsed.workspaceId !== "string") return null; 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(); + }); }); 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 @@ <!-- Bottom status bar: status icon · context-window fill · token count --> <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> <span class="shrink-0"> - {#if status === "running"} + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). --> + <span + class="loading loading-spinner loading-xs text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "running"} <span class="loading loading-dots loading-xs text-primary"></span> {:else if status === "error"} <svg diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 9af4cd1..4111c16 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -264,4 +264,35 @@ describe("TabList", () => { // Clicking the ID must NOT switch tabs (the badge stops propagation). expect(onSelect).not.toHaveBeenCalled(); }); + + it("shows a loading RING for a 'queued' tab and loading DOTS for an 'active' tab", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + statusFor: (id: string) => (id === "c1" ? "queued" : id === "c2" ? "active" : undefined), + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + // c1 is queued → a ring (spinner), labeled "Queued". + const queuedRing = screen.getByLabelText("Queued"); + expect(queuedRing.className).toContain("loading-spinner"); + expect(queuedRing.closest('[role="tab"]')).toHaveTextContent("First"); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(3); + const activeTab = tabs[1]; + const idleTab = tabs[2]; + if (activeTab === undefined || idleTab === undefined) throw new Error("missing tabs"); + + // c2 is active → loading dots (NOT a spinner). + expect(activeTab.querySelector(".loading-dots")).not.toBeNull(); + expect(activeTab.querySelector(".loading-spinner")).toBeNull(); + + // c3 has no status → no spinner at all. + expect(idleTab.querySelector(".loading")).toBeNull(); + }); }); diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index b1190e4..76e1968 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -144,7 +144,14 @@ {tab.title} </span> {/if} - {#if statusFor?.(tab.conversationId) === "active"} + {#if statusFor?.(tab.conversationId) === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-spinner loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if statusFor?.(tab.conversationId) === "active"} <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> {/if} <button |
