summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md53
-rw-r--r--backend-handoff.md130
-rw-r--r--src/App.svelte7
-rw-r--r--src/app/App.svelte6
-rw-r--r--src/app/store.svelte.ts111
-rw-r--r--src/app/store.test.ts244
-rw-r--r--src/core/metrics/format.test.ts8
-rw-r--r--src/core/metrics/format.ts20
-rw-r--r--src/core/metrics/place.test.ts97
-rw-r--r--src/core/metrics/place.ts75
-rw-r--r--src/core/metrics/reducer.test.ts247
-rw-r--r--src/core/metrics/reducer.ts92
-rw-r--r--src/features/chat/store.svelte.ts9
-rw-r--r--src/features/chat/ui/Composer.svelte9
-rw-r--r--src/features/concurrency/index.ts23
-rw-r--r--src/features/concurrency/logic/types.ts37
-rw-r--r--src/features/concurrency/logic/view-model.test.ts232
-rw-r--r--src/features/concurrency/logic/view-model.ts211
-rw-r--r--src/features/concurrency/ui/AutoReduceBanner.svelte81
-rw-r--r--src/features/concurrency/ui/ConcurrencyCooldownRow.svelte93
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte107
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts339
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte20
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts57
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte15
25 files changed, 2168 insertions, 155 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 6a5a6b3..0582d94 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -8,6 +8,21 @@
> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever
> it changes.
>
+> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains
+> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry`
+> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is
+> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced
+> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when
+> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` —
+> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user
+> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown
+> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when
+> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded;
+> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer,
+> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded.
+> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream
+> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3.
+>
> **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`),
@@ -1062,6 +1077,17 @@ export interface ConcurrencyLimitResponse {
* - `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.
+ * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held
+ * this long before the next waiter is admitted — covers the upstream
+ * provider's accounting lag. Configurable + persisted per provider.
+ * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429
+ * (adaptive headroom, one-way, persisted). The user restores the limit
+ * manually via `PUT /concurrency/limits/:providerId`, which clears the flag.
+ * When `true`, the frontend renders a visible notice/banner.
+ * - `autoReducedFrom`: the original limit before auto-reduction (present only
+ * when `autoReduced` is true).
+ * - `notice`: a human-readable notice string for the frontend to render as a
+ * banner when the limit was auto-reduced (present only when `autoReduced`).
*/
export interface ConcurrencyStatusEntry {
readonly providerId: string;
@@ -1070,6 +1096,10 @@ export interface ConcurrencyStatusEntry {
readonly queued: number;
readonly paused: boolean;
readonly pausedUntil?: number;
+ readonly cooldownMs: number;
+ readonly autoReduced: boolean;
+ readonly autoReducedFrom?: number;
+ readonly notice?: string;
}
/**
* Response of `GET /concurrency/status` — live status for every provider with a
@@ -1078,5 +1108,28 @@ export interface ConcurrencyStatusEntry {
export interface ConcurrencyStatusResponse {
readonly providers: readonly ConcurrencyStatusEntry[];
}
+
+// ─── Provider concurrency cooldown ────────────────────────────────────────────
+
+/**
+ * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release
+ * cooldown (ms) for a provider. A recycled slot is held this long before the
+ * next waiter is admitted, covering the upstream provider's accounting lag.
+ * When no cooldown was explicitly set, the server default (350ms) is returned.
+ */
+export interface ConcurrencyCooldownResponse {
+ readonly providerId: string;
+ readonly cooldownMs: number;
+}
+
+/**
+ * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown
+ * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no
+ * cooldown, instant re-admission). The value is persisted and applied to
+ * subsequently recycled slots.
+ */
+export interface SetConcurrencyCooldownRequest {
+ readonly cooldownMs: number;
+}
```
diff --git a/backend-handoff.md b/backend-handoff.md
index 3f4642b..4e3c6df 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,6 +5,22 @@
> **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-27 (FE-only slice: **workspace-active indicator** — loading-dots on
+workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued
+conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a
+`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at
+`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend /
+contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_
+_Last updated: 2026-06-27 (§2j-update-3 — concurrency-fixes: configurable + persisted per-provider release
+cooldown, adaptive headroom auto-reduce banner, usage gate. ADDITIVE to `[email protected]`, NO version
+bump — `ConcurrencyStatusEntry` gained `cooldownMs`/`autoReduced`/`autoReducedFrom?`/`notice?`; NEW
+`ConcurrencyCooldownResponse`/`SetConcurrencyCooldownRequest` + `GET`/`PUT /concurrency/cooldown/:providerId`.
+FE re-synced the `file:` dep + re-mirrored `.dispatch/transport-contract.reference.md`; built the cooldown
+view-model + inline-edit row, a dismissible auto-reduce banner with "Restore to N", store `getConcurrencyCooldown`/
+`setConcurrencyCooldown`, + 49 new tests. typecheck 0/0, 1050 tests green (run TWICE), biome clean, build OK.
+Worktree env note: an untracked `dispatch-backend → backend` symlink was created in the worktree parent so the
+canonical `file:../dispatch-backend/...` paths resolve — NOT committed.) Prior: §2j (vision consult_vision + image
+storage)._
_Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-star` shipped `Workspace.starred: boolean`
(additive to `[email protected]`, NO version bump) + `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss;
return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`,
@@ -58,6 +74,8 @@ WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`
`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`).
+`GET`/`PUT /concurrency/cooldown/:providerId` (per-slot release cooldown — configurable + persisted; 0 = no cooldown;
+`GET` 404 when the provider has no concurrency config at all, `PUT` 400 on a non-negative-int body — see §2j-update-3).
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]` /
@@ -1064,6 +1082,118 @@ conversation → confirm the persisted image renders from the `/images/…` endp
---
+## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change**
+
+The context-window usage indicator at the bottom of the screen (Composer status
+bar) now updates **after each step** during a multi-step turn, instead of only
+when the turn seals. Pure FE change — consumes wire events the backend ALREADY
+sends (`usage` per step + `step-complete` + `done.contextSize`); no contract
+change, re-pin, or re-mirror needed.
+
+- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT
+ (not-done) turn, returns the most recent step WITH USAGE's
+ `inputTokens + outputTokens` as the live context occupancy. Per the wire
+ contract each step's input already includes all prior context (the prompt is
+ re-prefilled every step), so the last step's input+output is the true occupancy
+ — the same definition `TurnDoneEvent.contextSize` stamps at turn end.
+- A finalized turn (`done` / durable) still wins with its authoritative
+ `contextSize`; durable still wins over live for a shared `turnId`. An in-flight
+ turn with no step usage yet falls back to the next older finalized turn (never
+ `0`).
+- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize`
+ + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green.
+
+### FE summary (this slice)
+No backend ask. The backend already emits per-step `usage` (token counts, may
+arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus
+`done.contextSize` (final step's input+output) — the FE just wasn't reading the
+per-step usage for the live indicator. Now it does.
+## 2j-update-3. Concurrency-fixes (cooldown + adaptive headroom + usage gate) → **CONSUMED ✅ (backend shipped; FE built + verified)**
+
+A follow-up to §2j. The backend's per-provider concurrency surface gained (a) a configurable + persisted
+per-provider release **cooldown**, (b) **adaptive headroom** — a 429 auto-reduces the provider's limit by 1
+(one-way, persisted) and the FE renders a visible banner, and (c) a **usage gate** (backend polls upstream
+`concurrent_sessions` before admitting a queued agent — no FE surface). The signal rides on
+`GET /concurrency/status` (no new WS push). **Additive to `[email protected]`, NO version bump**
+(the FE's `file:` dep picks the new types up via re-sync, no re-pin needed). Backend commit
+`feature/concurrency-fixes` `2d27666` ("usage-gate + adaptive headroom + configurable cooldown").
+
+**Contract changes (re-mirrored in `.dispatch/transport-contract.reference.md`):**
+- `ConcurrencyStatusEntry` gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in
+ ms, default 350; a recycled slot is held this long before the next waiter is admitted, covering the upstream
+ provider's accounting lag — configurable + persisted), `autoReduced: boolean` (REQUIRED — true when the limit
+ was auto-reduced by 1 after a 429, one-way + persisted; the FE renders a banner), `autoReducedFrom?: number`
+ (present only when `autoReduced===true` — the original limit before reduction), and `notice?: string`
+ (present only when `autoReduced===true` — a human-readable banner message).
+- NEW cooldown types: `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) +
+ `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — non-negative integer, 0 = no cooldown / instant re-admission).
+- NEW endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (404 when the provider
+ has no concurrency config at all — no limit AND no cooldown; 503 when the extension isn't loaded);
+ `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` → `ConcurrencyCooldownResponse`
+ (400 on an invalid body; 503 when not loaded). Persists + applies immediately to subsequently recycled slots.
+- A manual `PUT /concurrency/limits/:providerId` CLEARS `autoReduced` server-side (the restore path).
+
+**FE (DONE + verified):**
+- **Pure core (`logic/view-model.ts`):** `DEFAULT_COOLDOWN_MS = 350`; `parseCooldownInput` (non-negative integer —
+ unlike the limit, **0 is valid**); `normalizeCooldown` (defensive default 350 on garbage);
+ `cooldownLabel` ("350ms" / "1.2s" / "0ms (off)"); `viewConcurrencyStatus` extended to carry `cooldownMs` +
+ `cooldownLabel` + `autoReduced` + `autoReducedFrom` (auto-reduce → `warning` badge but NOT `busy` — a reduced
+ limit still admits agents); `viewAutoReduce`/`autoReduceNotices` (banner view — prefers the backend `notice`
+ verbatim, synthesizes a fallback when absent, `fromLimit` = `autoReducedFrom` for "Restore to N");
+ `summarizeStatus` gained an "N auto-reduced" fragment; `normalizeConcurrencyStatus` coerces the new fields
+ (builds the readonly entry immutably — `autoReducedFrom`/`notice` only when `autoReduced===true`, dropped when
+ false even if present in the JSON); `normalizeConcurrencyCooldown` (network-seam coercion).
+- **Types (`logic/types.ts`):** re-exports the 2 new contract types + `ConcurrencyCooldownResult` +
+ `GetConcurrencyCooldown`/`SaveConcurrencyCooldown` ports.
+- **UI:** new `ui/ConcurrencyCooldownRow.svelte` (per-provider inline-edit cooldown input + Save → PUT, seeded via
+ the ChatLimitField pattern so a status-poll refresh re-syncs without clobbering an in-flight edit; "Saved."
+ confirmation); new `ui/AutoReduceBanner.svelte` (the dismissible banner — backend `notice` + "Was N, now M." +
+ "Restore to N" PUT button + ✕ dismiss). `ConcurrencyView.svelte`: cooldown label per status card +
+ `ConcurrencyCooldownRow`; an auto-reduce banner section at the top of the panel. The banner is DISMISSIBLE +
+ persists while `autoReduced===true`: a dismissed provider stays hidden while still auto-reduced, and is
+ un-dismissed the moment a poll shows it restored (a `$effect` reconciles the dismissed set against the live
+ auto-reduced providers). "Restore to N" PUTs the limit back to `autoReducedFrom` via `saveLimit` → the next
+ status poll shows `autoReduced===false` → the banner drops automatically.
+- **Store (`store.svelte.ts`):** `getConcurrencyCooldown` (`GET .../cooldown/:id`) + `setConcurrencyCooldown`
+ (`PUT .../cooldown/:id` ← `{ cooldownMs }`) — both surface 400/404/503 as `ok:false` with the backend's `error`
+ string + normalize the body at the seam. Interface declarations added. (Mirrors the §2j "FE implements all API
+ client functions but the UI uses a subset" note: the UI seeds cooldown from the live status `cooldownMs`, so
+ `getConcurrencyCooldown` is an API-client function for completeness/future use — `saveCooldown` is the wired one.)
+- **Wired in `App.svelte`:** `saveConcurrencyCooldown` adapter → `ConcurrencyView`'s `saveCooldown` prop.
+- **Tests:** +47 (view-model: `parseCooldownInput`/`cooldownLabel`/`normalizeConcurrencyCooldown`/`viewAutoReduce`/
+ `autoReduceNotices`/`summarizeStatus` auto-reduced/`normalizeConcurrencyStatus` new-field coercion; component:
+ cooldown label render, cooldown PUT flow, negative-input rejection, auto-reduce banner render, restore clears
+ banner, dismiss-while-auto-reduced; store: `getCooldown` load/404, `setCooldown` PUT echo + 400).
+
+**Verification:** `svelte-check` 0/0; vitest **1050/1050** (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 one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute
+selectors, unrelated). **Live probe NOT run** (the backend is 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,
+confirm the cooldown label + edit field per provider; set a cooldown → Save → confirm it persists on reload;
+trigger a 429 on a limited provider → confirm the auto-reduce banner appears (with `notice` + "Was N, now M.") →
+click "Restore to N" → confirm the banner drops on the next poll.
+
+**Post-review fixes (folded into the same commit):** a Kimi review flagged a MEDIUM bug + 2 LOW issues, all fixed:
+- **MEDIUM — restore failure gave no inline feedback:** `restoreLimit` now returns a `RestoreOutcome`
+ (`{ ok: true } | { ok: false; error }`) and `AutoReduceBanner.handleRestore` shows the error INLINE next to the
+ restore button (cleared on retry) instead of silently re-enabling the button. New `RestoreOutcome` type in
+ `logic/types.ts` + exported. 2 new tests (inline error on failure; error clears on a retry that succeeds).
+- **LOW — a11y:** the "Restore to N" text now stays visible while loading (spinner prepended, not replacing the
+ text), so the button keeps its accessible name during the PUT (a spinner-only button loses its name for SR users).
+- **LOW — dismissed-banner persistence:** confirmed INTENTIONAL (not a bug). The dismissed set is component-local
+ (resets on remount): `autoReduced` is a REAL persisted degraded state, so re-showing the banner on a fresh mount
+ (sidebar view switch / reload) reminds the user; persisting dismissal in localStorage would risk HIDING an ongoing
+ degradation (a footgun), and AGENTS.md forbids module-global ambient state. Documented in a code comment.
+
+**Worktree environment note (same as §2d/§2j):** this worktree lays the repos out as
+`…/worktrees/concurrency-fixes/{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 the additive `[email protected]` types.
+
+---
+
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
diff --git a/src/App.svelte b/src/App.svelte
index 713c844..27c9271 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -68,7 +68,12 @@
</script>
{#if route.kind === "home"}
- <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
+ <WorkspacesHome
+ store={workspaceStore}
+ onNavigate={navigate}
+ computers={store.computers}
+ hasActive={(id) => store.workspaceHasActiveConversations(id)}
+ />
{:else}
<App {store} onNavigate={navigate} />
{/if}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 59f949c..b2cfd2d 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -76,6 +76,7 @@
type DeleteConcurrencyLimit,
type LoadConcurrencyLimits,
type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
type SaveConcurrencyLimit,
} from "../features/concurrency";
import type { ChatStore } from "../features/chat";
@@ -491,6 +492,8 @@
const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
store.deleteConcurrencyLimit(providerId);
const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
+ const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
+ store.setConcurrencyCooldown(providerId, cooldownMs);
</script>
<main class="relative flex h-screen overflow-hidden">
@@ -501,7 +504,7 @@
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">
+ <div class="flex items-center justify-between gap-2 px-2 py-2">
<span
class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
data-testid="top-bar-title"
@@ -841,6 +844,7 @@
saveLimit={saveConcurrencyLimit}
deleteLimit={deleteConcurrencyLimit}
loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
/>
{/if}
{/snippet}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index ead27a6..22b0a25 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -55,12 +55,14 @@ import {
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
import type {
+ ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
ConcurrencyLimitResult,
ConcurrencyLimitsResult,
ConcurrencyStatusResult,
} from "../features/concurrency";
import {
+ normalizeConcurrencyCooldown,
normalizeConcurrencyLimit,
normalizeConcurrencyLimits,
normalizeConcurrencyStatus,
@@ -351,6 +353,16 @@ export interface AppStore {
*/
conversationStatus(conversationId: string): ConversationStatus | undefined;
/**
+ * Whether at least one conversation in the given workspace is currently
+ * active or queued (generating / waiting for a concurrency slot) — drives
+ * the loading-dots indicator on workspace cards. Backed by a once-derived
+ * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
+ * so this is an O(1) lookup, not a per-card scan of the full tab list.
+ * Reactive: the set is a `$derived`, so a Svelte template expression calling
+ * this re-runs when the tab set or status map changes.
+ */
+ workspaceHasActiveConversations(workspaceId: string): boolean;
+ /**
* Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
* localStorage and propagates to every live chat store (trim if lower,
* deferred via the unload gate while a reader is scrolled up; no-op if
@@ -442,11 +454,29 @@ export interface AppStore {
/**
* 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
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
+ * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
+ * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
* an empty list when the extension isn't loaded (`{ providers: [] }`).
*/
concurrencyStatus(): Promise<ConcurrencyStatusResult>;
/**
+ * Fetch the per-slot release cooldown (ms) for one provider
+ * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
+ * all) and `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
+ /**
+ * Set the per-slot release cooldown (ms) for one provider
+ * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
+ * must be a non-negative integer (0 = no cooldown / instant re-admission); an
+ * invalid body is `400`. Persists + applies to subsequently recycled slots.
+ */
+ setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult>;
+ /**
* 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).
@@ -966,6 +996,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// fetched on connect). Keyed by conversationId.
let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+ // The set of workspaces with ≥1 active/queued conversation, derived ONCE
+ // (not recomputed per card). Every active/queued conversation has an open
+ // tab stamped with its workspace, so the tabs are the conversation→workspace
+ // map; cross-reference with the lifecycle statuses. `$derived` recomputes
+ // lazily when the tab set or status map changes, so each
+ // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
+ // of the full tab list per card.
+ const activeWorkspaces = $derived.by(() => {
+ const out = new Set<string>();
+ for (const tab of tabsStore.tabs) {
+ const status = conversationStatuses.get(tab.conversationId);
+ if (status === "active" || status === "queued") out.add(tab.workspaceId);
+ }
+ return out;
+ });
+
/**
* Fetch `GET /conversations?status=active,idle` on connect to restore the
* tab bar across devices. Merges: opens tabs for conversations not already
@@ -1255,6 +1301,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
conversationStatus(conversationId: string): ConversationStatus | undefined {
return conversationStatuses.get(conversationId);
},
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ // O(1) lookup into the once-derived `activeWorkspaces` set; false when the
+ // workspace has no active/queued conversation (or none at all).
+ return activeWorkspaces.has(workspaceId);
+ },
get currentConversationId(): string {
return workspaceConversationId();
},
@@ -1958,6 +2009,64 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ cooldownMs }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency cooldown 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 2cf473c..a945ea7 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -553,13 +553,17 @@ describe("createAppStore", () => {
event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
});
- await new Promise((r) => setTimeout(r, 50));
-
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
-
- await new Promise((r) => setTimeout(r, 50));
+ // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync
+ // → cache.commit → applyHistory). Poll for the side-effect rather than
+ // guessing a fixed delay — under suite load a fixed `setTimeout` raced the
+ // fetch chain and flaked here.
+ await vi.waitFor(() => {
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+ });
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ });
store.dispose();
});
@@ -1458,6 +1462,20 @@ describe("createAppStore", () => {
if (url.includes("/concurrency/limits/") && method === "DELETE") {
return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 });
}
+ if (url.includes("/concurrency/cooldown/") && method === "GET") {
+ return new Response(JSON.stringify({ providerId: "umans", cooldownMs: 350 }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/cooldown/") + "/concurrency/cooldown/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, cooldownMs: body.cooldownMs ?? 0 }), {
+ status: 200,
+ });
+ }
return base(input, init);
};
}
@@ -1641,11 +1659,15 @@ describe("createAppStore", () => {
inFlight: 2,
queued: 1,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
providerId: "openai-compat",
paused: true,
pausedUntil: 1_719_408_000_000,
+ cooldownMs: 350,
+ autoReduced: false,
});
store.dispose();
});
@@ -1663,6 +1685,100 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("getConcurrencyCooldown loads + coerces the cooldown", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.cooldownMs).toBe(350);
+ store.dispose();
+ });
+
+ it("getConcurrencyCooldown surfaces a 404 (no concurrency config) as ok:false", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/cooldown/")) {
+ return new Response(
+ JSON.stringify({ error: "No concurrency configuration for this provider" }),
+ {
+ status: 404,
+ },
+ );
+ }
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("ghost");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency configuration");
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown PUTs { cooldownMs } + returns the echoed value", 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";
+ calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : null });
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(
+ JSON.stringify({ providerId: "umans", cooldownMs: body.cooldownMs }),
+ { status: 200 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", 500);
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.cooldownMs).toBe(500);
+ const cooldownCall = calls.find(
+ (c) => c.url.includes("/concurrency/cooldown/") && c.method === "PUT",
+ );
+ expect(cooldownCall?.url).toContain("/concurrency/cooldown/umans");
+ expect(cooldownCall?.body).toEqual({ cooldownMs: 500 });
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown surfaces a 400 (invalid body) as ok:false", 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/cooldown/") && init?.method === "PUT") {
+ return new Response(
+ JSON.stringify({ error: "Body must be { cooldownMs: <non-negative integer> }" }),
+ { status: 400 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", -1);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("non-negative integer");
+ 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", () => {
@@ -1730,6 +1846,122 @@ describe("createAppStore", () => {
expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true);
store.dispose();
});
+
+ // ── workspaceHasActiveConversations (workspace-card active indicator) ─────
+
+ it("workspaceHasActiveConversations is false when no conversation is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ // The conversation is freshly created — the backend hasn't reported it as
+ // active yet, so the workspace has no active conversation.
+ expect(store.conversationStatus(convId)).toBeUndefined();
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation in the workspace is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation is queued (waiting for a slot)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "queued",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations goes back to false when the conversation goes idle", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "idle",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations scopes to the given workspace (ignores other workspaces)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A cross-device active conversation in workspace "proj-a".
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "proj-a-conv",
+ status: "active",
+ workspaceId: "proj-a",
+ });
+
+ // proj-a is active; proj-b is not (no active conversation there).
+ expect(store.workspaceHasActiveConversations("proj-a")).toBe(true);
+ expect(store.workspaceHasActiveConversations("proj-b")).toBe(false);
+ store.dispose();
+ });
});
describe("createAppStore — vision settings (global)", () => {
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts
index c7c4fbb..97170d0 100644
--- a/src/core/metrics/format.test.ts
+++ b/src/core/metrics/format.test.ts
@@ -351,8 +351,14 @@ describe("computeContextUsage", () => {
expect(u.percent).toBeCloseTo(3.4102, 4);
});
- it("treats unknown contextSize as current 0", () => {
+ it("treats unknown contextSize as current null (never 0)", () => {
const u = computeContextUsage(undefined, 1_000_000);
+ expect(u.current).toBeNull();
+ expect(u.percent).toBeNull();
+ });
+
+ it("an explicit 0 context size is a real reported value (current 0)", () => {
+ const u = computeContextUsage(0, 1_000_000);
expect(u.current).toBe(0);
expect(u.percent).toBe(0);
});
diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts
index 56e74e4..894bd54 100644
--- a/src/core/metrics/format.ts
+++ b/src/core/metrics/format.ts
@@ -45,14 +45,17 @@ export function formatCompactTokens(n: number): string {
/**
* Context-window occupancy: the current size against a max window limit.
*
- * `current` is the latest turn's context size (0 when unknown); `max` is the
- * model's window limit (or `null` when unknown). `percent` is
- * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the
- * precision) — so a few-thousand-token context against a 1,000,000 window still
- * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator).
+ * `current` is the latest turn's context size, or `null` when unknown (no
+ * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot
+ * silently render "0 tokens / 1M"; it must branch on `current === null` and show
+ * a placeholder instead. `max` is the model's window limit (or `null` when
+ * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED
+ * (the UI picks the precision) — so a few-thousand-token context against a
+ * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR
+ * `max` is unknown (no bar/denominator).
*/
export interface ContextUsage {
- readonly current: number;
+ readonly current: number | null;
readonly max: number | null;
readonly percent: number | null;
}
@@ -61,9 +64,10 @@ export function computeContextUsage(
contextSize: number | undefined,
contextLimit: number | null | undefined,
): ContextUsage {
- const current = contextSize ?? 0;
+ const current = contextSize ?? null;
const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
- const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100));
+ const percent =
+ current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100));
return { current, max, percent };
}
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index c05ba3b..9c925a3 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -354,6 +354,81 @@ describe("interleaveTurnMetrics", () => {
expectGroupAt(rows, 7, g6);
});
+ it("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => {
+ // A long conversation where the chat limit unloaded the oldest turn (t1).
+ // Metrics still hold all three turns; the loaded transcript is turns 2-3.
+ // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no
+ // stepId groups) — the failure case. The text-only turn MUST get its OWN
+ // entry (t3), NOT the trimmed t1's stale metrics.
+ const g3 = userGroup(3, "q2");
+ const g4 = toolBatchGroup("s2", ["c2"]);
+ const g5 = assistantGroup(4, "tool-reply");
+ const g6 = userGroup(5, "q3");
+ const g7 = assistantGroup(6, "text-reply");
+ const step1 = makeStep("s1", 11, 1); // t1 (trimmed)
+ const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool)
+ const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored)
+ const entries = [
+ makeEntry("t1", 11, 1, [step1]),
+ makeEntry("t2", 22, 2, [step2]),
+ makeEntry("t3", 33, 3, [step3]),
+ ];
+ const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries);
+
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render.
+ expect(tmRows).toHaveLength(2);
+ // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry),
+ // not t1 (the stale trimmed one). A misaligned head-align would show t1.
+ expect(tmRows[1]?.turn.turnId).toBe("t3");
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // And t1 never appears as a rendered row.
+ expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false);
+ });
+
+ it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => {
+ // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn.
+ // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat),
+ // even though t1 renders no row of its own.
+ const g1 = userGroup(2, "q2");
+ const g2 = assistantGroup(3, "a2");
+ const entries = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 },
+ steps: [],
+ },
+ },
+ {
+ turnId: "t2",
+ steps: [],
+ total: {
+ turnId: "t2",
+ usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 },
+ steps: [],
+ },
+ },
+ ];
+ const rows = interleaveTurnMetrics([g1, g2], entries);
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Only the loaded turn renders a row; the trimmed t1 does not.
+ expect(tmRows).toHaveLength(1);
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100.
+ expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000);
+ expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100);
+ // Retention baseline is the prior finalized turn (t1, even though trimmed).
+ expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000);
+ expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500);
+ });
+
it("in-flight turn (no durationMs) still produces turn row", () => {
const g1 = userGroup(1, "q1");
const g2 = toolCallGroup(2, "s1", "c1");
@@ -391,7 +466,7 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 4, "t1");
});
- it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => {
+ it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => {
const g1 = userGroup(1, "q1");
const g2 = toolCallGroup(2, "s1", "c1");
const step1 = makeStep("s1", 100, 50);
@@ -401,13 +476,19 @@ describe("interleaveTurnMetrics", () => {
[makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])],
);
- // Unmatched entry (t2) emits a standalone turn-metrics row at the top.
- expect(rows).toHaveLength(5);
- expectTurnMetricsAt(rows, 0, "t2");
- expectGroupAt(rows, 1, g1);
- expectGroupAt(rows, 2, g2);
- expectStepMetricsAt(rows, 3, "s1", 0);
- expectTurnMetricsAt(rows, 4, "t1");
+ // t2's content was unloaded by the chat limit (no segment for it); its
+ // metrics must NOT render a standalone row piled at the top. Only the
+ // loaded turn's content + its matched metrics appear. (t2 still counts
+ // toward the cumulative "chat total" — see the cache-total tests.)
+ expect(rows).toHaveLength(4);
+ expectGroupAt(rows, 0, g1);
+ expectGroupAt(rows, 1, g2);
+ expectStepMetricsAt(rows, 2, "s1", 0);
+ expectTurnMetricsAt(rows, 3, "t1");
+ // No standalone turn-metrics row for t2 anywhere.
+ const tmRows = rows.filter((r) => r.kind === "turn-metrics");
+ expect(tmRows).toHaveLength(1);
+ expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("t1");
});
it("turn with no steps emits only turn-metrics (no step-metrics)", () => {
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index b165fd0..7122b09 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -27,10 +27,11 @@ function addUsage(a: Usage, b: Usage): Usage {
* Splits groups into per-turn segments: a new segment begins at each `single`
* group with `group.chunk.role === "user"`. Segments are matched to entries
* by `stepId` presence when possible (robust against chat-limit trimming: when
- * a turn's user message is trimmed, head-alignment would be off by one, but
+ * a turn's user message is trimmed, positional alignment would be off, but
* stepId matching still finds the right entry). Segments with no stepId-bearing
- * groups (text-only turns) fall back to sequential matching against unused
- * entries.
+ * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the
+ * loaded transcript is always a SUFFIX of the full turn history (the chat limit
+ * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`.
*
* Within a segment that has a matched entry, each completed step's metrics
* are placed INLINE right after the last group bearing that step's `stepId`.
@@ -44,9 +45,13 @@ function addUsage(a: Usage, b: Usage): Usage {
* is finalized via `done` or durable data). A still-generating turn emits no
* turn-total row.
*
- * Cumulative usage is computed across finalized turns in entry-array order
- * (turn order), so the per-turn "chat total" cache rate is correct regardless
- * of which turns were trimmed.
+ * Fully trimmed turns (entries whose content was unloaded by the chat limit and
+ * which match no segment) are NOT rendered as standalone rows — that previously
+ * piled a wall of stale cache badges at the top of a long, trimmed transcript.
+ * Their usage still counts toward the per-turn "chat total" cumulative (computed
+ * across ALL finalized turns in entry-array order), so the running cache rate
+ * stays correct regardless of which turns were trimmed; paging earlier history
+ * back in ("Show earlier messages") re-matches them and re-renders their rows.
*/
export function interleaveTurnMetrics(
groups: readonly RenderGroup[],
@@ -84,8 +89,9 @@ export function interleaveTurnMetrics(
const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId)));
// Match segments to entries. Pass 1: match by stepId overlap (handles
- // trimming where head-alignment would be wrong). Pass 2: sequential fallback
- // for unmatched segments (text-only turns with no stepId-bearing groups).
+ // trimming where positional alignment alone could be ambiguous). Pass 2:
+ // positional tail-alignment fallback for unmatched segments (text-only turns
+ // with no stepId-bearing groups).
const usedEntries = new Set<number>();
const segmentEntry = new Map<number, TurnMetricsEntry>();
const segmentEntryIndex = new Map<number, number>();
@@ -127,19 +133,36 @@ export function interleaveTurnMetrics(
}
}
- // Pass 2: sequential fallback for unmatched segments.
- // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT:
- // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the
- // newest and trims the oldest), so match the LAST T entries to the T
- // segments. This prevents misaligning oldest (trimmed) entries to newest
- // segments — which would show "turn 1" on turn 20's content.
- const pass1Matches = segmentEntry.size;
- if (pass1Matches === 0 && K >= T) {
+ // Pass 2: positional fallback for segments pass 1 left unmatched
+ // (text-only turns with no stepId-bearing groups to anchor on).
+ //
+ // The loaded transcript is always a SUFFIX of the full turn history —
+ // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so
+ // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT
+ // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the
+ // metrics hold at least as many turns as there are loaded segments
+ // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content
+ // was unloaded) and must be skipped, never matched to a newer segment.
+ //
+ // This MUST run even when pass 1 matched SOME segments (tool turns). The
+ // earlier code only tail-aligned when pass 1 matched NONE, falling back to
+ // HEAD-alignment otherwise — which, with leading trimmed entries, matched a
+ // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the
+ // "new steps show no / wrong cache" failure). Tail-aligning by position is
+ // safe alongside pass 1: stepIds are unique per turn, so pass 1 already
+ // grabbed each tool turn's positionally-correct entry, leaving the right
+ // entry free for each text-only turn.
+ //
+ // Only when `K < T` (fewer entries than segments — some loaded turns have no
+ // metrics yet, e.g. a metrics sync still pending or a freshly loaded
+ // transcript) do we head-align, assigning the first K entries to the first K
+ // unmatched segments (the turns that DO have metrics sit at the front).
+ if (K >= T) {
// Tail-align: skip the first K-T entries (trimmed turns).
for (let seg = 0; seg < T; seg++) {
if (segmentEntry.has(seg)) continue;
const entryIdx = K - T + seg;
- if (entryIdx < K && !usedEntries.has(entryIdx)) {
+ if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) {
usedEntries.add(entryIdx);
const e = entries[entryIdx];
if (e !== undefined) {
@@ -149,7 +172,7 @@ export function interleaveTurnMetrics(
}
}
} else {
- // Head-align fallback for remaining unmatched segments.
+ // Head-align fallback (K < T): first K entries to first K unmatched segments.
let nextUnused = 0;
for (let seg = 0; seg < T; seg++) {
if (segmentEntry.has(seg)) continue;
@@ -186,22 +209,6 @@ export function interleaveTurnMetrics(
const firstUserIdx = segmentStarts[0] ?? 0;
- // Emit turn-metrics rows for entries that weren't matched to any segment
- // (fully trimmed turns — their content was unloaded by the chat limit, but
- // their aggregate metrics still show so the user knows what was trimmed).
- for (let i = 0; i < entries.length; i++) {
- if (usedEntries.has(i)) continue;
- const e = entries[i];
- if (e === undefined || e.total === null) continue;
- rows.push({
- kind: "turn-metrics",
- turn: e.total,
- turnNumber: i + 1,
- cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage,
- prevTurnUsage: prevUsageByEntry[i] ?? null,
- });
- }
-
for (let i = 0; i < firstUserIdx; i++) {
const g = groups[i];
if (g !== undefined) {
diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts
index 7d0a270..581a8b7 100644
--- a/src/core/metrics/reducer.test.ts
+++ b/src/core/metrics/reducer.test.ts
@@ -439,4 +439,251 @@ describe("contextSize / selectCurrentContextSize", () => {
]);
expect(selectCurrentContextSize(s)).toBe(222);
});
+
+ it("in-flight turn updates context size after the first step completes", () => {
+ // Before the requirement: an in-flight turn had total=null so its step usage
+ // was ignored until `done`. Now the latest step's input+output is used.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+
+ // Still generating (no done) — context = step 1 input+output = 5200.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("in-flight turn updates progressively as each step reports usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Step 2 reports usage mid-stream (before its step-complete): each step's
+ // input already includes all prior context, so the last step's input+output
+ // is the current occupancy.
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => {
+ // Mirrors the finalized-turn test: contextSize is the FINAL step's
+ // input+output, not the sum across steps (which would overcount a
+ // multi-step turn because every step re-prefills the growing prompt).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // Aggregate would be 300+130=430; the latest step is 200+80=280.
+ expect(selectCurrentContextSize(s)).toBe(280);
+ });
+
+ it("in-flight turn with a step-complete but no usage falls back to older turn", () => {
+ // step-complete before usage → the step has no usage yet, so the in-flight
+ // turn exposes no context size and the display falls back to the prior
+ // finalized turn's value (never 0).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 }));
+
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight turn with no steps/usage returns undefined (falls back)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 just started — no usage, no complete step — omitted entirely.
+ s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ expect(selectCurrentContextSize(s)).toBe(700);
+
+ // t2's first step reports usage → the display jumps to t2's live value.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("done finalizes the in-flight progressive value with the authoritative contextSize", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // done stamps the authoritative contextSize (the final step's input+output).
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 }));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => {
+ // cacheReadTokens / cacheWriteTokens are portions of inputTokens already
+ // counted — adding them would double-count. Only input+output is occupancy.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s1" as StepId,
+ usage: {
+ inputTokens: 5000,
+ outputTokens: 200,
+ cacheReadTokens: 4000,
+ cacheWriteTokens: 1000,
+ },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both).
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("multiple in-flight turns: the newest turn's live value wins", () => {
+ let s = initialMetricsState();
+ // t1 (older) in-flight with one completed step → 5200.
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // t2 (newer, seen later → last in liveOrder) in-flight → 8000.
+ s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(8000);
+ });
+
+ it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => {
+ // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is
+ // the newest step regardless of when each step's step-complete arrives.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ // Neither step complete yet → the turn is omitted (no complete step), so the
+ // display can't update until the first step completes.
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+
+ // s1 completes AFTER s2's usage was reported. The turn is now visible; the
+ // newest-first scan picks s2 (the later step), not s1 (the just-completed one).
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // s2 completes — still s2, unchanged.
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("done turn without contextSize falls back to an older turn (even with step usage)", () => {
+ // Contract lock-in: a done turn's step usage is NOT consulted for the
+ // context display — only its authoritative total.contextSize is. When that
+ // is absent, the display falls back to the next older finalized turn rather
+ // than synthesizing a value from the step usage.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 done WITH step usage but NO done.contextSize (edge case: the done event
+ // omitted contextSize despite per-step usage).
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => {
+ // A corrupt provider report must never reach the status bar. The newest
+ // step with invalid counters is skipped, falling back to the prior valid one.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // s2 reports NaN input (e.g. a non-numeric provider field coerced).
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s2" as StepId,
+ usage: { inputTokens: Number.NaN, outputTokens: 150 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Negative tokens are likewise skipped.
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s3" as StepId,
+ usage: { inputTokens: -10, outputTokens: 5 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+});
+
+describe("applyDurableMetrics pruning", () => {
+ it("prunes a live turn once durable data covers it (no unbounded growth)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ expect(s.live.has("t1")).toBe(true);
+ expect(s.liveOrder).toContain("t1");
+
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }],
+ contextSize: 150,
+ },
+ ]);
+ // The live copy is gone; the durable (authoritative) entry replaces it.
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.liveOrder).not.toContain("t1");
+ expect(s.durable.has("t1")).toBe(true);
+ // The display still reads the durable value atomically (no gap).
+ expect(selectCurrentContextSize(s)).toBe(150);
+ });
+
+ it("prunes only the turns present in the durable batch (leaves other live turns)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ // t2 still in flight — must NOT be pruned when only t1 seals.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 },
+ ]);
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.live.has("t2")).toBe(true);
+ expect(s.liveOrder).toEqual(["t2"]);
+ // The newest (in-flight) turn's live value still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("is a no-op when no incoming turn is live (no live mutation)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ const before = s;
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] },
+ ]);
+ // t1 was never live → the live map/order are unchanged (same reference).
+ expect(s.live).toBe(before.live);
+ expect(s.liveOrder).toBe(before.liveOrder);
+ // t1 (durable) is older; the in-flight t2 still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 }));
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 },
+ ]);
+ // The live (111) copy is pruned; only durable (222) remains.
+ expect(s.live.has("t1")).toBe(false);
+ expect(selectCurrentContextSize(s)).toBe(222);
+ });
});
diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts
index bebef1d..39fc5ee 100644
--- a/src/core/metrics/reducer.ts
+++ b/src/core/metrics/reducer.ts
@@ -68,6 +68,51 @@ function liveTurnToMetrics(lt: LiveTurn): TurnMetrics {
return base;
}
+/**
+ * A step's contribution to the live context size: `inputTokens + outputTokens`,
+ * or `undefined` when the step has no usage yet OR its counters are not safe to
+ * sum (non-finite / negative — defensive: a corrupt provider report must never
+ * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT
+ * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of
+ * `inputTokens`, so adding them would double-count.
+ */
+function stepContextSize(usage: Usage | undefined): number | undefined {
+ if (usage === undefined) return undefined;
+ const { inputTokens, outputTokens } = usage;
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined;
+ if (inputTokens < 0 || outputTokens < 0) return undefined;
+ return inputTokens + outputTokens;
+}
+
+/**
+ * The context size an IN-FLIGHT (not-done) turn occupies right now — for
+ * progressive display DURING a turn (before it seals), so the indicator updates
+ * after each step instead of waiting for `done`.
+ *
+ * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the
+ * caller, `selectCurrentContextSize`, reaches it solely for entries with
+ * `total === null`, i.e. `lt.done === false`). Finalized turns use their
+ * authoritative `contextSize` instead; `doneContextSize` is read on the
+ * `total` path, never here.
+ *
+ * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens`
+ * (scanning newest → oldest by first-seen step order): each step's input
+ * already includes all prior context (the prompt is re-prefilled every step), so
+ * the last step's input+output is the true occupancy — the same definition
+ * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage
+ * wins immediately, even mid-stream. Steps with no usage or unsafe usage are
+ * skipped, falling back to the next older usable step. `undefined` when no step
+ * has reported usable usage yet.
+ */
+function liveTurnContextSize(lt: LiveTurn): number | undefined {
+ for (let i = lt.stepOrder.length - 1; i >= 0; i--) {
+ const step = lt.stepMap.get(lt.stepOrder[i] ?? "");
+ const ctx = stepContextSize(step?.usage);
+ if (ctx !== undefined) return ctx;
+ }
+ return undefined;
+}
+
function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] {
const existing = state.live.get(turnId);
if (existing !== undefined) return [state, existing];
@@ -180,6 +225,12 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric
/**
* Store durable (sealed) metrics from the backend. These win over live data
* for any shared `turnId`.
+ *
+ * Once durable (authoritative) data covers a turn, its live (in-memory) copy
+ * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map
+ * doesn't grow unbounded over a long conversation. There is no display gap:
+ * the durable entry replaces the live one atomically in the same fold, and
+ * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it.
*/
export function applyDurableMetrics(
state: MetricsState,
@@ -187,14 +238,27 @@ export function applyDurableMetrics(
): MetricsState {
const newDurable = new Map(state.durable);
const newDurableOrder = [...state.durableOrder];
+ const prunedIds = new Set<string>();
for (const turn of turns) {
if (!newDurable.has(turn.turnId)) {
newDurableOrder.push(turn.turnId);
}
newDurable.set(turn.turnId, turn);
+ if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId);
}
+
+ if (prunedIds.size === 0) {
+ return { ...state, durable: newDurable, durableOrder: newDurableOrder };
+ }
+
+ const newLive = new Map(state.live);
+ for (const id of prunedIds) newLive.delete(id);
+ const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id));
+
return {
...state,
+ live: newLive,
+ liveOrder: newLiveOrder,
durable: newDurable,
durableOrder: newDurableOrder,
};
@@ -247,17 +311,35 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr
* Select the conversation's CURRENT context size — the tokens it occupies right
* now. Per the wire contract a client reads the LATEST turn's `contextSize`; we
* scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED
- * `contextSize` (a finalized turn whose provider reported per-step usage).
+ * value.
+ *
+ * For a FINALIZED turn (`done` event or durable data) we use its authoritative
+ * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY
+ * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the
+ * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so
+ * the indicator updates after each step completes instead of waiting for the
+ * turn to seal. An in-flight turn with no step usage yet is skipped, falling
+ * back to the next older finalized turn.
*
- * Returns `undefined` ("unknown") when no finalized turn carries a context size —
- * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over
+ * Returns `undefined` ("unknown") when no turn carries a context size — the
+ * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over
* live for a shared `turnId` (it is the persisted, authoritative value).
*/
export function selectCurrentContextSize(state: MetricsState): number | undefined {
const ordered = selectOrderedTurnMetrics(state);
for (let i = ordered.length - 1; i >= 0; i--) {
- const total = ordered[i]?.total;
- if (total?.contextSize !== undefined) return total.contextSize;
+ const entry = ordered[i];
+ if (entry === undefined) continue;
+ if (entry.total !== null) {
+ if (entry.total.contextSize !== undefined) return entry.total.contextSize;
+ continue;
+ }
+ // In-flight turn: progressive context size from the latest step with usage.
+ const lt = state.live.get(entry.turnId);
+ if (lt !== undefined) {
+ const live = liveTurnContextSize(lt);
+ if (live !== undefined) return live;
+ }
}
return undefined;
}
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 5278737..9911438 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -74,9 +74,12 @@ export interface ChatStore {
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
/**
- * The conversation's current context size (tokens occupied) — the latest
- * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
- * known yet. Never `0` for the unknown case.
+ * The conversation's current context size (tokens occupied) — updated
+ * PROGRESSIVELY: during an in-flight turn, the most recent step's
+ * `inputTokens + outputTokens` (each step's input already includes all prior
+ * context); once the turn seals, its authoritative `contextSize`. `undefined`
+ * ("unknown") when no step has reported usage yet. Never `0` for the unknown
+ * case.
*/
readonly currentContextSize: number | undefined;
/**
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index afe1e3c..04c28cd 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -39,8 +39,10 @@
onQueue?: (text: string) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
+ // Current context occupancy — updated progressively during a turn (the
+ // latest step's input+output) and finalized to the turn's `contextSize` on
+ // seal, or `undefined` when unknown — the status bar then shows
+ // "— tokens", never 0%.
contextSize?: number | undefined;
/** Per-model context window (max tokens) from `GET /models` modelInfo. */
contextWindow?: number | undefined;
@@ -66,7 +68,6 @@
const canSend = $derived(hasText || hasImages);
const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
@@ -395,7 +396,7 @@
{/if}
<span class="shrink-0 whitespace-nowrap font-mono">
- {#if hasUsage}
+ {#if usage.current !== null}
{formatCompactTokens(usage.current)}{#if usage.max !== null}<span
class="text-base-content/40"
>
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts
index 151acb8..95eb5ed 100644
--- a/src/features/concurrency/index.ts
+++ b/src/features/concurrency/index.ts
@@ -1,7 +1,9 @@
export type {
+ // Contract shapes re-exported for a single import surface.
+ ConcurrencyCooldownResponse,
+ ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
ConcurrencyLimitEntry,
- // Contract shapes re-exported for a single import surface.
ConcurrencyLimitResponse,
ConcurrencyLimitResult,
ConcurrencyLimitsResponse,
@@ -10,30 +12,47 @@ export type {
ConcurrencyStatusResponse,
ConcurrencyStatusResult,
DeleteConcurrencyLimit,
+ GetConcurrencyCooldown,
GetConcurrencyLimit,
LoadConcurrencyLimits,
LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
SaveConcurrencyLimit,
+ SetConcurrencyCooldownRequest,
SetConcurrencyLimitRequest,
} from "./logic/types";
-export type { Badge, ConcurrencyLimitView, ConcurrencyStatusView } from "./logic/view-model";
+export type {
+ AutoReduceNotice,
+ Badge,
+ ConcurrencyLimitView,
+ ConcurrencyStatusView,
+} from "./logic/view-model";
export {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
formatPauseDuration,
+ normalizeConcurrencyCooldown,
normalizeConcurrencyLimit,
normalizeConcurrencyLimits,
normalizeConcurrencyStatus,
normalizeLimit,
+ parseCooldownInput,
parseLimitInput,
pauseLabel,
providerFromModel,
providerOptions,
summarizeLimits,
summarizeStatus,
+ viewAutoReduce,
viewConcurrencyLimit,
viewConcurrencyLimits,
viewConcurrencyStatus,
viewConcurrencyStatuses,
} from "./logic/view-model";
+export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte";
+export { default as ConcurrencyCooldownRow } from "./ui/ConcurrencyCooldownRow.svelte";
export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte";
export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte";
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
index f05211f..a0c5f6b 100644
--- a/src/features/concurrency/logic/types.ts
+++ b/src/features/concurrency/logic/types.ts
@@ -1,8 +1,10 @@
import type {
+ ConcurrencyCooldownResponse,
ConcurrencyLimitResponse,
ConcurrencyLimitsResponse,
ConcurrencyStatusEntry,
ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
SetConcurrencyLimitRequest,
} from "@dispatch/transport-contract";
@@ -23,14 +25,23 @@ import type {
* 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).
+ *
+ * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry`
+ * now also carries `cooldownMs` (per-slot release cooldown, configurable +
+ * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and
+ * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual
+ * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints
+ * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown.
*/
/** Re-export the contract shapes so consumers import a single surface. */
export type {
+ ConcurrencyCooldownResponse,
ConcurrencyLimitResponse,
ConcurrencyLimitsResponse,
ConcurrencyStatusEntry,
ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
SetConcurrencyLimitRequest,
};
@@ -69,6 +80,25 @@ export type ConcurrencyStatusResult =
| { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
| { readonly ok: false; readonly error: string };
+/**
+ * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot
+ * release cooldown (ms) for one provider. `GET` returns `404` when the provider
+ * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400`
+ * for a non-negative-integer body. Both return `503` when the extension isn't
+ * loaded.
+ */
+export type ConcurrencyCooldownResult =
+ | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back
+ * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to
+ * the banner so a FAILED restore surfaces an inline error next to the button
+ * (instead of silently re-enabling the button / showing the error far away).
+ */
+export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
// ── Injected ports (consumer-defines-port; the composition root adapts the
// store's HTTP calls to these shapes). ──────────────────────────────────────
@@ -80,3 +110,10 @@ export type SaveConcurrencyLimit = (
) => Promise<ConcurrencyLimitResult>;
export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
+/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */
+export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>;
+/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */
+export type SaveConcurrencyCooldown = (
+ providerId: string,
+ cooldownMs: number,
+) => Promise<ConcurrencyCooldownResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
index 82b72b0..c284ad0 100644
--- a/src/features/concurrency/logic/view-model.test.ts
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -1,16 +1,22 @@
import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
formatPauseDuration,
+ normalizeConcurrencyCooldown,
normalizeConcurrencyLimit,
normalizeConcurrencyLimits,
normalizeConcurrencyStatus,
+ parseCooldownInput,
parseLimitInput,
pauseLabel,
providerFromModel,
providerOptions,
summarizeLimits,
summarizeStatus,
+ viewAutoReduce,
viewConcurrencyLimit,
viewConcurrencyLimits,
viewConcurrencyStatus,
@@ -23,6 +29,8 @@ const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEn
inFlight: 2,
queued: 0,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
...over,
});
@@ -46,6 +54,41 @@ describe("parseLimitInput", () => {
});
});
+// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ──
+
+describe("parseCooldownInput", () => {
+ it("accepts zero + positive integers", () => {
+ expect(parseCooldownInput("0")).toBe(0);
+ expect(parseCooldownInput("350")).toBe(350);
+ expect(parseCooldownInput(" 100 ")).toBe(100);
+ });
+
+ it("rejects negatives, non-integers, and garbage", () => {
+ expect(parseCooldownInput("-1")).toBeNull();
+ expect(parseCooldownInput("4.5")).toBeNull();
+ expect(parseCooldownInput("")).toBeNull();
+ expect(parseCooldownInput("abc")).toBeNull();
+ expect(parseCooldownInput("100ms")).toBeNull();
+ });
+});
+
+// ── cooldownLabel ─────────────────────────────────────────────────────────────
+
+describe("cooldownLabel", () => {
+ it("0 → off label", () => {
+ expect(cooldownLabel(0)).toBe("0ms (off)");
+ });
+ it("sub-second → ms", () => {
+ expect(cooldownLabel(350)).toBe("350ms");
+ expect(cooldownLabel(999)).toBe("999ms");
+ });
+ it("≥1s → seconds (trims trailing .0)", () => {
+ expect(cooldownLabel(1000)).toBe("1s");
+ expect(cooldownLabel(1500)).toBe("1.5s");
+ expect(cooldownLabel(60_000)).toBe("60s");
+ });
+});
+
// ── providerFromModel / providerOptions ───────────────────────────────────────
describe("providerFromModel", () => {
@@ -178,6 +221,8 @@ describe("viewConcurrencyStatus", () => {
inFlight: Number.NaN,
queued: "oops" as unknown as number,
paused: false,
+ cooldownMs: Number.NaN,
+ autoReduced: false,
},
0,
);
@@ -185,6 +230,7 @@ describe("viewConcurrencyStatus", () => {
expect(v.inFlight).toBe(0);
expect(v.queued).toBe(0);
expect(v.inFlightLabel).toBe("0/1");
+ expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
});
it("viewConcurrencyStatuses maps a list preserving order", () => {
@@ -194,6 +240,81 @@ describe("viewConcurrencyStatus", () => {
);
expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
});
+
+ it("carries cooldownMs + label + autoReduced fields onto the view", () => {
+ const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0);
+ expect(v.cooldownMs).toBe(1500);
+ expect(v.cooldownLabel).toBe("1.5s");
+ expect(v.autoReduced).toBe(false);
+ expect(v.autoReducedFrom).toBeNull();
+ });
+
+ it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => {
+ const v = viewConcurrencyStatus(
+ status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }),
+ 0,
+ );
+ expect(v.autoReduced).toBe(true);
+ expect(v.autoReducedFrom).toBe(4);
+ expect(v.badge).toBe("warning");
+ // autoReduced alone does NOT flip busy (a reduced limit still admits agents).
+ expect(v.busy).toBe(false);
+ });
+});
+
+// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ───────────
+
+describe("viewAutoReduce", () => {
+ it("returns null when not auto-reduced", () => {
+ expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull();
+ });
+
+ it("uses the backend notice verbatim + carries from/current limits", () => {
+ const notice = viewAutoReduce(
+ status({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ );
+ expect(notice).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ });
+
+ it("synthesizes a fallback notice when the backend notice is absent/empty", () => {
+ expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ expect(
+ viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })),
+ ).not.toBeNull();
+ });
+
+ it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => {
+ const notice = viewAutoReduce(status({ limit: 3, autoReduced: true }));
+ expect(notice?.fromLimit).toBe(4); // 3 + 1
+ });
+});
+
+describe("autoReduceNotices", () => {
+ it("collects one banner per auto-reduced provider (input order), empty when none", () => {
+ expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]);
+ const out = autoReduceNotices([
+ status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }),
+ status({ providerId: "b" }),
+ status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }),
+ ]);
+ expect(out.map((n) => n.providerId)).toEqual(["a", "c"]);
+ expect(out[1]?.fromLimit).toBe(2);
+ });
});
// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
@@ -273,6 +394,16 @@ describe("summarizeStatus", () => {
"1 provider · 1/4 in flight",
);
});
+ it("includes an auto-reduced fragment only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }),
+ status({ providerId: "b", limit: 4, inFlight: 1 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced");
+ });
});
// ── Network-seam normalizers ───────────────────────────────────────────────────
@@ -359,6 +490,8 @@ describe("normalizeConcurrencyStatus", () => {
inFlight: 2,
queued: 1,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
});
expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
expect(second).toEqual({
@@ -368,6 +501,8 @@ describe("normalizeConcurrencyStatus", () => {
queued: 3,
paused: true,
pausedUntil: now,
+ cooldownMs: 350,
+ autoReduced: false,
});
});
@@ -388,8 +523,24 @@ describe("normalizeConcurrencyStatus", () => {
],
});
expect(providers).toEqual([
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false },
+ {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ {
+ providerId: "x",
+ limit: 1,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
]);
});
@@ -402,4 +553,81 @@ describe("normalizeConcurrencyStatus", () => {
});
for (const p of providers) expect("pausedUntil" in p).toBe(false);
});
+
+ it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => {
+ const [reduced, healthy] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "umans",
+ limit: 3,
+ inFlight: 1,
+ queued: 0,
+ paused: false,
+ cooldownMs: 500,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "auto-reduced to 3 after a 429.",
+ },
+ { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false },
+ ],
+ });
+ expect(reduced?.cooldownMs).toBe(500);
+ expect(reduced?.autoReduced).toBe(true);
+ expect(reduced?.autoReducedFrom).toBe(4);
+ expect(reduced?.notice).toBe("auto-reduced to 3 after a 429.");
+ // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields
+ // are NOT present (they are only included when autoReduced===true).
+ expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(healthy?.autoReduced).toBe(false);
+ expect(healthy && "autoReducedFrom" in healthy).toBe(false);
+ expect(healthy && "notice" in healthy).toBe(false);
+ });
+
+ it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => {
+ const [p] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "x",
+ limit: 4,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ autoReduced: false,
+ autoReducedFrom: 9,
+ notice: "stale",
+ },
+ ],
+ });
+ expect(p?.autoReduced).toBe(false);
+ expect(p && "autoReducedFrom" in p).toBe(false);
+ expect(p && "notice" in p).toBe(false);
+ });
+});
+
+// ── normalizeConcurrencyCooldown ───────────────────────────────────────────────
+
+describe("normalizeConcurrencyCooldown", () => {
+ it("coerces a well-formed body", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({
+ providerId: "umans",
+ cooldownMs: 500,
+ });
+ });
+
+ it("defaults a malformed/absent cooldownMs to 350", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ });
+
+ it("returns null for a missing/malformed providerId", () => {
+ expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown(null)).toBeNull();
+ expect(normalizeConcurrencyCooldown({})).toBeNull();
+ });
});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
index 7a4fe0e..e05423b 100644
--- a/src/features/concurrency/logic/view-model.ts
+++ b/src/features/concurrency/logic/view-model.ts
@@ -1,12 +1,16 @@
-import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import type {
+ ConcurrencyCooldownResponse,
+ 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.
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown
+ * labels, auto-reduce banners, summaries), holds the limit/cooldown-input
+ * parsing, and the network-seam normalizers the composition root coerces the
+ * untyped JSON with.
*/
export type Badge = "success" | "warning" | "error" | "neutral";
@@ -36,6 +40,29 @@ export interface ConcurrencyStatusView {
readonly badge: Badge;
/** True when paused or at capacity (show a spinner). */
readonly busy: boolean;
+ /** Per-slot release cooldown in ms (defensive default 350 on garbage). */
+ readonly cooldownMs: number;
+ /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */
+ readonly cooldownLabel: string;
+ /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */
+ readonly autoReduced: boolean;
+ /** The original limit before auto-reduction; null when not auto-reduced. */
+ readonly autoReducedFrom: number | null;
+}
+
+/**
+ * A view-model for the auto-reduce banner — derived from a status entry whose
+ * `autoReduced` is `true`. `message` is the backend's `notice` when present, else
+ * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner
+ * section renders without reaching into the raw entry.
+ */
+export interface AutoReduceNotice {
+ readonly providerId: string;
+ readonly message: string;
+ /** The original limit before reduction — the value "Restore to N" PUTs. */
+ readonly fromLimit: number;
+ /** The current (reduced) limit. */
+ readonly currentLimit: number;
}
// ── Limit input parsing ───────────────────────────────────────────────────────
@@ -63,6 +90,53 @@ export function normalizeLimit(value: unknown): number {
return int >= 1 ? int : 1;
}
+// ── Cooldown input parsing ────────────────────────────────────────────────────
+//
+// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown,
+// instant re-admission) — unlike the limit, 0 is a VALID value. The default is
+// 350ms (the backend's server default when a limit is set but no explicit
+// cooldown was configured). It is configurable + persisted per provider via
+// `PUT /concurrency/cooldown/:providerId`.
+
+/** The server's default cooldown (ms) — used when none is explicitly set. */
+export const DEFAULT_COOLDOWN_MS = 350;
+
+/**
+ * Parse a raw cooldown input into a non-negative integer, or `null` when it is
+ * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc".
+ * Drives the cooldown Save button's disabled state so an invalid value never
+ * reaches the backend (the backend 400s a non-negative-integer body).
+ */
+export function parseCooldownInput(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 >= 0 ? n : null;
+}
+
+/**
+ * Coerce an untrusted cooldown value into a non-negative integer (default
+ * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a
+ * malformed `cooldownMs` can never be negative/non-finite.
+ */
+export function normalizeCooldown(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS;
+ const int = Math.floor(n);
+ return int >= 0 ? int : DEFAULT_COOLDOWN_MS;
+}
+
+/**
+ * Format a cooldown (ms) as a short display label:
+ * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed).
+ */
+export function cooldownLabel(ms: number): string {
+ if (ms <= 0) return "0ms (off)";
+ if (ms < 1000) return `${ms}ms`;
+ const secs = ms / 1000;
+ const fixed = secs.toFixed(1);
+ return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`;
+}
+
// ── Provider options (the Add-form dropdown) ───────────────────────────────────
//
// A concurrency `providerId` is the credential name that prefixes a model name
@@ -140,6 +214,10 @@ export function pauseLabel(
* 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).
+ *
+ * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is
+ * a degraded-but-active state surfaced via the banner, not a spinner) — it only
+ * nudges the badge to `warning` so the row signals attention.
*/
export function viewConcurrencyStatus(
entry: ConcurrencyStatusEntry,
@@ -149,12 +227,21 @@ export function viewConcurrencyStatus(
const inFlight = clampCount(entry.inFlight);
const queued = clampCount(entry.queued);
const paused = entry.paused === true;
+ const autoReduced = entry.autoReduced === true;
const atCapacity = inFlight >= limit;
let badge: Badge;
if (paused) badge = "warning";
+ else if (autoReduced) badge = "warning";
else if (atCapacity && queued > 0) badge = "warning";
else if (inFlight > 0) badge = "success";
else badge = "neutral";
+ const cooldownMs = normalizeCooldown(entry.cooldownMs);
+ const autoReducedFrom =
+ autoReduced &&
+ typeof entry.autoReducedFrom === "number" &&
+ Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : null;
return {
providerId: entry.providerId,
limit,
@@ -166,9 +253,53 @@ export function viewConcurrencyStatus(
pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
badge,
busy: paused || (atCapacity && queued > 0),
+ cooldownMs,
+ cooldownLabel: cooldownLabel(cooldownMs),
+ autoReduced,
+ autoReducedFrom,
};
}
+/**
+ * The auto-reduce banner view for a status entry, or `null` when it is not
+ * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present
+ * + non-empty); otherwise a synthesized fallback is built from
+ * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back.
+ */
+export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null {
+ if (entry.autoReduced !== true) return null;
+ const currentLimit = normalizeLimit(entry.limit);
+ const fromLimit =
+ typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : currentLimit + 1;
+ const notice =
+ typeof entry.notice === "string" && entry.notice.length > 0
+ ? entry.notice
+ : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`;
+ return {
+ providerId: entry.providerId,
+ message: notice,
+ fromLimit,
+ currentLimit,
+ };
+}
+
+/**
+ * All auto-reduce banners across a status list (one per auto-reduced provider),
+ * in input order. Empty when none are auto-reduced.
+ */
+export function autoReduceNotices(
+ entries: readonly ConcurrencyStatusEntry[],
+): readonly AutoReduceNotice[] {
+ const out: AutoReduceNotice[] = [];
+ for (const e of entries) {
+ const n = viewAutoReduce(e);
+ if (n !== null) out.push(n);
+ }
+ return out;
+}
+
export function viewConcurrencyStatuses(
entries: readonly ConcurrencyStatusEntry[],
now: number = Date.now(),
@@ -197,8 +328,8 @@ export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): strin
/**
* 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.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the
+ * queued / paused / auto-reduced fragments appear when non-zero.
*/
export function summarizeStatus(
providers: readonly ConcurrencyStatusEntry[],
@@ -209,12 +340,14 @@ export function summarizeStatus(
let limitTotal = 0;
let queued = 0;
let paused = 0;
+ let autoReduced = 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;
+ if (p.autoReduced === true) autoReduced += 1;
}
const parts: string[] = [];
parts.push(
@@ -223,6 +356,7 @@ export function summarizeStatus(
);
if (queued > 0) parts.push(`${queued} queued`);
if (paused > 0) parts.push(`${paused} paused`);
+ if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`);
// Touch `now` so the summary recomputes alongside the per-row pause countdown.
void now;
return parts.join(" · ");
@@ -274,7 +408,9 @@ export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry
/**
* 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).
+ * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced;
+ * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and
+ * well-formed), mirroring the backend's "present only when auto-reduced" contract.
*/
export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
if (!isRecord(data) || !Array.isArray(data.providers)) return [];
@@ -282,18 +418,57 @@ export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyS
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 providerId = asString(r.providerId) ?? "";
+ const limit = normalizeLimit(r.limit);
+ const inFlight = clampCount(r.inFlight);
+ const queued = clampCount(r.queued);
+ const paused = r.paused === true;
+ const cooldownMs = normalizeCooldown(r.cooldownMs);
+ const autoReduced = r.autoReduced === true;
+ // Build immutably (the contract fields are readonly): start with the always-
+ // present fields, then layer the optional `pausedUntil` (finite number only)
+ // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true).
+ let entry: ConcurrencyStatusEntry = {
+ providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ cooldownMs,
+ autoReduced,
};
- const pausedUntil =
- typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)
- ? r.pausedUntil
- : undefined;
- return pausedUntil !== undefined ? { ...base, pausedUntil } : base;
+ if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) {
+ entry = { ...entry, pausedUntil: r.pausedUntil };
+ }
+ if (autoReduced) {
+ // Accumulate the auto-reduce-only optionals into a plain record (the
+ // contract fields are readonly, so we can't mutate a typed partial —
+ // collect then spread into a fresh entry).
+ const patch: { autoReducedFrom?: number; notice?: string } = {};
+ if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) {
+ patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom);
+ }
+ if (typeof r.notice === "string" && r.notice.length > 0) {
+ patch.notice = r.notice;
+ }
+ if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) {
+ entry = { ...entry, ...patch };
+ }
+ }
+ return entry;
})
.filter((r) => r.providerId !== "");
}
+
+/**
+ * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a
+ * typed cooldown response, or `null` when it is malformed (missing/malformed
+ * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as
+ * `ok: false` separately; this only defends the success body.
+ */
+export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) };
+}
diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte
new file mode 100644
index 0000000..132ebc7
--- /dev/null
+++ b/src/features/concurrency/ui/AutoReduceBanner.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import type { AutoReduceNotice } from "../logic/view-model";
+ import type { RestoreOutcome } from "../logic/types";
+
+ let {
+ notice,
+ onRestore,
+ onDismiss,
+ }: {
+ /** The auto-reduce banner view (providerId + message + from/current limit). */
+ notice: AutoReduceNotice;
+ /**
+ * "Restore to N" — PUT the limit back to `fromLimit`. Returns the outcome so
+ * a FAILED restore surfaces an inline error here (the banner owns its error
+ * display; the parent only refreshes on success).
+ */
+ onRestore: (providerId: string, limit: number) => Promise<RestoreOutcome>;
+ /** Hide this banner locally (persists hidden while autoReduced stays true). */
+ onDismiss: (providerId: string) => void;
+ } = $props();
+
+ let restoring = $state(false);
+ /** Inline restore error (e.g. "Concurrency service not available"); cleared on retry. */
+ let error = $state<string | null>(null);
+
+ async function handleRestore(): Promise<void> {
+ restoring = true;
+ error = null;
+ // The parent PUTs the limit + refreshes status on success; the banner clears
+ // once the next poll shows autoReduced===false. On failure the outcome is
+ // bubbled back here so the error shows inline next to the button.
+ const result = await onRestore(notice.providerId, notice.fromLimit);
+ restoring = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ }
+</script>
+
+<div
+ class="alert alert-warning flex flex-col gap-2 py-2 text-xs"
+ role="status"
+ data-testid={`auto-reduce-banner-${notice.providerId}`}
+>
+ <div class="flex items-start gap-2">
+ <span class="shrink-0">⚠</span>
+ <div class="flex-1">
+ <p>{notice.message}</p>
+ <p class="opacity-70">
+ Was {notice.fromLimit}, now {notice.currentLimit}.
+ </p>
+ </div>
+ <div class="flex shrink-0 items-center gap-1">
+ <!-- The "Restore to N" text stays visible while loading (only the spinner is
+ prepended) so the button keeps its accessible name during the PUT — a
+ spinner-only button loses its name for screen-reader users. -->
+ <button
+ type="button"
+ class="btn btn-warning btn-xs gap-1"
+ disabled={restoring}
+ onclick={handleRestore}
+ >
+ {#if restoring}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Restore to {notice.fromLimit}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ aria-label={`Dismiss auto-reduce notice for ${notice.providerId}`}
+ onclick={() => onDismiss(notice.providerId)}
+ >
+ ✕
+ </button>
+ </div>
+ </div>
+ {#if error}
+ <p class="font-mono text-error" data-testid={`restore-error-${notice.providerId}`}>{error}</p>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte
new file mode 100644
index 0000000..03ddec9
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { parseCooldownInput } from "../logic/view-model";
+ import type { SaveConcurrencyCooldown } from "../logic/types";
+
+ let {
+ providerId,
+ cooldownMs,
+ save,
+ }: {
+ /** The provider this cooldown row controls. */
+ providerId: string;
+ /** The current per-slot release cooldown (ms) from the live status poll. */
+ cooldownMs: number;
+ save: SaveConcurrencyCooldown;
+ } = $props();
+
+ // Inline-edit state: the raw text bound to the cooldown input. Seeded from the
+ // row's canonical cooldownMs, but only while the field is untouched — so a
+ // status-poll refresh re-syncs it without clobbering an in-flight edit. Mirrors
+ // the ConcurrencyLimitRow / ChatLimitField seed pattern.
+ let draft = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved." confirmation after a successful save; cleared on edit. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incoming = String(cooldownMs);
+ untrack(() => {
+ if (draft === lastSeed) draft = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const parsed = $derived(parseCooldownInput(draft));
+ const dirty = $derived(parsed !== null && parsed !== cooldownMs);
+
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ async function handleSave(): Promise<void> {
+ if (parsed === null || parsed === cooldownMs) return;
+ saving = true;
+ error = null;
+ const result = await save(providerId, parsed);
+ saving = false;
+ if (result.ok) {
+ // Reflect the echoed cooldown back into the field immediately (the prop
+ // also re-asserts it via the seed effect above once the parent reloads).
+ draft = String(result.cooldownMs);
+ lastSeed = draft;
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+</script>
+
+<div class="flex items-center gap-2 text-xs">
+ <span class="opacity-60">cooldown</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ aria-label={`Release cooldown (ms) for ${providerId}`}
+ bind:value={draft}
+ oninput={onInput}
+ disabled={saving}
+ />
+ <span class="opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ aria-label={`Save cooldown for ${providerId}`}
+ disabled={!dirty || saving}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ {#if error}
+ <span class="font-mono text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-success">Saved.</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
index f8a2199..6078b1a 100644
--- a/src/features/concurrency/ui/ConcurrencyView.svelte
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -2,6 +2,7 @@
import { untrack } from "svelte";
import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
import {
+ autoReduceNotices,
type Badge,
parseLimitInput,
providerOptions,
@@ -15,8 +16,12 @@
DeleteConcurrencyLimit,
LoadConcurrencyLimits,
LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
SaveConcurrencyLimit,
} from "../logic/types";
+ import AutoReduceBanner from "./AutoReduceBanner.svelte";
+ import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte";
import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
let {
@@ -25,6 +30,7 @@
saveLimit,
deleteLimit,
loadStatus,
+ saveCooldown,
}: {
/** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
models: readonly string[];
@@ -32,6 +38,7 @@
saveLimit: SaveConcurrencyLimit;
deleteLimit: DeleteConcurrencyLimit;
loadStatus: LoadConcurrencyStatus;
+ saveCooldown: SaveConcurrencyCooldown;
} = $props();
const badgeClass: Record<Badge, string> = {
@@ -136,6 +143,18 @@
return result;
}
+ // Wrap the cooldown save so a successful PUT refreshes the live status (which
+ // re-carries the new `cooldownMs`). The row still gets the result to drive its
+ // own UI. `getCooldown` is exposed for completeness/future use (the live status
+ // already carries `cooldownMs`, so the row seeds from the status view).
+ async function cooldownSave(providerId: string, cooldownMs: number) {
+ const result = await saveCooldown(providerId, cooldownMs);
+ if (result.ok) {
+ void refreshStatus();
+ }
+ return result;
+ }
+
// ── Live status (polls while mounted) ───────────────────────────────────────
let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
let statusError = $state<string | null>(null);
@@ -158,6 +177,69 @@
const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now));
const statusSummary = $derived(summarizeStatus(statusEntries, now));
+ // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ───────
+ //
+ // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status`
+ // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a
+ // banner per such provider. The banner is DISMISSIBLE: a dismissed provider
+ // stays hidden while it remains auto-reduced (persist-while-true), and is
+ // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future
+ // auto-reduce re-shows the banner. Restoring the limit (PUT) clears
+ // `autoReduced` server-side → the next poll drops the banner automatically.
+ //
+ // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to
+ // localStorage / a module-global): it resets on remount (sidebar view switch /
+ // reload). This is correct — `autoReduced` is a REAL persisted degraded state,
+ // so re-showing the banner on a fresh mount reminds the user. Persisting a
+ // dismissal across reloads would risk HIDING an ongoing degradation (a
+ // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the
+ // component-local `limitsError`/`statusError` pattern.
+ let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set());
+
+ const allNotices = $derived(autoReduceNotices(statusEntries));
+ const visibleNotices = $derived(
+ allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)),
+ );
+
+ // Reconcile the dismissed set against the live auto-reduced providers: keep a
+ // dismissed entry ONLY while its provider is still auto-reduced. A provider
+ // that has been restored (no longer in `allNotices`) is dropped from the
+ // dismissed set so a future auto-reduce re-shows its banner.
+ $effect(() => {
+ const autoReducedIds = new Set(allNotices.map((n) => n.providerId));
+ untrack(() => {
+ let changed = false;
+ const next = new Set<string>();
+ for (const id of dismissedAutoReduce) {
+ if (autoReducedIds.has(id)) next.add(id);
+ else changed = true;
+ }
+ if (changed) dismissedAutoReduce = next;
+ });
+ });
+
+ function dismissAutoReduce(providerId: string): void {
+ if (dismissedAutoReduce.has(providerId)) return;
+ dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]);
+ }
+
+ // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits
+ // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits +
+ // status on success; the next status poll shows `autoReduced===false` and the
+ // banner drops (the dismissed-set effect above un-dismisses it too). The banner
+ // component owns its own restoring-spinner + inline error; on FAILURE the
+ // outcome is bubbled back so the banner shows the error inline (instead of
+ // silently re-enabling the button / surfacing it only in the limits section).
+ async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ return { ok: true };
+ }
+ return { ok: false, error: result.error };
+ }
+
async function refreshStatus(): Promise<void> {
if (statusInFlight) return;
statusInFlight = true;
@@ -192,6 +274,19 @@
</script>
<div class="flex flex-col gap-4">
+ <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) -->
+ {#if visibleNotices.length > 0}
+ <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices">
+ {#each visibleNotices as notice (notice.providerId)}
+ <AutoReduceBanner
+ {notice}
+ onRestore={restoreLimit}
+ onDismiss={dismissAutoReduce}
+ />
+ {/each}
+ </section>
+ {/if}
+
<!-- Limits (config) -->
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
@@ -319,10 +414,22 @@
<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>
+ <span title="Per-slot release cooldown">cooldown {s.cooldownLabel}</span>
</div>
{#if s.pausedLabel}
<span class="text-xs text-warning">{s.pausedLabel}</span>
{/if}
+ {#if s.autoReduced}
+ <span class="text-xs text-warning">
+ Limit auto-reduced{#if s.autoReducedFrom !== null}
+ from {s.autoReducedFrom} to {s.limit}{/if}.
+ </span>
+ {/if}
+ <ConcurrencyCooldownRow
+ providerId={s.providerId}
+ cooldownMs={s.cooldownMs}
+ save={cooldownSave}
+ />
</li>
{/each}
</ul>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
index 3dc8e78..854f13b 100644
--- a/src/features/concurrency/ui/ConcurrencyView.test.ts
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type {
+ ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
ConcurrencyLimitResult,
ConcurrencyLimitsResult,
@@ -13,35 +14,80 @@ 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*).
+// A status entry factory (defaults to a healthy limited provider). The new
+// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.
+function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry {
+ return {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+ };
+}
+// Fakes for the injected ports. Each resolves immediately so the mount effect's
+// initial load settles in a microtask (assertions await via findBy*). The status
+// list is mutable so a test can flip `autoReduced` between polls to simulate a
+// restore clearing the banner.
function makeFakes(opts?: {
limits?: readonly { providerId: string; limit: number }[];
- status?: readonly ConcurrencyStatusEntry[];
+ status?: ConcurrencyStatusEntry[];
+ /**
+ * When set, `saveLimit` rejects with this error (returns `ok: false`) — used
+ * to test the auto-reduce banner's inline restore-error feedback.
+ */
+ saveLimitError?: string;
+ /**
+ * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a
+ * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced`
+ * on the next status poll). Receives the providerId + limit + the fakes bag so
+ * it can mutate the status list. (A plain method reassignment would NOT reach
+ * the already-rendered component — the prop captured the original closure.)
+ */
+ onSaveLimit?: (
+ providerId: string,
+ limit: number,
+ self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void },
+ ) => void;
}) {
let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
- const status = opts?.status ?? [
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- ];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
- const calls = {
+ const calls: MakeFakesCalls = {
loadLimits: 0,
loadStatus: 0,
saves: [] as { providerId: string; limit: number }[],
deletes: [] as string[],
+ cooldownSaves: [] as { providerId: string; cooldownMs: number }[],
};
+ function setStatus(next: ConcurrencyStatusEntry[]): void {
+ status = next;
+ }
+
return {
calls,
+ // Allow a test to mutate the status list between polls (e.g. clear
+ // autoReduced after a restore to simulate the next poll).
+ setStatus,
loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
calls.loadLimits++;
return { ok: true, limits };
},
saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
calls.saves.push({ providerId, limit });
+ if (saveLimitError !== undefined) {
+ return { ok: false, error: saveLimitError };
+ }
// Reflect the new limit into the list the next load returns.
limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus });
return { ok: true, providerId, limit };
},
deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => {
@@ -53,21 +99,41 @@ function makeFakes(opts?: {
calls.loadStatus++;
return { ok: true, providers: status };
},
+ saveCooldown: async (
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> => {
+ calls.cooldownSaves.push({ providerId, cooldownMs });
+ // Reflect the new cooldown into the status list the next load returns.
+ status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s));
+ return { ok: true, providerId, cooldownMs };
+ },
+ };
+}
+
+type MakeFakesCalls = {
+ loadLimits: number;
+ loadStatus: number;
+ saves: { providerId: string; limit: number }[];
+ deletes: string[];
+ cooldownSaves: { providerId: string; cooldownMs: number }[];
+};
+
+function props(fakes: ReturnType<typeof makeFakes>) {
+ return {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ saveCooldown: fakes.saveCooldown,
};
}
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,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// The provider dropdown is populated from the available models' providers.
const providerSelect = await screen.findByLabelText("Provider");
@@ -81,21 +147,21 @@ describe("ConcurrencyView", () => {
expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
});
+ it("renders the per-provider cooldown label from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The status card shows the cooldown alongside in-flight/queue.
+ expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument();
+ });
+
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,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
@@ -116,15 +182,7 @@ describe("ConcurrencyView", () => {
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,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// Choose "anthropic" from the dropdown (the list is auto-selected first).
@@ -142,15 +200,7 @@ describe("ConcurrencyView", () => {
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,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// A provider is auto-selected from the dropdown.
@@ -172,13 +222,7 @@ describe("ConcurrencyView", () => {
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,
- },
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
});
const providerSelect = await screen.findByLabelText("Provider");
@@ -189,15 +233,7 @@ describe("ConcurrencyView", () => {
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,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// Wait for the limits to load (unique summary) before interacting.
await screen.findByText(/1 limit configured/);
@@ -209,7 +245,7 @@ describe("ConcurrencyView", () => {
it("surfaces a load error from the limits endpoint", async () => {
const failing = {
- models: MODELS,
+ models: MODELS as unknown as readonly string[],
loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
ok: false,
error: "Concurrency service not available",
@@ -217,8 +253,191 @@ describe("ConcurrencyView", () => {
saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }),
};
render(ConcurrencyView, { props: failing });
expect(await screen.findByText("Concurrency service not available")).toBeVisible();
});
+
+ // ── Concurrency-fixes: auto-reduce banner + cooldown editing ────────────────
+
+ it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => {
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The banner shows the backend notice verbatim + a "Restore to 4" action.
+ expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible();
+ expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ // The "Was 4, now 3." provenance line is shown.
+ expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible();
+ });
+
+ it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => {
+ const user = userEvent.setup();
+ // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced`
+ // server-side; the next status poll returns limit 4 + autoReduced===false →
+ // the banner drops.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ // Simulate the backend clearing `autoReduced` on the manual PUT: the next
+ // status load returns the restored limit with autoReduced===false.
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The restore PUT the limit back to the original (autoReducedFrom = 4).
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ // The banner is gone (no Restore button, no notice text); the status summary
+ // now reflects the restored limit (2/4 in flight).
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByRole("button", { name: /Restore to 4/ });
+ // Dismiss the banner (hide locally — the provider is still auto-reduced).
+ await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans"));
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ saveLimitError: "Concurrency service not available",
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The error surfaces INLINE in the banner (near the restore action), not
+ // only in the far-away limits section. The banner is still present (restore
+ // did not succeed) and the button re-enabled for a retry.
+ expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent(
+ "Concurrency service not available",
+ );
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled();
+ // The restore PUT was attempted.
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ });
+
+ it("clears the inline restore error on a retry that succeeds", async () => {
+ const user = userEvent.setup();
+ // First restore fails; the second succeeds (clears autoReduced). Reassigning
+ // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the
+ // rendered component, so swap it BEFORE render here.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ let attempts = 0;
+ const succeeding = fakes.saveLimit;
+ fakes.saveLimit = async (providerId, limit) => {
+ attempts++;
+ if (attempts === 1) return { ok: false, error: "Concurrency service not available" };
+ return succeeding(providerId, limit);
+ };
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+ // First attempt: inline error appears.
+ expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument();
+
+ // Retry: the error clears, the banner drops (restore succeeded).
+ await user.click(screen.getByRole("button", { name: /Restore to 4/ }));
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the status card + its cooldown input (seeded with 350).
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Save cooldown for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // After save the component reloads status (which now carries cooldownMs=500).
+ expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument();
+ });
+
+ it("rejects a negative cooldown input (Save disabled — non-negative integer only)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ // 0 is valid (no cooldown); a negative is not.
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "0");
+ expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
});
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 561b06c..6de4109 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -11,14 +11,27 @@
store,
onNavigate,
computers,
+ hasActive,
}: {
ws: WorkspaceEntry;
store: WorkspaceStore;
onNavigate: (path: string) => void;
/** Discovered computers (`GET /computers`), for the default-computer dropdown. */
computers: readonly ComputerEntry[];
+ /**
+ * Optional port: returns whether the workspace has at least one active
+ * (generating / queued) conversation — drives a loading-dots indicator on
+ * the card. Wired by the composition root to the app store's
+ * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests).
+ */
+ hasActive?: (workspaceId: string) => boolean;
} = $props();
+ // Whether at least one conversation in this workspace is currently active
+ // (generating). Reactive: the composition-root port reads the app store's
+ // reactive tab set + lifecycle statuses, so this re-derives on change.
+ const active = $derived(hasActive?.(ws.id) ?? false);
+
// ── Title: double-click to rename inline ──────────────────────────────────
let editingTitle = $state(false);
let titleDraft = $state("");
@@ -144,6 +157,13 @@
ondblclick={startEditTitle}>{ws.title}</span
>
{/if}
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0 text-primary"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span
+ >
+ {/if}
<span class="font-mono text-xs opacity-50">/{ws.id}</span>
<button
type="button"
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index f3ed1e7..28aed88 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -143,6 +143,63 @@ describe("WorkspaceCard", () => {
expect(onNavigate).toHaveBeenCalledWith("/my-ws");
});
+ // ── Active indicator (loading dots) ──────────────────────────────────────
+
+ it("shows no loading-dots when no hasActive port is given", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows no loading-dots when hasActive returns false", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => false,
+ },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows loading-dots when hasActive returns true", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => true,
+ },
+ });
+ const dots = container.querySelector(".loading-dots");
+ expect(dots).not.toBeNull();
+ // Accessible label ties the indicator to the workspace-active concept.
+ expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations");
+ });
+
+ it("forwards the workspace id to hasActive", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const seen: string[] = [];
+ render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry({ id: "proj-x" }),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: (id: string) => {
+ seen.push(id);
+ return false;
+ },
+ },
+ });
+ expect(seen).toEqual(["proj-x"]);
it("renders an outline star button for an unstarred workspace", () => {
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 02e92b4..d97eab7 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -9,10 +9,17 @@
store,
onNavigate,
computers,
+ hasActive,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
computers: readonly ComputerEntry[];
+ /**
+ * Optional port forwarded to each {@link WorkspaceCard}: whether the
+ * workspace has at least one active (generating / queued) conversation.
+ * Wired by the composition root to the app store. Absent → no indicator.
+ */
+ hasActive?: (workspaceId: string) => boolean;
} = $props();
onMount(() => {
@@ -89,7 +96,13 @@
{:else}
<ul class="flex flex-col gap-2">
{#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} {computers} />
+ <WorkspaceCard
+ {ws}
+ {store}
+ {onNavigate}
+ {computers}
+ {...(hasActive ? { hasActive } : {})}
+ />
{/each}
</ul>
{/if}