summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md32
-rw-r--r--src/App.svelte7
-rw-r--r--src/app/App.svelte2
-rw-r--r--src/app/store.svelte.ts31
-rw-r--r--src/app/store.test.ts132
-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/workspaces/ui/WorkspaceCard.svelte20
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts59
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte15
16 files changed, 783 insertions, 72 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index fc9a6db..c7ec815 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,6 +5,12 @@
> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
+_Last updated: 2026-06-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
@@ -1043,6 +1049,32 @@ 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
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 3e7d05a..b2cfd2d 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -504,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"
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index d725dc0..22b0a25 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -353,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
@@ -986,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
@@ -1275,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();
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a2f7f2f..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();
});
@@ -1842,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/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 0ffc975..1825b7d 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("");
@@ -122,6 +135,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>
<span class="ml-auto text-xs opacity-50">
{ws.conversationCount}
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 0d03b8e..1874391 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -135,4 +135,63 @@ describe("WorkspaceCard", () => {
expect(onNavigate).toHaveBeenCalledTimes(1);
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"]);
+ });
});
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}