summaryrefslogtreecommitdiffhomepage
path: root/src/features
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/features
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/features')
-rw-r--r--src/features/chat/index.ts1
-rw-r--r--src/features/chat/ui/Composer.svelte37
-rw-r--r--src/features/tabs/ui.test.ts31
-rw-r--r--src/features/tabs/ui/TabList.svelte9
4 files changed, 69 insertions, 9 deletions
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