summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md61
-rw-r--r--.dispatch/wire.reference.md21
-rw-r--r--backend-handoff.md143
-rw-r--r--src/App.svelte2
-rw-r--r--src/adapters/ws/logic.test.ts15
-rw-r--r--src/adapters/ws/logic.ts7
-rw-r--r--src/app/App.svelte173
-rw-r--r--src/app/App.test.ts86
-rw-r--r--src/app/store.svelte.ts175
-rw-r--r--src/app/store.test.ts327
-rw-r--r--src/features/chat/index.ts1
-rw-r--r--src/features/chat/ui/ChatView.svelte2
-rw-r--r--src/features/chat/ui/Composer.svelte46
-rw-r--r--src/features/concurrency/index.ts44
-rw-r--r--src/features/concurrency/logic/types.ts82
-rw-r--r--src/features/concurrency/logic/view-model.test.ts405
-rw-r--r--src/features/concurrency/logic/view-model.ts299
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte119
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte331
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts224
-rw-r--r--src/features/tabs/index.ts2
-rw-r--r--src/features/tabs/tabs.test.ts24
-rw-r--r--src/features/tabs/tabs.ts20
-rw-r--r--src/features/tabs/ui.test.ts185
-rw-r--r--src/features/tabs/ui/TabBar.svelte177
-rw-r--r--src/features/tabs/ui/TabList.svelte184
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte11
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts14
28 files changed, 2825 insertions, 355 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 182279a..6a5a6b3 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,9 +5,16 @@
> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for
> READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers). Regenerate whenever
+> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever
> it changes.
>
+> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the
+> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`),
+> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`),
+> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The
+> `concurrency` extension tracks/limits in-flight token-generating requests per provider with
+> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and
+> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j.
> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface.
> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]`
> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/
@@ -1019,5 +1026,57 @@ export interface TestComputerResponse {
readonly ok: boolean;
readonly error?: string;
}
+
+// ── Provider concurrency limits ([email protected]) ───────────────────
+
+/**
+ * Response of `GET /concurrency/limits` — all providers with configured
+ * concurrency limits. Each entry pairs a provider id (e.g. "umans",
+ * "openai-compat") with its maximum concurrent in-flight requests. Providers
+ * not listed here have no limit (unlimited).
+ */
+export interface ConcurrencyLimitsResponse {
+ readonly limits: readonly {
+ readonly providerId: string;
+ readonly limit: number;
+ }[];
+}
+/**
+ * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency
+ * limit for a provider. `limit` must be a positive integer. When a limit is
+ * set, requests beyond the limit queue (oldest-agent-first) rather than being
+ * sent immediately.
+ */
+export interface SetConcurrencyLimitRequest {
+ readonly limit: number;
+}
+/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */
+export interface ConcurrencyLimitResponse {
+ readonly providerId: string;
+ readonly limit: number;
+}
+/**
+ * One provider's live concurrency status.
+ *
+ * - `inFlight`: how many slots are currently held (tokens being generated).
+ * - `queued`: how many agents are waiting for a slot.
+ * - `paused`: whether the queue is paused due to a 429 backoff.
+ * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused.
+ */
+export interface ConcurrencyStatusEntry {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ readonly pausedUntil?: number;
+}
+/**
+ * Response of `GET /concurrency/status` — live status for every provider with a
+ * configured limit. Providers without a limit are absent (they are unlimited).
+ */
+export interface ConcurrencyStatusResponse {
+ readonly providers: readonly ConcurrencyStatusEntry[];
+}
```
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index 75adac5..888c160 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-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new
> `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data
@@ -622,12 +627,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 5a476be..5b41741 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,6 +5,10 @@
> **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 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE
+re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests
+green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._
+**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP
paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp,
not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic
@@ -12,7 +16,7 @@ echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchang
(+11), biome clean, build OK. §2i 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). The SSH-divergence
-(§2d) is RESOLVED.
+(§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
@@ -25,13 +29,13 @@ FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`
## 1. Pinned backend contracts (consumed by the FE)
-Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
+Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
| Package | Used for |
|---|---|
| `@dispatch/ui-contract` | surfaces + surface WS protocol |
| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`)/`Computer`/`ComputerEntry` (SSH handoff #1) |
-| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` |
+| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + concurrency (`ConcurrencyLimitsResponse`/`SetConcurrencyLimitRequest`/`ConcurrencyLimitResponse`/`ConcurrencyStatusEntry`/`ConcurrencyStatusResponse`) + WS chat ops + `WsClientMessage`/`WsServerMessage` |
Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`):
`POST /chat` (NDJSON) · `GET /models` ·
@@ -45,11 +49,14 @@ steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` �
WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) ·
WS `chat.queue` (enqueue steering; fire-and-forget — surface updates on success) ·
WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab; carries `workspaceId`) ·
-WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`).
+WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`) ·
+`GET /concurrency/limits` · `GET`/`PUT`/`DELETE /concurrency/limits/:providerId` · `GET /concurrency/status`
+(per-provider in-flight caps + oldest-agent-first queueing + 429-pause backoff; the `concurrency` extension — when not
+loaded the list + status endpoints return empty arrays, the single/PUT/DELETE return `503`).
Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md`
(regenerate on any contract bump; all current as of `[email protected]` /
### FE invariants to keep (don't regress)
@@ -90,6 +97,51 @@ 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 → **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 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)**
Fixed backend-side (reply from arch-rewrite agent ab13). **No wire/transport-contract/ui-contract
@@ -717,6 +769,87 @@ down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE
---
+## 2j. Provider concurrency limits → **CONSUMED ✅ (backend shipped; FE built)**
+
+The backend tracks + limits how many concurrent token-generating API requests are in flight PER
+PROVIDER. When the cap is reached, further requests QUEUE and are granted slots oldest-agent-first
+(a 429 backoff PAUSES a provider's queue until `pausedUntil`). The cap is per-provider, managed via a
+new GLOBAL REST surface under `/concurrency/...` provided by the `concurrency` extension. **The limits
+are now PERSISTED across reboots** (backend update on `feature/provider-concurrency` — `PUT`/`DELETE`
+also write to storage; `GET /concurrency/limits` reads in-memory state pre-populated from storage on
+boot). **No API contract change** — the endpoints, request bodies, and response shapes are identical,
+so the FE needs no re-pin/re-mirror; a limit set via the UI now survives a server restart. (Earlier
+backend-only changes since the last handoff, also no FE impact: a 200ms release cooldown for internal
+slot recycling, and the `"queued"` `ConversationStatus` — which the FE already shipped, CR-13.)
+**`[email protected]`** added the 5 types: `ConcurrencyLimitsResponse`,
+`SetConcurrencyLimitRequest`, `ConcurrencyLimitResponse`,
+`ConcurrencyStatusEntry`, `ConcurrencyStatusResponse`.
+
+**Backend API (plain REST — the types ARE in `[email protected]`):**
+- `GET /concurrency/limits` → `{ limits: [{ providerId, limit }] }` (empty when extension not loaded)
+- `GET /concurrency/limits/:providerId` → `{ providerId, limit }` · 404 (not configured) · 503 (not loaded)
+- `PUT /concurrency/limits/:providerId` body `{ limit }` (positive int) → `{ providerId, limit }` · 400 (bad body) · 503
+- `DELETE /concurrency/limits/:providerId` → `{ ok, providerId }` · 404 · 503
+- `GET /concurrency/status` → `{ providers: [{ providerId, limit, inFlight, queued, paused, pausedUntil? }] }` (empty when not loaded)
+
+**Contract note:** the concurrency shapes ARE in `@dispatch/[email protected]` (a real version
+bump, unlike the additive `provider-retry`/`computer` deltas). The FE re-pinned the `file:` dep
+(`bun install`) + re-mirrored `.dispatch/transport-contract.reference.md` (appended the 5 types +
+bumped the snapshot header). The FE imports the contract types directly (no consumer-defines-port
+needed for the data shapes), exactly mirroring `mcp`/`computer`.
+
+**FE (DONE + verified):**
+- New feature library `src/features/concurrency/`:
+ - `logic/types.ts` — re-exports the 5 contract types + defines the FE-owned result types
+ (`ConcurrencyLimitsResult`/`ConcurrencyLimitResult`/`ConcurrencyDeleteResult`/
+ `ConcurrencyStatusResult`) + the 5 injected ports (`LoadConcurrencyLimits`/
+ `GetConcurrencyLimit`/`SaveConcurrencyLimit`/`DeleteConcurrencyLimit`/`LoadConcurrencyStatus`).
+ - `logic/view-model.ts` (pure) — `viewConcurrencyStatus`/`viewConcurrencyStatuses` (badge +
+ busy + "2/4" in-flight + queue + `paused — resumes in Ns` countdown), `viewConcurrencyLimit`/
+ `Limits`, `summarizeLimits`/`summarizeStatus`, `parseLimitInput`/`normalizeLimit` (positive-int
+ validation), `formatPauseDuration`/`pauseLabel`, + the network-seam normalizers
+ `normalizeConcurrencyLimits`/`normalizeConcurrencyLimit`/`normalizeConcurrencyStatus` (defensive
+ coercion — a malformed/empty `{}` body never crashes the renderer). 34 view-model tests green.
+ - `ui/ConcurrencyView.svelte` — a sidebar panel with TWO sections: (1) **Concurrency limits**
+ (config): an add form (provider id text input + positive-int limit + Add → PUT) + a list of
+ `ConcurrencyLimitRow` (inline-edit limit + Save → PUT + ✕ Remove → DELETE); (2) **Live status**:
+ a summary + per-provider cards (in-flight "2/4", queued, paused indicator with a live countdown),
+ polling `GET /concurrency/status` every 2s + a 1s countdown clock (both intervals disposed on
+ unmount). Reloads limits+status on every mutation.
+ - `ui/ConcurrencyLimitRow.svelte` — one editable limit row (inline-edit seeded via the ChatLimitField
+ pattern so a save echo / refresh re-syncs without clobbering an in-flight edit).
+ - `index.ts` — `ConcurrencyView`/`ConcurrencyLimitRow`/`manifest`/types/exports.
+- `AppStore` (`src/app/store.svelte.ts`) — 5 methods (GLOBAL, not workspace-scoped): `concurrencyLimits()`,
+ `getConcurrencyLimit(providerId)`, `setConcurrencyLimit(providerId, limit)`,
+ `deleteConcurrencyLimit(providerId)`, `concurrencyStatus()`. Each normalizes the untyped JSON at the
+ network seam + surfaces HTTP errors (incl. 400/404/503) as `ok:false` with the backend's `error`
+ string. Interface declarations added to `AppStore`.
+- `src/app/App.svelte` — new `"concurrency"` viewKind (sidebar "Concurrency"), `concurrencyManifest`
+ in `loadedModules`, thin passthrough adapters, + the `viewContent` branch rendering `<ConcurrencyView>`
+ (global — no `{#key}`, stays mounted across tab switches).
+- Tests: 34 view-model + 5 component (`@testing-library/svelte`, faking the 4 ports) + 9 store
+ (load/empty/503/404/PUT/400/DELETE/status coerce/empty).
+
+**Verification:** typecheck 0/0, **914 tests green** (run TWICE — no cross-test pollution; the polling
+intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome
+clean, `vite build` succeeds (the lone build warning is PRE-EXISTING — a Tailwind/DaisyUI `file:path` /
+`heartbeat:elapsed` arbitrary-CSS ambiguity, not from concurrency). Live probe NOT run (the backend
+was the user's process; never booted headless). To confirm end-to-end: start the backend with the
+`concurrency` extension loaded, open the Concurrency sidebar view, add a provider limit, watch the live
+status poll (in-flight/queued/paused), update + remove it. If the extension isn't loaded, the limits +
+status lists render empty (graceful).
+
+**Note (the single-provider GET):** the FE implements all 5 API client functions (incl.
+`getConcurrencyLimit`, endpoint #2), but the UI uses only 4 — the limits LIST covers the configured
+providers; `getConcurrencyLimit` is an API-client function for completeness/future use (a detail view).
+**No `chat.send` change** — concurrency is a transport-layer concern the backend applies to outbound
+provider calls; the agent/model prompt never sees it (does not affect prompt caching).
+
+**Worktree environment note (same as §2d):** this worktree lays the repos out as
+`…/worktrees/provider-concurrency/{backend,frontend}`, but `package.json`'s canonical `file:` paths
+point at `../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink
+`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced
+`node_modules/@dispatch/*` to pick up `[email protected]`.
## 2j. Vision & vision handoff → **CONSUMED ✅ (backend shipped; FE built + verified)**
The backend shipped image/vision support: a user can attach images to a chat message, vision-capable
diff --git a/src/App.svelte b/src/App.svelte
index 0dbc958..713c844 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -70,5 +70,5 @@
{#if route.kind === "home"}
<WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
{:else}
- <App {store} />
+ <App {store} onNavigate={navigate} />
{/if}
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 e521167..59f949c 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";
@@ -40,7 +41,7 @@
import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
import { parseTodoPayload } from "../features/surface-host/logic/todo";
import TodoList from "../features/surface-host/ui/TodoList.svelte";
- import { manifest as tabsManifest, TabBar } from "../features/tabs";
+ import { manifest as tabsManifest, TabList } from "../features/tabs";
import { manifest as viewsManifest, ViewSidebar } from "../features/views";
import {
CwdField,
@@ -69,6 +70,14 @@
type HeartbeatRunsResult,
type HeartbeatStopResult,
} from "../features/heartbeat";
+ import {
+ ConcurrencyView,
+ manifest as concurrencyManifest,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyLimit,
+ } from "../features/concurrency";
import type { ChatStore } from "../features/chat";
import {
SystemPromptBuilder,
@@ -89,7 +98,7 @@
import { createLocalStore } from "../adapters/local-storage";
import { untrack } from "svelte";
- let { store }: { store: AppStore } = $props();
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
// The backend's conversation-scoped cache-warming surface. Referenced by id at
// the composition root (sanctioned discovery-by-id) to give it a dedicated view
@@ -106,6 +115,7 @@
// The view kinds offered in the sidebar's dropdown. Generic data — the
// `viewContent` snippet below maps each kind id to its renderer.
const viewKinds = [
+ { id: "tabs", label: "Tabs" },
{ id: "model", label: "Model" },
{ id: "lsp", label: "Language Servers" },
{ id: "mcp", label: "MCP Servers" },
@@ -114,12 +124,14 @@
{ id: "tasks", label: "Tasks" },
{ id: "compaction", label: "Compaction" },
{ id: "heartbeat", label: "Heartbeat" },
+ { id: "concurrency", label: "Concurrency" },
{ id: "system-prompt", label: "System Prompt" },
{ id: "settings", label: "Settings" },
] as const;
- // Default sidebar layout: just the Model view.
- const DEFAULT_VIEWS: readonly string[] = ["model"];
+ // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the
+ // Model view, so a fresh user sees their conversations + model controls.
+ const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"];
const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
storage: untrack(() => store.storage),
});
@@ -150,6 +162,7 @@
settingsManifest,
systemPromptManifest,
heartbeatManifest,
+ concurrencyManifest,
visionManifest,
].map((m) => [m.name, m.description] as const);
@@ -222,6 +235,31 @@
return parseTodoPayload(field.payload);
});
+ // Top-bar title: the active tab's title, or "New Tab" when no tab is active
+ // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
+ // tab exists). Pure-derived from the (workspace-filtered) tab set + the
+ // active id; reflects whichever tab is selected in the sidebar's Tabs view.
+ const NEW_TAB_TITLE = "New Tab";
+ const topBarTitle = $derived.by(() => {
+ const id = store.activeConversationId;
+ if (id === null) return NEW_TAB_TITLE;
+ const tab = store.tabs.find((t) => t.conversationId === id);
+ 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;
@@ -441,34 +479,72 @@
function closeRunChat(conversationId: string): void {
store.unwatchConversation(conversationId);
}
+
+ // Adapt the store's concurrency results to the feature's ports. The store
+ // returns the feature's result types directly (the API is a plain REST surface
+ // under /concurrency, not a workspace/conversation-scoped one), so the adapter
+ // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts
+ // are the cross-unit surface").
+ const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
+ const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
+ store.setConcurrencyLimit(providerId, limit);
+ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
+ store.deleteConcurrencyLimit(providerId);
+ const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
</script>
<main class="relative flex h-screen overflow-hidden">
<!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
- (below), so opening it shrinks this ENTIRE column — tab row included, which
- slides the hamburger left. -->
+ (below), so opening it shrinks this ENTIRE column. -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
- a permanently seated hamburger pinned to the far right. -->
- <div class="flex min-w-0 items-center">
- <TabBar
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- statusFor={(id) => store.conversationStatus(id)}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- onRename={(id, title) => store.renameTab(id, title)}
- />
+ <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
+ the top row now shows the active tab's title on the left (or "New Tab"
+ for an unstarted draft), with the build version + sidebar toggle on
+ the right. -->
+ <div class="flex items-center justify-between gap-2 px-2">
+ <span
+ class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/");
+ }}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
+ />
+ </svg>
+ </a>
<span
class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
title="Build version (git short hash)"
>
- {__APP_VERSION__}
+ build: {__APP_VERSION__}
</span>
<button
class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label="Toggle sidebar"
+ aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
aria-expanded={sidebarOpen}
onclick={() => (sidebarOpen = !sidebarOpen)}
>
@@ -481,11 +557,21 @@
class="size-5"
aria-hidden="true"
>
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
- />
+ {#if sidebarOpen}
+ <!-- Sidebar open → chevrons point right -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
+ />
+ {:else}
+ <!-- Sidebar closed → chevrons point left -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
+ />
+ {/if}
</svg>
</button>
</div>
@@ -504,7 +590,7 @@
</div>
{/if}
- <div class="relative min-h-0 min-w-0 flex-1">
+ <div class="relative min-h-0 min-w-0 flex-1 pr-4">
<div bind:this={transcriptEl} class="h-full overflow-y-auto">
<div bind:this={transcriptContentEl}>
{#key store.activeConversationId}
@@ -546,11 +632,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>
@@ -563,7 +645,7 @@
class:w-0={!sidebarOpen}
>
<div
- class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
+ class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
style="transform: translateX({sidebarOpen ? '0' : '100%'})"
>
<ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
@@ -616,7 +698,22 @@
{/if}
{#snippet viewContent(kind: string)}
- {#if kind === "model"}
+ {#if kind === "tabs"}
+ <!-- The conversation tab list (moved out of the top bar into the sidebar).
+ Re-mount per workspace so the filtered tab set + scroll reset cleanly
+ on a workspace switch. -->
+ {#key store.activeWorkspaceId}
+ <TabList
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ />
+ {/key}
+ {:else if kind === "model"}
<div class="flex flex-col gap-3">
<ModelSelector
models={store.models}
@@ -735,5 +832,15 @@
loadNextRun={loadHeartbeatNextRun}
onOpenRun={(run) => (heartbeatRun = run)}
/>
+ {:else if kind === "concurrency"}
+ <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or
+ conversation-scoped), so the panel stays mounted across tab switches. -->
+ <ConcurrencyView
+ models={store.models}
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ />
{/if}
{/snippet}
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 7c0a851..dcdcb9b 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -2,7 +2,7 @@ import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contrac
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
@@ -127,7 +127,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument();
@@ -136,6 +136,68 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it("shows 'New Tab' in the top bar for an unstarted draft (no active tab)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ expect(store.activeConversationId).toBeNull();
+ expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab");
+
+ store.dispose();
+ });
+
+ it("shows the active tab's title in the top bar once a tab is started", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ // Promote draft → tab: the tab's title is derived from the first message.
+ store.send("Refactor the auth module");
+ expect(store.activeConversationId).not.toBeNull();
+
+ // The top-bar title updates reactively; findByTestId awaits the flush.
+ expect(await screen.findByTestId("top-bar-title")).toHaveTextContent(
+ "Refactor the auth module",
+ );
+
+ store.dispose();
+ });
+
+ it("the dashboard home button navigates to '/' (back to the workspaces home)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const onNavigate = vi.fn();
+ render(App, { props: { store, onNavigate } });
+
+ const homeBtn = screen.getByRole("link", { name: "Back to dashboard" });
+ expect(homeBtn).toHaveAttribute("href", "/");
+ await userEvent.setup().click(homeBtn);
+
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/");
+
+ store.dispose();
+ });
+
it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -154,7 +216,7 @@ describe("App component interaction tests", () => {
],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const subscribed = sentMessages(ws)
.filter((m: { type: string }) => m.type === "subscribe")
@@ -182,7 +244,7 @@ describe("App component interaction tests", () => {
],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// No interaction: specs arrive and both surfaces render expanded.
ws.feedSurfaceMessage({
@@ -221,7 +283,7 @@ describe("App component interaction tests", () => {
message: "Something went wrong",
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Something went wrong");
@@ -243,7 +305,7 @@ describe("App component interaction tests", () => {
catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const user = userEvent.setup();
// Surface is auto-subscribed; its spec arrives and renders expanded.
@@ -290,7 +352,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const user = userEvent.setup();
const textarea = screen.getByRole("textbox", { name: "Message input" });
@@ -323,7 +385,7 @@ describe("App component interaction tests", () => {
store.send("test");
const convId = activeConversationId(store);
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
ws.feedServerMessage({
type: "chat.delta",
@@ -363,7 +425,7 @@ describe("App component interaction tests", () => {
catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Auto-subscribed; the custom-table spec arrives and renders expanded.
ws.feedSurfaceMessage({
@@ -401,7 +463,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
@@ -451,7 +513,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// The modal should appear with the error text
const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 });
@@ -477,7 +539,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Wait a tick for boot async to settle
await new Promise((resolve) => setTimeout(resolve, 200));
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 8353820..ead27a6 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -54,6 +54,17 @@ import {
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
+import type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
import type { ConversationCache } from "../features/conversation-cache";
import { createConversationCache } from "../features/conversation-cache";
import type {
@@ -404,6 +415,38 @@ export interface AppStore {
/** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
unwatchConversation(conversationId: string): void;
/**
+ * Load all configured per-provider concurrency limits
+ * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
+ * list when the concurrency extension isn't loaded (`{ limits: [] }`).
+ */
+ concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
+ /**
+ * Fetch the configured limit for one provider
+ * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
+ * `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
+ /**
+ * Set or update a provider's concurrency limit
+ * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
+ * positive integer (a non-positive body is `400`). At the cap, further requests
+ * queue oldest-agent-first rather than being sent immediately.
+ */
+ setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
+ /**
+ * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
+ * making it unlimited. `404` (not configured) and `503` (extension not loaded)
+ * both surface as `ok: false`.
+ */
+ deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
+ /**
+ * Fetch live concurrency status for every provider with a configured limit
+ * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Returns
+ * an empty list when the extension isn't loaded (`{ providers: [] }`).
+ */
+ concurrencyStatus(): Promise<ConcurrencyStatusResult>;
+ /**
* A critical error that blocks normal operation (e.g. the cross-device tab
* restore fetch failed). When non-null, a full-screen modal is shown with the
* error details. Cleared by `clearFatalError` (the modal's dismiss button).
@@ -994,10 +1037,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);
}
},
@@ -1789,6 +1836,128 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
unwatchConversation(conversationId);
},
+ // ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped)
+
+ async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/limits`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response (e.g. the extension returning `{}`) can
+ // never crash the renderer.
+ const limits = normalizeConcurrencyLimits(await res.json());
+ return { ok: true, limits };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limits request failed",
+ };
+ }
+ },
+
+ async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const limit = normalizeConcurrencyLimit(await res.json());
+ if (limit === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: limit.providerId, limit: limit.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limit request failed",
+ };
+ }
+ },
+
+ async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ limit }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const echoed = normalizeConcurrencyLimit(await res.json());
+ if (echoed === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency limit request failed",
+ };
+ }
+ },
+
+ async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true, providerId };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
+ };
+ }
+ },
+
+ async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
+ };
+ }
+ const providers = normalizeConcurrencyStatus(await res.json());
+ return { ok: true, providers };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency status request failed",
+ };
+ }
+ },
+
async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
try {
const res = await fetchImpl(`${httpBase}/system-prompt`);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 1534402..2cf473c 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1403,6 +1403,333 @@ describe("createAppStore", () => {
store.dispose();
});
+
+ // ── Concurrency (per-provider limits + live status; GLOBAL REST surface) ─────
+ //
+ // The concurrency API is a plain REST surface under /concurrency (provided by
+ // the `concurrency` extension). These tests fake all five endpoints + verify
+ // the store coerces the untyped JSON and routes the right method/URL.
+
+ function concurrencyFetchImpl(opts?: {
+ limits?: Record<string, unknown>;
+ limit?: Record<string, unknown>;
+ status?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const limits = opts?.limits ?? {
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ };
+ const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
+ const status = opts?.status ?? {
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/concurrency/status") && method === "GET") {
+ return new Response(JSON.stringify(status), { status: 200 });
+ }
+ if (url.endsWith("/concurrency/limits") && method === "GET") {
+ return new Response(JSON.stringify(limits), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "GET") {
+ return new Response(JSON.stringify(limit), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/limits/") + "/concurrency/limits/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, ...body }), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("concurrencyLimits loads + coerces the limits list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits tolerates a malformed/empty body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ limits: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/concurrency/limits"))
+ return new Response(JSON.stringify({ error: "Concurrency service not available" }), {
+ status: 503,
+ });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Concurrency service not available");
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit loads one provider's limit", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.limit).toBe(4);
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit surfaces a 404 (not configured)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "GET")
+ return new Response(
+ JSON.stringify({ error: "No concurrency limit configured for this provider" }),
+ { status: 404 },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("anthropic");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency limit configured");
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit PUTs { limit } to the provider URL and returns the echoed limit", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("anthropic", 8);
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("PUT");
+ expect(calls[0]?.url).toContain("/concurrency/limits/anthropic");
+ expect(calls[0]?.body).toEqual({ limit: 8 });
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("anthropic"); // echoed by the fake
+ expect(result.limit).toBe(8);
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit surfaces a 400 (non-positive body)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/limits/") && init?.method === "PUT")
+ return new Response(
+ JSON.stringify({ error: "Body must be { limit: <positive integer> }" }),
+ {
+ status: 400,
+ },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("umans", 0);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Body must be");
+ store.dispose();
+ });
+
+ it("deleteConcurrencyLimit DELETEs the provider URL and returns ok", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ calls.push({ url, method });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.deleteConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("DELETE");
+ expect(calls[0]?.url).toContain("/concurrency/limits/umans");
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ store.dispose();
+ });
+
+ it("concurrencyStatus loads + coerces the status list (incl. pausedUntil)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providers).toHaveLength(2);
+ expect(result.providers[0]).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ });
+ expect(result.providers[1]).toMatchObject({
+ providerId: "openai-compat",
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ });
+ store.dispose();
+ });
+
+ it("concurrencyStatus tolerates a malformed body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ status: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ 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();
+ });
});
describe("createAppStore — vision settings (global)", () => {
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 773cb91..cf57cea 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -25,6 +25,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/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index cd69071..e67ca5b 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -231,7 +231,7 @@
{/if}
{/snippet}
-<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
+<div class="flex flex-col gap-2 pt-4 pb-4 pl-6" role="log" aria-live="polite">
{#if hasEarlier && onShowEarlier}
<!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
<div class="flex justify-center">
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 7898448..afe1e3c 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -44,10 +44,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 images = $state<StagedImage[]>([]);
let inputEl: HTMLTextAreaElement | undefined;
@@ -63,21 +70,25 @@
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
- // - running + text/images → "Queue" (steers via chat.queue — text only)
- // - running + empty → "Stop" (aborts via POST /stop)
+ // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - 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.)
// Steering never carries images: when running with images staged but no text,
- // fall back to "Send" semantics is wrong mid-turn — instead queue the text part
- // only (images stay staged). Simplest correct rule: queue is text-only and only
- // offered with text; images-without-text while running is an unusual case that
- // still sends (the server auto-starts/resolves). Keep the three-mode logic
- // driven by text presence for the queue vs stop split.
+ // the images stay staged (queue is text-only). Images-without-text while running
+ // is an unusual case that still sends (the server auto-starts/resolves).
+ const inFlight = $derived(status === "running" || status === "queued");
const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
- if (status === "running" && !hasText && !hasImages && onStop !== undefined) return "stop";
- if (status === "running" && hasText && onQueue !== undefined) return "queue";
+ if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
+ if (inFlight && hasText && onQueue !== undefined) return "queue";
return "send";
});
const placeholder = $derived(
- status === "running" ? "Steer the conversation..." : "Type a message, paste or drop an image…",
+ status === "queued"
+ ? "Queued for a slot…"
+ : status === "running"
+ ? "Steer the conversation..."
+ : "Type a message, paste or drop an image…",
);
// As the window fills, escalate color: calm → warning → danger.
@@ -331,8 +342,15 @@
<!-- 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"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
+ {#if status === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). -->
+ <span
+ class="loading loading-ring 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
xmlns="http://www.w3.org/2000/svg"
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts
new file mode 100644
index 0000000..151acb8
--- /dev/null
+++ b/src/features/concurrency/index.ts
@@ -0,0 +1,44 @@
+export type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitEntry,
+ // Contract shapes re-exported for a single import surface.
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResponse,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ ConcurrencyStatusResult,
+ DeleteConcurrencyLimit,
+ GetConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ SaveConcurrencyLimit,
+ SetConcurrencyLimitRequest,
+} from "./logic/types";
+export type { Badge, ConcurrencyLimitView, ConcurrencyStatusView } from "./logic/view-model";
+export {
+ formatPauseDuration,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ normalizeLimit,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./logic/view-model";
+export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte";
+export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "concurrency",
+ description: "Per-provider concurrency limits + live in-flight/queue status",
+} as const;
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
new file mode 100644
index 0000000..f05211f
--- /dev/null
+++ b/src/features/concurrency/logic/types.ts
@@ -0,0 +1,82 @@
+import type {
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyLimitRequest,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte.
+ *
+ * The backend tracks + limits how many concurrent token-generating API requests
+ * are in flight PER PROVIDER. When the cap is reached, additional requests queue
+ * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's
+ * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence),
+ * managed via a plain REST surface under `/concurrency/...` provided by the
+ * `concurrency` extension. When the extension isn't loaded, the list + status
+ * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the
+ * single / PUT / DELETE endpoints return `503`.
+ *
+ * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they
+ * are imported directly (mirrors `mcp` / `computer`). The result types + injected
+ * ports below are FE-owned (the composition root adapts the store's HTTP calls to
+ * them). The endpoints are GLOBAL (not workspace- or conversation-scoped).
+ */
+
+/** Re-export the contract shapes so consumers import a single surface. */
+export type {
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyLimitRequest,
+};
+
+/**
+ * A configured concurrency limit — one provider's cap on in-flight requests.
+ * Same shape as the contract's `ConcurrencyLimitResponse`.
+ */
+export interface ConcurrencyLimitEntry {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+// ── Result types (port outcomes; the store returns these directly) ──────────────
+
+/** Outcome of `GET /concurrency/limits` (all configured limits). */
+export type ConcurrencyLimitsResult =
+ | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit
+ * for one provider. `GET` returns `404` when the provider has no limit (surfaced
+ * as `ok: false`); `PUT` returns `400` for a non-positive-integer body.
+ */
+export type ConcurrencyLimitResult =
+ | { readonly ok: true; readonly providerId: string; readonly limit: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */
+export type ConcurrencyDeleteResult =
+ | { readonly ok: true; readonly providerId: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /concurrency/status` (live status for every limited provider). */
+export type ConcurrencyStatusResult =
+ | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>;
+export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>;
+export type SaveConcurrencyLimit = (
+ providerId: string,
+ limit: number,
+) => Promise<ConcurrencyLimitResult>;
+export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
+export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
new file mode 100644
index 0000000..82b72b0
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -0,0 +1,405 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ formatPauseDuration,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./view-model";
+
+const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 0,
+ paused: false,
+ ...over,
+});
+
+// ── parseLimitInput ───────────────────────────────────────────────────────────
+
+describe("parseLimitInput", () => {
+ it("accepts positive integers", () => {
+ expect(parseLimitInput("4")).toBe(4);
+ expect(parseLimitInput(" 12 ")).toBe(12);
+ expect(parseLimitInput("1")).toBe(1);
+ });
+
+ it("rejects zero, negatives, non-integers, and garbage", () => {
+ expect(parseLimitInput("0")).toBeNull();
+ expect(parseLimitInput("-1")).toBeNull();
+ expect(parseLimitInput("4.5")).toBeNull();
+ expect(parseLimitInput("")).toBeNull();
+ expect(parseLimitInput(" ")).toBeNull();
+ expect(parseLimitInput("abc")).toBeNull();
+ expect(parseLimitInput("4abc")).toBeNull();
+ });
+});
+
+// ── providerFromModel / providerOptions ───────────────────────────────────────
+
+describe("providerFromModel", () => {
+ it("takes the part before the first slash", () => {
+ expect(providerFromModel("openai/gpt-4o")).toBe("openai");
+ expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat");
+ });
+ it("returns the whole string when there is no slash", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ });
+});
+
+describe("providerOptions", () => {
+ it("derives distinct provider ids from models, first-seen order", () => {
+ expect(
+ providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []),
+ ).toEqual(["openai", "umans"]);
+ });
+ it("unions with providers already carrying a configured limit", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([
+ "openai",
+ "anthropic",
+ ]);
+ });
+ it("does not duplicate a provider present in both models and limits", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([
+ "openai",
+ ]);
+ });
+ it("ignores models whose provider prefix is empty", () => {
+ expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]);
+ });
+ it("returns [] when there are no models and no limits", () => {
+ expect(providerOptions([], [])).toEqual([]);
+ });
+});
+
+// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
+
+describe("formatPauseDuration", () => {
+ it("formats seconds / minutes+seconds / hours+minutes", () => {
+ expect(formatPauseDuration(30_000)).toBe("30s");
+ expect(formatPauseDuration(65_000)).toBe("1m 05s");
+ expect(formatPauseDuration(3_660_000)).toBe("1h 01m");
+ });
+
+ it("non-positive → resuming", () => {
+ expect(formatPauseDuration(0)).toBe("resuming");
+ expect(formatPauseDuration(-5_000)).toBe("resuming");
+ });
+});
+
+describe("pauseLabel", () => {
+ it("null when not paused", () => {
+ expect(pauseLabel(false, undefined, 0)).toBeNull();
+ expect(pauseLabel(false, 10_000, 0)).toBeNull();
+ });
+
+ it("'paused' (bare) when paused without a usable timestamp", () => {
+ expect(pauseLabel(true, undefined, 0)).toBe("paused");
+ expect(pauseLabel(true, null, 0)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ });
+
+ it("countdown when paused with a future timestamp", () => {
+ const now = 1_000_000;
+ expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s");
+ expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s");
+ });
+
+ it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => {
+ expect(pauseLabel(true, 0, 1_000)).toBe("paused");
+ expect(pauseLabel(true, 1_000, 2_000)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ // The countdown prefix only appears with a FUTURE timestamp:
+ expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s");
+ });
+});
+
+// ── viewConcurrencyStatus ──────────────────────────────────────────────────────
+
+describe("viewConcurrencyStatus", () => {
+ it("serving under capacity → success badge, in-flight label, no queue", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0);
+ expect(v.inFlightLabel).toBe("2/4");
+ expect(v.queuedLabel).toBe("no queue");
+ expect(v.pausedLabel).toBeNull();
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("at capacity with a queue → warning badge + busy (spinner)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0);
+ expect(v.inFlightLabel).toBe("4/4");
+ expect(v.queuedLabel).toBe("3 queued");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("idle (no in-flight) → neutral badge, not busy", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("neutral");
+ expect(v.busy).toBe(false);
+ expect(v.inFlightLabel).toBe("0/4");
+ });
+
+ it("paused → warning badge + pause countdown label", () => {
+ const now = 1_000_000;
+ const v = viewConcurrencyStatus(
+ status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }),
+ now,
+ );
+ expect(v.paused).toBe(true);
+ expect(v.pausedLabel).toBe("paused — resumes in 30s");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("at capacity but no queue → success (busy only when queuing)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("normalizes garbage counts to 0 and a malformed limit to 1", () => {
+ const v = viewConcurrencyStatus(
+ {
+ providerId: "x",
+ limit: -3,
+ inFlight: Number.NaN,
+ queued: "oops" as unknown as number,
+ paused: false,
+ },
+ 0,
+ );
+ expect(v.limit).toBe(1);
+ expect(v.inFlight).toBe(0);
+ expect(v.queued).toBe(0);
+ expect(v.inFlightLabel).toBe("0/1");
+ });
+
+ it("viewConcurrencyStatuses maps a list preserving order", () => {
+ const views = viewConcurrencyStatuses(
+ [status({ providerId: "a" }), status({ providerId: "b" })],
+ 0,
+ );
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
+
+describe("viewConcurrencyLimit", () => {
+ it("passes through id + normalizes the limit", () => {
+ const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 });
+ expect(v.providerId).toBe("umans");
+ expect(v.limit).toBe(4);
+ });
+
+ it("clamps a malformed limit to 1", () => {
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2);
+ });
+
+ it("viewConcurrencyLimits maps a list preserving order", () => {
+ const views = viewConcurrencyLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]);
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── summarizeLimits / summarizeStatus ──────────────────────────────────────────
+
+describe("summarizeLimits", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeLimits([])).toBe("No limits configured");
+ });
+ it("counts limits (singular/plural)", () => {
+ expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured");
+ expect(
+ summarizeLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]),
+ ).toBe("2 limits configured");
+ });
+});
+
+describe("summarizeStatus", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeStatus([], 0)).toBe("No limits configured");
+ });
+ it("aggregates providers + in-flight totals", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 2 }),
+ status({ providerId: "b", limit: 6, inFlight: 3 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/10 in flight");
+ });
+ it("includes queued + paused fragments only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }),
+ status({
+ providerId: "b",
+ limit: 4,
+ inFlight: 1,
+ queued: 0,
+ paused: true,
+ pausedUntil: 1000,
+ }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused");
+ });
+ it("singular provider", () => {
+ expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe(
+ "1 provider · 1/4 in flight",
+ );
+ });
+});
+
+// ── Network-seam normalizers ───────────────────────────────────────────────────
+
+describe("normalizeConcurrencyLimits", () => {
+ it("coerces a well-formed body", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ });
+
+ it("non-array / missing limits → []", () => {
+ expect(normalizeConcurrencyLimits({})).toEqual([]);
+ expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]);
+ expect(normalizeConcurrencyLimits(null)).toEqual([]);
+ expect(normalizeConcurrencyLimits(undefined)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps limits", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "", limit: 9 },
+ { providerId: 123, limit: 1 },
+ { limit: 2 },
+ { providerId: "anthropic", limit: -5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "anthropic", limit: 1 },
+ ]);
+ });
+});
+
+describe("normalizeConcurrencyLimit", () => {
+ it("coerces a well-formed single response", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({
+ providerId: "umans",
+ limit: 4,
+ });
+ });
+ it("null when the provider id is missing/non-string", () => {
+ expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit(null)).toBeNull();
+ });
+ it("clamps a malformed limit to 1", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({
+ providerId: "x",
+ limit: 1,
+ });
+ });
+});
+
+describe("normalizeConcurrencyStatus", () => {
+ it("coerces a well-formed body, preserving pausedUntil only when present", () => {
+ const now = Date.now();
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ },
+ ],
+ });
+ expect(providers).toHaveLength(2);
+ const [first, second] = providers;
+ expect(first).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ });
+ expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
+ expect(second).toEqual({
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ });
+ });
+
+ it("non-array / missing providers → []", () => {
+ expect(normalizeConcurrencyStatus({})).toEqual([]);
+ expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]);
+ expect(normalizeConcurrencyStatus(null)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps counts", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "", inFlight: 1 },
+ { limit: 2 },
+ { providerId: 9, inFlight: 0 },
+ { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" },
+ ],
+ });
+ expect(providers).toEqual([
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false },
+ ]);
+ });
+
+ it("omits pausedUntil when it is not a finite number", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" },
+ { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null },
+ ],
+ });
+ for (const p of providers) expect("pausedUntil" in p).toBe(false);
+ });
+});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
new file mode 100644
index 0000000..7a4fe0e
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.ts
@@ -0,0 +1,299 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import type { ConcurrencyLimitEntry } from "./types";
+
+/**
+ * Pure view-models for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, summaries),
+ * holds the limit-input parsing, and the network-seam normalizers the composition
+ * root coerces the untyped JSON with.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A configured limit row shaped for display. */
+export interface ConcurrencyLimitView {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+/**
+ * A live status row shaped for display. Carries the raw counts plus pre-computed
+ * labels so the template stays thin.
+ */
+export interface ConcurrencyStatusView {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ /** "2/4" — in-flight slots held vs the cap. */
+ readonly inFlightLabel: string;
+ /** "1 queued" / "no queue". */
+ readonly queuedLabel: string;
+ /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */
+ readonly pausedLabel: string | null;
+ readonly badge: Badge;
+ /** True when paused or at capacity (show a spinner). */
+ readonly busy: boolean;
+}
+
+// ── Limit input parsing ───────────────────────────────────────────────────────
+
+/**
+ * Parse a raw limit input into a positive integer, or `null` when it is not a
+ * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc".
+ * Drives the Add/Save button's disabled state so an invalid value never reaches
+ * the backend (the backend is still the authority — it 400s a non-positive body).
+ */
+export function parseLimitInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 1 ? n : null;
+}
+
+/**
+ * Coerce an untrusted limit value into a positive integer (default 1). Used when
+ * normalizing backend responses so a malformed `limit` can never be 0/negative.
+ */
+export function normalizeLimit(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 1;
+ const int = Math.floor(n);
+ return int >= 1 ? int : 1;
+}
+
+// ── Provider options (the Add-form dropdown) ───────────────────────────────────
+//
+// A concurrency `providerId` is the credential name that prefixes a model name
+// (`<provider>/<model>` — the same key the model picker groups by). The dropdown
+// is the UNION of providers discoverable from the available models AND providers
+// already carrying a configured limit (so a limit set out-of-band but whose
+// model list is empty still appears), in first-seen order. Models are the
+// authority; a provider with models but no limit is still selectable (Add sets it).
+
+/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */
+export function providerFromModel(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? full : full.slice(0, i);
+}
+
+/** Distinct provider ids to offer in the Add dropdown, first-seen order. */
+export function providerOptions(
+ models: readonly string[],
+ limits: readonly ConcurrencyLimitEntry[],
+): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ const add = (p: string): void => {
+ if (p !== "" && !seen.has(p)) {
+ seen.add(p);
+ out.push(p);
+ }
+ };
+ for (const m of models) add(providerFromModel(m));
+ for (const l of limits) add(l.providerId);
+ return out;
+}
+
+// ── Status → display view ──────────────────────────────────────────────────────
+
+const NO_LIMITS = "No limits configured";
+
+/**
+ * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s",
+ * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes
+ * this on each status poll (every ~2s) — a 1s ticking timer is optional.
+ */
+export function formatPauseDuration(remainingMs: number): string {
+ if (remainingMs <= 0) return "resuming";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * The pause label for a status entry, or `null` when not paused. When paused with
+ * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a
+ * usable timestamp, shows "paused". Pure via the injectable `now`.
+ */
+export function pauseLabel(
+ paused: boolean,
+ pausedUntil: number | null | undefined,
+ now: number = Date.now(),
+): string | null {
+ if (!paused) return null;
+ if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) {
+ const remaining = pausedUntil - now;
+ if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`;
+ }
+ // Paused without a usable future timestamp (missing, non-finite, or already
+ // expired): the next status poll will clear `paused`. Show "paused" meanwhile.
+ return "paused";
+}
+
+/**
+ * Build a display view for a status entry. `now` is injectable for tests
+ * (defaults to `Date.now()`); the composition-root component passes nothing in
+ * production (it recomputes on each poll).
+ */
+export function viewConcurrencyStatus(
+ entry: ConcurrencyStatusEntry,
+ now: number = Date.now(),
+): ConcurrencyStatusView {
+ const limit = normalizeLimit(entry.limit);
+ const inFlight = clampCount(entry.inFlight);
+ const queued = clampCount(entry.queued);
+ const paused = entry.paused === true;
+ const atCapacity = inFlight >= limit;
+ let badge: Badge;
+ if (paused) badge = "warning";
+ else if (atCapacity && queued > 0) badge = "warning";
+ else if (inFlight > 0) badge = "success";
+ else badge = "neutral";
+ return {
+ providerId: entry.providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ inFlightLabel: `${inFlight}/${limit}`,
+ queuedLabel: queued === 0 ? "no queue" : `${queued} queued`,
+ pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
+ badge,
+ busy: paused || (atCapacity && queued > 0),
+ };
+}
+
+export function viewConcurrencyStatuses(
+ entries: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): readonly ConcurrencyStatusView[] {
+ return entries.map((e) => viewConcurrencyStatus(e, now));
+}
+
+/** A display view for a configured limit entry. */
+export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView {
+ return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) };
+}
+
+export function viewConcurrencyLimits(
+ entries: readonly ConcurrencyLimitEntry[],
+): readonly ConcurrencyLimitView[] {
+ return entries.map(viewConcurrencyLimit);
+}
+
+// ── Summaries ──────────────────────────────────────────────────────────────────
+
+/** A one-line summary of the configured limits list, e.g. "2 limits configured". */
+export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string {
+ if (limits.length === 0) return NO_LIMITS;
+ return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`;
+}
+
+/**
+ * A one-line summary of the live status, e.g.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused". Only the queued / paused
+ * fragments appear when non-zero.
+ */
+export function summarizeStatus(
+ providers: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): string {
+ if (providers.length === 0) return NO_LIMITS;
+ let inFlight = 0;
+ let limitTotal = 0;
+ let queued = 0;
+ let paused = 0;
+ for (const p of providers) {
+ const limit = normalizeLimit(p.limit);
+ inFlight += clampCount(p.inFlight);
+ limitTotal += limit;
+ queued += clampCount(p.queued);
+ if (p.paused === true) paused += 1;
+ }
+ const parts: string[] = [];
+ parts.push(
+ `${providers.length} provider${providers.length === 1 ? "" : "s"}`,
+ `${inFlight}/${limitTotal} in flight`,
+ );
+ if (queued > 0) parts.push(`${queued} queued`);
+ if (paused > 0) parts.push(`${paused} paused`);
+ // Touch `now` so the summary recomputes alongside the per-row pause countdown.
+ void now;
+ return parts.join(" · ");
+}
+
+// ── Network-seam normalization (pure; called by the composition root) ───────────
+//
+// The concurrency responses are untyped JSON at runtime. The store coerces each
+// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the
+// extension returning `{}`) can never crash the renderer. Mirrors the
+// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards.
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */
+function clampCount(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 0;
+ const int = Math.floor(n);
+ return int >= 0 ? int : 0;
+}
+
+/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */
+export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.limits)) return [];
+ const limits = data.limits as readonly unknown[];
+ return limits
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r) => ({
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ }))
+ .filter((r) => r.providerId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */
+export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, limit: normalizeLimit(data.limit) };
+}
+
+/**
+ * Coerce an untrusted `GET /concurrency/status` body into a typed status list.
+ * `pausedUntil` is included only when it is a finite number (it is absent when
+ * not paused).
+ */
+export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.providers)) return [];
+ const providers = data.providers as readonly unknown[];
+ return providers
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r): ConcurrencyStatusEntry => {
+ const base = {
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ inFlight: clampCount(r.inFlight),
+ queued: clampCount(r.queued),
+ paused: r.paused === true,
+ };
+ const pausedUntil =
+ typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)
+ ? r.pausedUntil
+ : undefined;
+ return pausedUntil !== undefined ? { ...base, pausedUntil } : base;
+ })
+ .filter((r) => r.providerId !== "");
+}
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
new file mode 100644
index 0000000..5aacb88
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -0,0 +1,119 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model";
+ import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types";
+
+ let {
+ limit,
+ save,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ save: SaveConcurrencyLimit;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // Inline-edit state: the raw text bound to the limit input. Seeded from the
+ // row's canonical limit, but only while the field is untouched — so a save
+ // echo / list refresh re-syncs it without clobbering an in-flight edit. Mirrors
+ // the ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let draft = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved" confirmation after a successful save (mirrors ChatLimitField).
+ * Cleared when the field is edited again. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incoming = String(limit.limit);
+ untrack(() => {
+ if (draft === lastSeed) draft = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const parsed = $derived(parseLimitInput(draft));
+ const dirty = $derived(parsed !== null && parsed !== limit.limit);
+
+ // Clear the "Saved" hint + any error as soon as the user edits the field.
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ async function handleSave(): Promise<void> {
+ if (parsed === null || parsed === limit.limit) return;
+ saving = true;
+ error = null;
+ const result = await save(limit.providerId, parsed);
+ saving = false;
+ if (result.ok) {
+ // Reflect the echoed limit back into the field immediately (the prop will
+ // also re-assert it via the seed effect above once the parent reloads).
+ draft = String(result.limit);
+ lastSeed = draft;
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+
+ async function handleRemove(): Promise<void> {
+ removing = true;
+ error = null;
+ const result = await remove(limit.providerId);
+ removing = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ // On success the parent drops this row (re-loaded limits list).
+ }
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center gap-2">
+ <span class="flex-1 truncate font-medium font-mono" title={limit.providerId}>{limit.providerId}</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={draft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!dirty || saving || removing}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs text-error"
+ aria-label={`Remove concurrency limit for ${limit.providerId}`}
+ disabled={saving || removing}
+ onclick={handleRemove}
+ >
+ {#if removing}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-xs text-success">Saved.</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
new file mode 100644
index 0000000..f8a2199
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -0,0 +1,331 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ type Badge,
+ parseLimitInput,
+ providerOptions,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimits,
+ viewConcurrencyStatuses,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ models,
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
+ models: readonly string[];
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedLimits = $state(false);
+ /** Re-entrancy guard for background/silent refreshes (no UI — prevents
+ * overlapping fetches). The refresh is near-instant, so a visible loading
+ * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the
+ * heartbeat runs list). */
+ let limitsInFlight = false;
+
+ // Add-form state. The provider id is chosen from a dropdown of known providers
+ // (derived from the available models + any already-configured limit providers).
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let adding = $state(false);
+ let addError = $state<string | null>(null);
+
+ const providerOpts = $derived(providerOptions(models, limits));
+ const limitViews = $derived(viewConcurrencyLimits(limits));
+ const limitsSummary = $derived(summarizeLimits(limits));
+ const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
+ const canAdd = $derived(
+ newProviderId !== "" &&
+ parsedNewLimit !== null &&
+ !limits.some((l) => l.providerId === newProviderId) &&
+ !adding,
+ );
+
+ // Keep the dropdown selection valid: default to the first option, and if the
+ // selected provider is removed from the options (e.g. its limit was deleted and
+ // it has no models), fall back to the first remaining option. Runs untracked so
+ // it doesn't loop on its own assignment.
+ $effect(() => {
+ const opts = providerOpts;
+ untrack(() => {
+ if (opts.length === 0) {
+ if (newProviderId !== "") newProviderId = "";
+ return;
+ }
+ if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? "";
+ });
+ });
+
+ async function refreshLimits(): Promise<void> {
+ if (limitsInFlight) return;
+ limitsInFlight = true;
+ const result = await loadLimits();
+ limitsInFlight = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-refresh.
+ limitsError = null;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || newProviderId === "") return;
+ adding = true;
+ addError = null;
+ const result = await saveLimit(newProviderId, parsedNewLimit);
+ adding = false;
+ if (result.ok) {
+ newLimitInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ } else {
+ addError = result.error;
+ }
+ }
+
+ // Wrap the ports so a row's save/remove reloads the authoritative list + status
+ // on success (the row still gets the result to drive its own UI).
+ async function rowSave(providerId: string, limit: number) {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ async function rowRemove(providerId: string) {
+ const result = await deleteLimit(providerId);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // ── Live status (polls while mounted) ───────────────────────────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedStatus = $state(false);
+ /** Re-entrancy guard for the 2s background poll (no UI — a visible loading
+ * indicator flickered every poll because the refresh is near-instant; it stays
+ * INVISIBLE, mirroring the heartbeat runs list). */
+ let statusInFlight = false;
+ let now = $state(Date.now());
+
+ // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls.
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+
+ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now));
+ const statusSummary = $derived(summarizeStatus(statusEntries, now));
+
+ async function refreshStatus(): Promise<void> {
+ if (statusInFlight) return;
+ statusInFlight = true;
+ const result = await loadStatus();
+ statusInFlight = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-poll.
+ statusError = null;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (a running provider's in-flight/queued/paused transitions stay fresh
+ // without a manual refresh). Runs once — no reactive deps read inside.
+ $effect(() => {
+ untrack(() => {
+ void refreshLimits();
+ void refreshStatus();
+ });
+ const h = setInterval(() => {
+ void refreshStatus();
+ }, STATUS_POLL_MS);
+ return () => clearInterval(h);
+ });
+</script>
+
+<div class="flex flex-col gap-4">
+ <!-- Limits (config) -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => refreshLimits()}
+ aria-label="Refresh concurrency limits"
+ >
+ Refresh
+ </button>
+ </div>
+
+ <!-- Add form -->
+ <form
+ class="flex flex-wrap items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void handleAdd();
+ }}
+ >
+ <label class="flex flex-col gap-1">
+ <span class="text-[10px] uppercase opacity-60">Provider</span>
+ <select
+ class="select select-bordered select-xs w-40 font-mono"
+ aria-label="Provider"
+ bind:value={newProviderId}
+ disabled={adding || providerOpts.length === 0}
+ >
+ {#if providerOpts.length === 0}
+ <option value="" disabled>No providers available</option>
+ {:else}
+ {#each providerOpts as provider (provider)}
+ <option value={provider}>{provider}</option>
+ {/each}
+ {/if}
+ </select>
+ </label>
+ <label class="flex flex-col gap-1">
+ <span class="text-[10px] uppercase opacity-60">Limit</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ placeholder="4"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ </label>
+ <button
+ type="submit"
+ class="btn btn-primary btn-xs"
+ disabled={!canAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Add
+ {/if}
+ </button>
+ </form>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow {limit} save={rowSave} remove={rowRemove} />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+
+ <!-- Live status -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Live status</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => refreshStatus()}
+ aria-label="Refresh concurrency status"
+ >
+ Refresh
+ </button>
+ </div>
+
+ <span class="text-xs opacity-70">{statusSummary}</span>
+
+ {#if statusError}
+ <p class="text-xs text-error">{statusError}</p>
+ {:else if hasLoadedStatus && statusViews.length === 0}
+ <p class="text-xs opacity-60">No limits configured — nothing to report.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each statusViews as s (s.providerId)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono" title={s.providerId}>{s.providerId}</span>
+ <span class="badge badge-sm {badgeClass[s.badge]} gap-1">
+ {#if s.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {#if s.paused}
+ Paused
+ {:else if s.inFlight >= s.limit && s.queued > 0}
+ At capacity
+ {:else if s.inFlight > 0}
+ Active
+ {:else}
+ Idle
+ {/if}
+ </span>
+ </div>
+ <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span>
+ <span>{s.queuedLabel}</span>
+ </div>
+ {#if s.pausedLabel}
+ <span class="text-xs text-warning">{s.pausedLabel}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
new file mode 100644
index 0000000..3dc8e78
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -0,0 +1,224 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../logic/types";
+import ConcurrencyView from "./ConcurrencyView.svelte";
+
+// Available models → provider ids are "umans", "anthropic", "openai-compat".
+const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const;
+
+// Fakes for the four injected ports. Each resolves immediately so the mount
+// effect's initial load settles in a microtask (assertions await via findBy*).
+
+function makeFakes(opts?: {
+ limits?: readonly { providerId: string; limit: number }[];
+ status?: readonly ConcurrencyStatusEntry[];
+}) {
+ let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
+ const status = opts?.status ?? [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ ];
+
+ const calls = {
+ loadLimits: 0,
+ loadStatus: 0,
+ saves: [] as { providerId: string; limit: number }[],
+ deletes: [] as string[],
+ };
+
+ return {
+ calls,
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
+ calls.loadLimits++;
+ return { ok: true, limits };
+ },
+ saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
+ calls.saves.push({ providerId, limit });
+ // Reflect the new limit into the list the next load returns.
+ limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ return { ok: true, providerId, limit };
+ },
+ deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => {
+ calls.deletes.push(providerId);
+ limits = limits.filter((l) => l.providerId !== providerId);
+ return { ok: true, providerId };
+ },
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => {
+ calls.loadStatus++;
+ return { ok: true, providers: status };
+ },
+ };
+}
+
+describe("ConcurrencyView", () => {
+ it("loads + renders the configured limits and live status on mount", async () => {
+ const fakes = makeFakes();
+ render(ConcurrencyView, {
+ props: {
+ models: MODELS,
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ // The provider dropdown is populated from the available models' providers.
+ const providerSelect = await screen.findByLabelText("Provider");
+ expect(providerSelect).toBeVisible();
+ // Limits summary (unique to the limits section) + the row's remove control.
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible();
+ // Status summary (unique to the status section).
+ expect(await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/)).toBeInTheDocument();
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1);
+ expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
+ });
+
+ it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => {
+ // The 2s status poll + post-mutation reloads are SILENT: they never toggle a
+ // visible loading state, so the Refresh buttons are plain-text (no spinner)
+ // and the list area never reflows mid-refresh. Regression guard for the
+ // flicker fix (mirrors the heartbeat runs list).
+ const fakes = makeFakes();
+ render(ConcurrencyView, {
+ props: {
+ models: MODELS,
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+
+ const statusRefresh = screen.getByLabelText("Refresh concurrency status");
+ expect(statusRefresh).toHaveTextContent("Refresh");
+ expect(statusRefresh.querySelector(".loading-spinner")).toBeNull();
+ expect(statusRefresh).not.toBeDisabled();
+
+ const limitsRefresh = screen.getByLabelText("Refresh concurrency limits");
+ expect(limitsRefresh).toHaveTextContent("Refresh");
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+
+ // A manual refresh stays silent too (no spinner appears).
+ await fakes.loadStatus();
+ expect(statusRefresh.querySelector(".loading-spinner")).toBeNull();
+ });
+
+ it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: {
+ models: MODELS,
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ const providerSelect = await screen.findByLabelText("Provider");
+ // Choose "anthropic" from the dropdown (the list is auto-selected first).
+ await user.selectOptions(providerSelect, "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Add" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ // After save the component reloads the limits list (now showing the row).
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible();
+ });
+
+ it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: {
+ models: MODELS,
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ const providerSelect = await screen.findByLabelText("Provider");
+ // A provider is auto-selected from the dropdown.
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ const addBtn = screen.getByRole("button", { name: "Add" });
+ expect(addBtn).toBeDisabled(); // no limit entered yet
+
+ // An invalid (non-numeric) limit keeps Add disabled.
+ await user.type(screen.getByPlaceholderText("4"), "abc");
+ expect(addBtn).toBeDisabled();
+
+ // A valid positive-integer limit enables Add.
+ const limitInput = screen.getByPlaceholderText("4");
+ await user.clear(limitInput);
+ await user.type(limitInput, "5");
+ expect(addBtn).toBeEnabled();
+ });
+
+ it("shows no-providers + disables the dropdown when there are no models", async () => {
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: {
+ models: [],
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ const providerSelect = await screen.findByLabelText("Provider");
+ expect(providerSelect).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
+ });
+
+ it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, {
+ props: {
+ models: MODELS,
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ // Wait for the limits to load (unique summary) before interacting.
+ await screen.findByText(/1 limit configured/);
+ await user.click(screen.getByLabelText("Remove concurrency limit for umans"));
+
+ expect(fakes.calls.deletes).toEqual(["umans"]);
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ });
+
+ it("surfaces a load error from the limits endpoint", async () => {
+ const failing = {
+ models: MODELS,
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
+ ok: false,
+ error: "Concurrency service not available",
+ }),
+ saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
+ deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ };
+ render(ConcurrencyView, { props: failing });
+ expect(await screen.findByText("Concurrency service not available")).toBeVisible();
+ });
+});
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 6ac90a3..7520215 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -14,7 +14,7 @@ export {
} from "./tabs";
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
-export { default as TabBar } from "./ui/TabBar.svelte";
+export { default as TabList } from "./ui/TabList.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index c31d2e7..ec93076 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -6,7 +6,6 @@ import {
createTab,
deriveTitle,
initialState,
- isStuckToEnd,
MIN_HANDLE_LENGTH,
newDraft,
selectTab,
@@ -194,29 +193,6 @@ describe("deriveTitle", () => {
});
});
-describe("isStuckToEnd", () => {
- it("is false when the strip does not overflow", () => {
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false);
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false);
- });
-
- it("is true when overflowing and scrolled to the left", () => {
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
- });
-
- it("is true when overflowing and scrolled to the middle", () => {
- expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
- });
-
- it("is false when overflowing but scrolled fully to the right", () => {
- expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
- });
-
- it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => {
- expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
- });
-});
-
describe("shortHandle", () => {
it("uses the minimum length when the id is unique", () => {
const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]);
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index bc7e30b..63cda35 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -87,26 +87,6 @@ export function activeTab(state: TabsState): Tab | null {
return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
}
-export interface ScrollMetrics {
- readonly scrollLeft: number;
- readonly clientWidth: number;
- readonly scrollWidth: number;
-}
-
-const STUCK_EPSILON = 1;
-
-/**
- * True when a right-pinned sticky element is floating over scrolled content — the
- * strip overflows horizontally AND is not scrolled fully to the right. When it is
- * at rest (no overflow, or scrolled to the end so it sits at its natural position)
- * this returns false. Pure: layout measurements in, boolean out.
- */
-export function isStuckToEnd(m: ScrollMetrics): boolean {
- const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON;
- const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON;
- return overflows && notAtEnd;
-}
-
export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string {
const trimmed = message.trim().replace(/\s+/g, " ");
if (trimmed.length === 0) return DEFAULT_TITLE;
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 087b28c..0dc5e15 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,8 +1,8 @@
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
-import TabBar from "./ui/TabBar.svelte";
+import TabList from "./ui/TabList.svelte";
const sampleTabs: readonly Tab[] = [
{ conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
@@ -10,9 +10,9 @@ const sampleTabs: readonly Tab[] = [
{ conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
-describe("TabBar", () => {
+describe("TabList", () => {
it("renders one role=tab element per tab showing each title", () => {
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -29,8 +29,8 @@ describe("TabBar", () => {
expect(tabs[2]).toHaveTextContent("Third");
});
- it("applies tab-active to the active tab only", () => {
- render(TabBar, {
+ it("marks the active tab as aria-selected", () => {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c2",
@@ -41,24 +41,9 @@ describe("TabBar", () => {
});
const tabs = screen.getAllByRole("tab");
- expect(tabs[0]).not.toHaveClass("tab-active");
- expect(tabs[1]).toHaveClass("tab-active");
- expect(tabs[2]).not.toHaveClass("tab-active");
- });
-
- it("applies tab-active to New chat button when activeConversationId is null", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: null,
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("tab-active");
+ expect(tabs[0]).toHaveAttribute("aria-selected", "false");
+ expect(tabs[1]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[2]).toHaveAttribute("aria-selected", "false");
});
it("calls onSelect with the conversationId when a tab is clicked", async () => {
@@ -66,7 +51,7 @@ describe("TabBar", () => {
const onClose = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -91,7 +76,7 @@ describe("TabBar", () => {
const onClose = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -115,7 +100,7 @@ describe("TabBar", () => {
const onNewDraft = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -131,23 +116,8 @@ describe("TabBar", () => {
expect(onNewDraft).toHaveBeenCalledTimes(1);
});
- it("the New chat button has the sticky class", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("sticky");
- });
-
it("shows visible 'New Chat' text when activeConversationId is null", () => {
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: null,
@@ -162,7 +132,7 @@ describe("TabBar", () => {
});
it("does not show 'New Chat' text when a real tab is active", () => {
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -181,7 +151,7 @@ describe("TabBar", () => {
{ conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
{ conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
];
- render(TabBar, {
+ render(TabList, {
props: {
tabs,
activeConversationId: "3f9a1b2c-1111",
@@ -195,19 +165,134 @@ describe("TabBar", () => {
expect(screen.getByText("7c2d")).toBeInTheDocument();
});
- it("renders fixed-width tabs", () => {
- render(TabBar, {
+ it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ // The scroll region containing the tab rows is a vertical flex column.
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.length).toBeGreaterThan(0);
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("flex-col");
+ });
+
+ it("caps the tab list region at 80vh so a long set scrolls internally", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("max-h-[80vh]");
+ expect(region).toHaveClass("overflow-y-auto");
+ });
+
+ it("calls onRename when a tab title is double-clicked and committed with Enter", async () => {
+ const onRename = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onRename,
+ },
+ });
+
+ const titleButtons = screen.getAllByRole("button");
+ // The inline-rename trigger is the title span (role=button) — find the one
+ // whose text matches the first tab's title.
+ const titleButton = titleButtons.find((b) => b.textContent === "First");
+ if (!titleButton) throw new Error("title button not found");
+ await user.dblClick(titleButton);
+
+ const input = screen.getByRole("textbox");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(onRename).toHaveBeenCalledTimes(1);
+ expect(onRename).toHaveBeenCalledWith("c1", "Renamed");
+ });
+
+ it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ const onSelect = vi.fn();
+
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" });
+ await fireEvent.click(idBadge);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText).toHaveBeenCalledWith("c1");
+ // The badge text is NOT swapped (no width shift) — the highlight (selection)
+ // is the only indicator.
+ expect(idBadge).toHaveTextContent("c1");
+ expect(idBadge).not.toHaveTextContent("Copied");
+ // The badge text is selected (highlighted) as the copy indicator.
+ const selection = window.getSelection();
+ expect(selection?.toString()).toBe("c1");
+ // 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(),
},
});
- for (const t of screen.getAllByRole("tab")) {
- expect(t).toHaveClass("w-48");
- }
+ // c1 is queued → a ring (DaisyUI `loading-ring`), labeled "Queued".
+ const queuedRing = screen.getByLabelText("Queued");
+ expect(queuedRing.className).toContain("loading-ring");
+ 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 ring).
+ expect(activeTab.querySelector(".loading-dots")).not.toBeNull();
+ expect(activeTab.querySelector(".loading-ring")).toBeNull();
+
+ // c3 has no status → no spinner at all.
+ expect(idleTab.querySelector(".loading")).toBeNull();
});
});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
deleted file mode 100644
index 211fd5c..0000000
--- a/src/features/tabs/ui/TabBar.svelte
+++ /dev/null
@@ -1,177 +0,0 @@
-<script lang="ts">
- import type { Tab } from "../tabs";
- import { isStuckToEnd, shortHandle } from "../tabs";
-
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
-
- // The new-chat button is `position: sticky; right: 0`. It floats over the tabs
- // only while the strip overflows and isn't scrolled fully right; we square its
- // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) +
- // DOM-measurement at the edge here.
- let scrollEl = $state<HTMLDivElement>();
- let stuck = $state(false);
-
- // Git-style short handle (shortest unique prefix) per open tab — the visible
- // "tab ID". Derived from the set of open conversation ids; pure helper.
- const handles = $derived.by(() => {
- const ids = tabs.map((t) => t.conversationId);
- const map = new Map<string, string>();
- for (const id of ids) map.set(id, shortHandle(id, ids));
- return map;
- });
-
- function recompute(): void {
- const el = scrollEl;
- if (el === undefined) {
- stuck = false;
- return;
- }
- stuck = isStuckToEnd({
- scrollLeft: el.scrollLeft,
- clientWidth: el.clientWidth,
- scrollWidth: el.scrollWidth,
- });
- }
-
- $effect(() => {
- const el = scrollEl;
- if (el === undefined) return;
- // Re-evaluate when the tab set changes (overflow may appear/disappear).
- void tabs;
- recompute();
-
- el.addEventListener("scroll", recompute, { passive: true });
- const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined;
- ro?.observe(el);
-
- return () => {
- el.removeEventListener("scroll", recompute);
- ro?.disconnect();
- };
- });
- // Inline rename: double-click a tab's title to edit, Enter/blur to save.
- let editingId = $state<string | null>(null);
- let editValue = $state("");
- let editEl = $state<HTMLInputElement>();
-
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
-
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
-
- function cancelRename(): void {
- editingId = null;
- }
-</script>
-
-<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto">
- <div class="tabs tabs-lift min-w-max">
- {#each tabs as tab (tab.conversationId)}
- <div
- class="tab flex w-48 shrink-0 items-center gap-1.5"
- class:tab-active={tab.conversationId === activeConversationId}
- role="tab"
- tabindex="0"
- onclick={() => onSelect(tab.conversationId)}
- onkeydown={(e) => {
- if (e.key === "Enter") onSelect(tab.conversationId);
- }}
- >
- <span
- class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
- title="Tab ID"
- >
- {handles.get(tab.conversationId) ?? tab.conversationId}
- </span>
- {#if editingId === tab.conversationId}
- <input
- bind:this={editEl}
- bind:value={editValue}
- class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
- }}
- onblur={commitRename}
- />
- {:else}
- <span
- class="min-w-0 flex-1 cursor-pointer truncate text-left"
- role="button"
- tabindex="-1"
- title={tab.title}
- ondblclick={(e) => {
- e.stopPropagation();
- startRename(tab);
- }}
- >
- {tab.title}
- </span>
- {/if}
- {#if statusFor?.(tab.conversationId) === "active"}
- <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
- {/if}
- <button
- class="btn btn-ghost btn-xs shrink-0"
- aria-label="Close tab"
- onclick={(e) => {
- e.stopPropagation();
- onClose(tab.conversationId);
- }}
- >
- &times;
- </button>
- </div>
- {/each}
- <button
- class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck
- ? '!rounded-se-none !rounded-ee-none'
- : ''}"
- class:tab-active={activeConversationId === null}
- aria-label="New chat"
- onclick={() => onNewDraft()}
- >
- {#if activeConversationId === null}
- <span class="max-w-[120px] truncate">New Chat</span>
- <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span>
- {:else}
- +
- {/if}
- </button>
- </div>
-</div>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
new file mode 100644
index 0000000..08a3274
--- /dev/null
+++ b/src/features/tabs/ui/TabList.svelte
@@ -0,0 +1,184 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+ import { shortHandle } from "../tabs";
+
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ onRename?: (conversationId: string, title: string) => void;
+ } = $props();
+
+ // Git-style short handle (shortest unique prefix) per open tab — the visible
+ // "tab ID". Derived from the set of open conversation ids; pure helper.
+ const handles = $derived.by(() => {
+ const ids = tabs.map((t) => t.conversationId);
+ const map = new Map<string, string>();
+ for (const id of ids) map.set(id, shortHandle(id, ids));
+ return map;
+ });
+
+ // Inline rename: double-click a tab's title to edit, Enter/blur to save.
+ let editingId = $state<string | null>(null);
+ let editValue = $state("");
+ let editEl = $state<HTMLInputElement>();
+
+ function startRename(tab: Tab): void {
+ if (onRename === undefined) return;
+ editingId = tab.conversationId;
+ editValue = tab.title;
+ // Focus the input after it renders.
+ queueMicrotask(() => editEl?.focus());
+ }
+
+ function commitRename(): void {
+ const id = editingId;
+ if (id !== null && onRename !== undefined) {
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0) onRename(id, trimmed);
+ }
+ editingId = null;
+ }
+
+ function cancelRename(): void {
+ editingId = null;
+ }
+
+ // Click-to-copy the agent (conversation) id: clicking the ID badge copies the
+ // FULL conversationId to the clipboard (the stable, useful id — the badge
+ // only shows a short prefix) and highlights the badge text as feedback. The
+ // highlight (text selection) is the indicator — no text is swapped, so the
+ // badge keeps a stable width. If the clipboard write fails, the selection is
+ // already in place so the user can Ctrl+C the text manually.
+ async function copyId(conversationId: string, el: HTMLElement): Promise<void> {
+ selectText(el);
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+ try {
+ await clipboard.writeText(conversationId);
+ } catch {
+ // Selection already lets the user copy manually.
+ }
+ }
+
+ function selectText(el: HTMLElement): void {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <!-- Single-column vertical tab list. Capped at 80% of the viewport height
+ so a long tab set scrolls inside this region instead of growing the
+ whole sidebar. -->
+ <div class="flex max-h-[80vh] flex-col gap-1 overflow-y-auto pr-1">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-200"
+ class:bg-base-300={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ aria-selected={tab.conversationId === activeConversationId}
+ title={tab.title}
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <button
+ type="button"
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content"
+ data-copy-id={tab.conversationId}
+ title="Click to copy conversation id"
+ aria-label={`Copy conversation id ${tab.conversationId}`}
+ onclick={(e) => {
+ e.stopPropagation();
+ void copyId(tab.conversationId, e.currentTarget);
+ }}
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </button>
+ {#if editingId === tab.conversationId}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="min-w-0 flex-1 cursor-pointer truncate text-left"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename(tab);
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+ {#if statusFor?.(tab.conversationId) === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). -->
+ <span
+ class="loading loading-ring 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
+ class="btn btn-ghost btn-xs shrink-0"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {/each}
+ </div>
+
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm w-full border border-base-300"
+ class:btn-primary={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ New Chat
+ {:else}
+ + New chat
+ {/if}
+ </button>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 81b89e9..0ffc975 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -188,7 +188,16 @@
</div>
<div class="flex justify-start">
- <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a>
+ <a
+ class="btn"
+ href={workspacePath(ws.id)}
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate(workspacePath(ws.id));
+ }}
+ >
+ Open
+ </a>
</div>
{#if cwdError}
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 0736efb..0d03b8e 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -119,16 +119,20 @@ describe("WorkspaceCard", () => {
expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
});
- it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
+ it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => {
+ const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
const onNavigate = vi.fn();
render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
const open = screen.getByRole("link", { name: "Open" });
- // Native new-tab link: href points at the workspace, and target="_blank"
- // opens it in a new browser tab rather than client-side navigating.
+ // Still a real link (progressive enhancement): href points at the workspace.
expect(open).toHaveAttribute("href", "/my-ws");
- expect(open).toHaveAttribute("target", "_blank");
- expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
+ // But it no longer opens a new browser tab.
+ expect(open).not.toHaveAttribute("target", "_blank");
+ // Clicking navigates in-place via the SPA callback.
+ await user.click(open);
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/my-ws");
});
});