summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--.dispatch/wire.reference.md21
-rw-r--r--backend-handoff.md87
-rw-r--r--src/adapters/ws/logic.test.ts15
-rw-r--r--src/adapters/ws/logic.ts7
-rw-r--r--src/app/App.svelte20
-rw-r--r--src/app/store.svelte.ts10
-rw-r--r--src/app/store.test.ts68
-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
11 files changed, 242 insertions, 64 deletions
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index 05ed40b..f9a14f8 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,7 +4,12 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers). Regenerate whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes.
+>
+> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus`
+> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a
+> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows
+> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13.
>
> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds
> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required
@@ -559,12 +564,16 @@ export interface TurnSteeringEvent {
/**
* The lifecycle status of a conversation, used for tab persistence across
- * devices. `active` = an agent is currently generating; `idle` = exists but not
- * generating; `closed` = user dismissed the tab (hidden from the tab bar, not
- * deleted). New conversations start as `idle`; transitions to `active` on
- * turn-start, back to `idle` on turn done/error, and to `closed` on user close.
+ * devices. `active` = an agent is currently generating; `queued` = the turn is
+ * in flight but waiting for a per-provider concurrency slot (broadcast-only,
+ * never persisted — CR-13; the tab shows a ring vs the dots of `active`);
+ * `idle` = exists but not generating; `closed` = user dismissed the tab
+ * (hidden from the tab bar, not deleted). New conversations start as `idle`;
+ * transitions to `active` on turn-start (or `queued` when the request blocks on
+ * a concurrency slot before generation begins), back to `idle` on turn
+ * done/error, and to `closed` on user close.
*/
-export type ConversationStatus = "active" | "idle" | "closed";
+export type ConversationStatus = "active" | "queued" | "idle" | "closed";
/**
* Metadata for a conversation, returned by `GET /conversations` (the list
diff --git a/backend-handoff.md b/backend-handoff.md
index c6d5165..4197f24 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,13 +5,12 @@
> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-26 (dev merged — sidebar-tabs tab rework into feature/provider-concurrency; merge commit e81df4c,
-clean auto-merge, 921 tests green. CR-13 OPEN: per-conversation `"queued"` ConversationStatus for the concurrency
-queue — sent to backend agent `e1d7`; FE side designed + blocked on the `wire` bump.) §2j concurrency unchanged._
+_Last updated: 2026-06-26 (CR-13 RESOLVED — backend shipped `"queued"` ConversationStatus (additive to `[email protected]`,
+no bump); FE consumed: WS parser accepts `"queued"`, TabList shows a loading-ring for queued / dots for active, Composer
+corner status gains `"queued"` → ring. 925 tests green. dev was merged earlier — merge commit e81df4c.) §2j concurrency unchanged._
**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
-(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump) + **CR-13**
-(add `"queued"` to `ConversationStatus` + emit from the concurrency extension — `wire` bump). The SSH-divergence
-(§2d) is RESOLVED.
+(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence
+(§2d) is RESOLVED. CR-13 (`"queued"` ConversationStatus) is RESOLVED.
Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11
(per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three.
The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to
@@ -92,42 +91,50 @@ No wire/transport-contract/ui-contract change needed — this is a backend behav
how the `system:os` variable is resolved (the type shape is unchanged: it's still a `string`).
The FE is unaffected (it only inserts `[system:os]` into the template; the backend resolves it).
-### CR-13 — Per-conversation `"queued"` status for the concurrency queue → **OPEN** (sent to backend agent `e1d7`)
+### CR-13 — Per-conversation `"queued"` status for the concurrency queue → **RESOLVED ✅ (backend shipped; FE consumed + tested)**
Small UX ask: when a conversation's request is WAITING in the per-provider concurrency queue (not yet
-generating tokens), the FE should show the loading **RING** (spinner) on that tab + in the composer
-corner, instead of the loading **DOTS** (dots = actively generating). Currently both states show dots.
-
-**Why a backend change is needed (no FE-derivable signal):**
-- The FE tab spinner keys off the `conversation.statusChanged` broadcast (`ConversationStatus`:
- `active`/`idle`/`closed`). There is no per-conversation `"queued"` state.
-- `GET /concurrency/status` is per-PROVIDER aggregate (`inFlight`/`queued` counts) — it can't tell
- which SPECIFIC conversation is queued.
-- `turn-start` is emitted at turn entry (`run-turn.ts:617`) BEFORE `provider.stream()` → concurrency
- `acquire()` (which may block). So the FE's `generating` flag is already `true` during the queue wait
- — there is no `active && !generating` window to derive `"queued"` from.
-- Background tabs (not the active conversation) only receive the `conversation.statusChanged`
- broadcast (the FE does not subscribe to every tab's chat event stream), so the queued signal MUST
- flow through that broadcast — not a transient `AgentEvent`.
-
-**The change requested:**
-1. Add `"queued"` to the `ConversationStatus` type in `@dispatch/wire` (a wire version bump — additive
- enum value; no `transport-contract` or `ui-contract` change). The `conversation.statusChanged` WS
- message already carries `workspaceId` (CR-10); only the `status` value set widens.
-2. The `concurrency` extension: when a request BLOCKS on `acquire()` (enqueued in the per-provider
- queue awaiting a slot), emit `conversation.statusChanged` with `status: "queued"` (carrying
- `workspaceId`). When the slot is GRANTED and generation begins, flip to `"active"`. The existing
- `"idle"` on turn seal is unchanged.
- - Net lifecycle for a queued request: `queued` (waiting for slot) → `active` (generating) → `idle`
- (turn sealed). A request that gets a slot immediately never emits `"queued"` (goes straight to
- `active`) — fine.
- - Edge: the FE opens a tab only on `"active"` (store handler), not on `"queued"`; in practice a
- queued conversation is already an open tab, so `"queued"` just updates the status map.
-
-**FE side (BLOCKED on the wire bump — will implement the moment it ships):** re-pin `@dispatch/wire` →
-re-mirror `.dispatch/wire.reference.md` → accept `"queued"` in the WS parser (`adapters/ws/logic.ts`) →
-`TabList`: loading-ring for `status === "queued"`, loading-dots for `"active"` → Composer corner
-`status` gains `"queued"` → loading-ring, derived from the active conversation's `conversationStatus`.
+generating tokens), the FE shows the loading **RING** (spinner) on that tab + in the composer corner,
+instead of the loading **DOTS** (dots = actively generating).
+
+**Backend (shipped — additive to `[email protected]`, NO version bump):** `ConversationStatus` widened to
+`"active" | "queued" | "idle" | "closed"`. When the concurrency manager CANNOT grant a slot
+immediately (at limit or paused), `onQueued` fires → the orchestrator broadcasts
+`conversation.statusChanged` with `status: "queued"` (carrying `workspaceId`, same shape as before).
+`"queued"` is BROADCAST ONLY — it is NOT persisted (the persisted status stays `"active"`; on restart
+conversations show `"active"`, never stuck in `"queued"`). When the slot is granted (`acquire`
+resolves), `onAcquired` fires → broadcasts `"active"` again. The existing `"idle"` on turn seal is
+unchanged. A request that gets a slot immediately never emits `"queued"`. Also: `GET /conversations?status=queued`
+now works ("queued" added to the valid status filter set).
+
+**FE (DONE + verified):**
+- Re-synced the `@dispatch/wire` `file:` dep (`bun install`); `node_modules/@dispatch/wire/dist` now has
+ `"queued"` in `ConversationStatus`. Re-mirrored `.dispatch/wire.reference.md` (widened the type +
+ header delta note).
+- WS parser (`src/adapters/ws/logic.ts`): accepts `"queued"` in the `conversation.statusChanged`
+ status set (was hard-coded to `active/idle/closed`).
+- Store handler (`onConversationStatusChanged`): `"queued"` updates the status map (drives the tab
+ spinner) AND opens a tab for a new cross-device queued conversation (like `"active"`; `"idle"`
+ never opens). `closed` still removes the tab.
+- `TabList.svelte`: `status === "queued"` → loading-**ring** (`loading-spinner`, `aria-label="Queued"`,
+ `title="Waiting for a concurrency slot"`); `status === "active"` → loading-**dots** (unchanged); no
+ status → no spinner.
+- `Composer.svelte`: `status` type widened to `ComposerStatus = "idle" | "running" | "queued" | "error"`
+ (exported via `features/chat/index.ts`). `"queued"` → a loading-ring status icon (`aria-label="Queued"`)
+ + placeholder "Queued for a slot…". `"queued"` behaves like `"running"` for the send button
+ (`inFlight = running || queued` → steer/stop) — the turn is in flight, just waiting for a slot.
+- `App.svelte`: `composerStatus` derived (`error > queued > running > idle`) — `conversationStatus(id)
+ === "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 `conversationStatus === "queued"`;
+ the explicit queued check is what distinguishes them).
+- Tests: WS parser accepts `"queued"`; store handler sets the status + opens a cross-device tab +
+ transitions `queued → active → idle`; TabList renders a ring for `"queued"` + dots for `"active"`.
+
+**Verification:** typecheck 0/0, **925 tests green** (run TWICE — no cross-test pollution; the store
+handler tests feed real WS frames through the parser), biome clean, build OK. Live probe NOT run (the
+backend is the user's process; never booted headless). To confirm end-to-end: set a provider's
+concurrency limit to 1, start 2 turns on that provider, watch the second tab show a ring ("Queued")
+until the first finishes, then flip to dots ("active").
### CR-7 — Workspace cwd fallthrough bug + relative-path resolution → **RESOLVED ✅ (backend shipped; FE code unchanged)**
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