diff options
| author | Adam Malczewski <[email protected]> | 2026-10-19 17:02:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-19 17:02:40 +0900 |
| commit | 01f9fb2a4ad6110def531ead16b370c818e8ee81 (patch) | |
| tree | 398996139295c24350714bb02a88b0dc07545123 /src/features | |
| parent | c4397f633415a46852f0e3b49c78ab7b2d87d4c1 (diff) | |
| download | dispatch-web-01f9fb2a4ad6110def531ead16b370c818e8ee81.tar.gz dispatch-web-01f9fb2a4ad6110def531ead16b370c818e8ee81.zip | |
feat(chat): cache timer for Claude Code subscription models
Per-step and per-turn timers for the gaps between model requests (the
Claude prompt cache expires after 5 min idle). A step gap runs live under
the executing tools from step-complete until its last tool result (or the
next step's output), then is stamped on that step's metrics line beside
its cache badge. A turn gap runs live on the turn's metrics line from
done/error until the next message is sent, then is stamped there. Counts
m:ss up to 10 min, then '10+ min'; colour tracks the 5-min TTL.
Gated on claude/<model>. Pure reducer in core/metrics/cache-timer.ts;
the turn gap is seeded from an idle conversation's lastActivityAt so it
survives a reload. Stamps are in-memory only.
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/chat/index.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/model-select.test.ts | 21 | ||||
| -rw-r--r-- | src/features/chat/model-select.ts | 19 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 45 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 103 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 163 | ||||
| -rw-r--r-- | src/features/chat/ui/CacheTimerBadge.svelte | 58 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 62 |
8 files changed, 470 insertions, 3 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 59da39b..fec2cdb 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -8,6 +8,8 @@ export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../c export type { TurnMetricsEntry } from "../../core/metrics"; export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; export { + CLAUDE_SUBSCRIPTION_KEY, + isClaudeSubscriptionModel, isNamedAgentSelection, isNumberedOpenCode, isVisionModel, diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 57de11d..523f1d8 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isClaudeSubscriptionModel, isNamedAgentSelection, isVisionModel, joinModelName, @@ -192,3 +193,23 @@ describe("named-agent provider helpers", () => { expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding"); }); }); + +describe("isClaudeSubscriptionModel", () => { + it("is true for claude/<model> (the Claude Code subscription credential)", () => { + expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true); + expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true); + }); + + it("is false for Claude models served through other credentials", () => { + expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false); + expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false); + expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false); + }); + + it("is false for a bare key, a lookalike key, or undefined", () => { + expect(isClaudeSubscriptionModel("claude")).toBe(false); + expect(isClaudeSubscriptionModel("claude/")).toBe(false); + expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false); + expect(isClaudeSubscriptionModel(undefined)).toBe(false); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 6f198f3..bebad2c 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -63,6 +63,25 @@ export function isNumberedOpenCode(id: string): boolean { } /** + * The credential key of the Claude Code subscription (the external Claude + * extension — `auth-claude` + `provider-anthropic`). The backend registers it + * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive + * as `claude/<model>`. + */ +export const CLAUDE_SUBSCRIPTION_KEY = "claude"; + +/** + * True iff `full` is a model served through the Claude Code subscription + * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache + * expires after 5 minutes). A bare key with no model is not a model. + */ +export function isClaudeSubscriptionModel(full: string | undefined): boolean { + if (full === undefined) return false; + const { key, model } = splitModelName(full); + return key === CLAUDE_SUBSCRIPTION_KEY && model !== ""; +} + +/** * A friendly human label for a model key/provider id: `opencode-<n>` → * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what * gets selected/persisted — this only affects display. diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 7f8e113..01ad9f9 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -25,13 +25,17 @@ import { unloadCount, windowTranscript, } from "../../core/chunks"; -import type { MetricsState, TurnMetricsEntry } from "../../core/metrics"; +import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics"; import { applyDurableMetrics, + foldCacheTimerEvent, foldMetricsEvent, + initialCacheTimerState, initialMetricsState, + seedTurnGap, selectCurrentContextSize, selectOrderedTurnMetrics, + stopTurnGap, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; import type { NamedAgentSelectionSnapshot } from "../named-agents"; @@ -78,6 +82,8 @@ export interface ChatStoreDependencies { * folded synchronously (the original behavior; used by tests). */ readonly scheduleFlush?: (flush: () => void) => void; + /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */ + readonly now?: () => number; } export interface ChatStore { @@ -122,6 +128,20 @@ export interface ChatStore { * a trim (see `TranscriptState.hiddenThinkingCount`). */ readonly thinkingKeyBase: number; + /** + * The cache timer (see `core/metrics/cache-timer.ts`): the running gap + * between model requests (a step's tools executing, or the wait after a turn + * ended) plus the stamped durations of finished gaps, keyed by `stepId` / + * `turnId`. Timed with the local clock from live events; in-memory only (a + * reload keeps just a turn gap seeded via `noteLastActivity`). + */ + readonly cacheTimer: CacheTimerState; + /** + * Seed the turn gap from an authoritative activity timestamp (the + * conversation list's `lastActivityAt`) — call only for an IDLE conversation. + * Ignored while a turn is generating; a running turn gap keeps the later start. + */ + noteLastActivity(at: number): void; handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; /** * Send a user message (start a turn via `chat.send`). Optimistically echoes @@ -200,8 +220,16 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let _error = $state<string | null>(null); let _model = $state<string | undefined>(deps.model); let _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null); + let cacheTimer = $state<CacheTimerState>(initialCacheTimerState()); + const now = deps.now ?? Date.now; let disposed = false; + /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */ + function lastTurnIdOf(m: MetricsState): string | undefined { + const entries = selectOrderedTurnMetrics(m); + return entries[entries.length - 1]?.turnId; + } + let chatLimit = normalizeChatLimit(deps.chatLimit); // Memoized projections, keyed by state-object identity (the reducers are @@ -242,14 +270,19 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let m = metrics; let sealed = false; let failed = false; + let c = cacheTimer; + const at = now(); for (const event of events) { t = foldEvent(t, event); m = foldMetricsEvent(m, event); + const mNow = m; + c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow)); if (t.sealedTurnId !== null) sealed = true; else if (event.type === "error") failed = true; } transcript = t; metrics = m; + cacheTimer = c; maybeTrim(); if (sealed) { void syncTail(); @@ -390,6 +423,14 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { get thinkingKeyBase(): number { return transcript.hiddenThinkingCount; }, + get cacheTimer(): CacheTimerState { + return cacheTimer; + }, + + noteLastActivity(at: number): void { + if (selectGenerating(transcript)) return; + cacheTimer = seedTurnGap(cacheTimer, at); + }, handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { if (msg.type === "chat.error") { @@ -415,6 +456,8 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { send(text: string, images?: readonly ImageInput[]): void { flushEvents(); + // Sending ends the wait since the last turn: stamp it on that turn. + cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics)); transcript = appendUserMessage(transcript, text, images); maybeTrim(); const msg: ChatSendMessage = { diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index f9b4b38..8604107 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1789,3 +1789,106 @@ describe("createChatStore — batched delta application (scheduleFlush)", () => expect(store.chunks).toHaveLength(0); }); }); + +describe("createChatStore — cacheTimer", () => { + function makeStore(now: () => number) { + return createChatStore({ + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + now, + }); + } + const call = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + input: {}, + }); + const result = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-result", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + content: "ok", + isError: false, + }); + const stepComplete = (stepId: string) => + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + }); + const done = (turnId: string) => + deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" }); + + it("is idle until something happens", () => { + const store = makeStore(() => 1000); + expect(store.cacheTimer.running).toBeNull(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("times a step's tool execution with the injected clock and stamps it", () => { + let clock = 0; + const store = makeStore(() => clock); + store.handleDelta(call("t1#0", "a")); + clock = 1_000; + store.handleDelta(stepComplete("t1#0")); + expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 }); + clock = 31_000; + store.handleDelta(result("t1#0", "a")); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000); + store.dispose(); + }); + + it("starts a turn gap on done and stamps it on send", () => { + let clock = 5_000; + const store = makeStore(() => clock); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + clock = 125_000; + store.send("next"); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000); + store.dispose(); + }); + + it("ignores another conversation's events", () => { + const store = makeStore(() => 5_000); + store.handleDelta( + deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }), + ); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => { + const store = makeStore(() => 5_000); + store.noteLastActivity(3_000); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 }); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + store.noteLastActivity(4_000); // an older seed never rewinds it + expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 }); + store.dispose(); + + const busy = makeStore(() => 5_000); + busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + busy.noteLastActivity(4_000); + expect(busy.cacheTimer.running).toBeNull(); + busy.dispose(); + }); +}); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 91585e2..6b7176d 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -3,7 +3,11 @@ import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; -import type { TurnMetricsEntry } from "../../core/metrics"; +import { + type CacheTimerState, + initialCacheTimerState, + type TurnMetricsEntry, +} from "../../core/metrics"; import type { ConvertedJpeg } from "./adapter/image-encode"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; @@ -1542,3 +1546,160 @@ describe("ReasoningEffortSelector", () => { }); }); }); + +describe("ChatView — cache timer", () => { + const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 }; + function step(stepId: string) { + return { stepId: stepId as StepId, usage, decodeMs: 100 }; + } + function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry { + const steps = stepIds.map(step); + return { turnId, steps, total: { turnId, usage, steps } }; + } + function exchange(turnId: string, base: number): RenderedChunk[] { + return [ + { + seq: base, + role: "user", + chunk: { type: "text", text: `q-${turnId}` }, + provisional: false, + turnId, + }, + { + seq: base + 1, + role: "assistant", + chunk: { type: "text", text: `a-${turnId}` }, + provisional: false, + turnId, + }, + ]; + } + function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk { + return { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: `call-${stepId}`, + toolName: "read_file", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + turnId, + }; + } + function state(partial: Partial<CacheTimerState>): CacheTimerState { + return { ...initialCacheTimerState(), ...partial }; + } + + it("renders nothing when the feature is off (cacheTimer null)", () => { + render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } }); + expect(screen.queryByTestId("cache-timer-live")).toBeNull(); + expect(screen.queryByTestId("cache-timer-stamp")).toBeNull(); + }); + + it("shows a live timer under executing tools, counting up to 10+ min", async () => { + vi.useFakeTimers(); + try { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 1, + pending: new Set(["call-t1#0"]), + maxToolMs: 0, + }, + }), + }, + }); + const row = screen.getByTestId("cache-timer-tools"); + expect(row).toHaveTextContent(/Running tools/); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30"); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min"); + } finally { + vi.useRealTimers(); + } + }); + + it("hides the live tools row for a step with no tool calls", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 0, + pending: new Set(), + maxToolMs: 0, + }, + }), + }, + }); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("stamps a finished step gap on that step's line, next to its cache badge", () => { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }), + }, + }); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("1:05"); + // Same line as the step's cache badge. + expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge")); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: "t2", startedAt: Date.now() }, + turnStamps: new Map([["t1", 11 * 60_000]]), + }), + }, + }); + const live = screen.getByTestId("cache-timer-live"); + expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("10+ min"); + expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/); + }); + + it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 }, + }), + }, + }); + const badges = screen.getAllByTestId("cache-timer-live"); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent("2:00"); + expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + }); +}); diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte new file mode 100644 index 0000000..e5abed5 --- /dev/null +++ b/src/features/chat/ui/CacheTimerBadge.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + /** + * One cache-timer badge. LIVE when given `since` (counts up from that epoch + * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED + * when given `elapsedMs` (a finished gap — static). Formatting lives in + * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this + * badge re-renders each second, not the transcript. + */ + import { untrack } from "svelte"; + import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics"; + + let { + since, + elapsedMs, + now = Date.now, + }: { + /** Live: epoch ms the gap started. */ + since?: number; + /** Stamped: the gap's final duration (wins over `since`). */ + elapsedMs?: number; + /** Wall clock (injected for tests). */ + now?: () => number; + } = $props(); + + // Initial read only (the effect below keeps it current while live). + let current = $state(untrack(() => now())); + + $effect(() => { + if (elapsedMs !== undefined || since === undefined) return; + const start = since; + current = now(); + if (current - start >= CACHE_TIMER_MAX_MS) return; + const id = setInterval(() => { + current = now(); + if (current - start >= CACHE_TIMER_MAX_MS) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }); + + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; + + const live = $derived(elapsedMs === undefined); + const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current))); +</script> + +<span + class="badge badge-sm gap-1 font-mono tabular-nums {badgeClass[view.level]}" + class:badge-outline={!live} + title={live + ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle." + : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."} + data-testid={live ? "cache-timer-live" : "cache-timer-stamp"} + ><span aria-hidden="true">⏱</span>{view.label}</span +> diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 18fea7c..e641791 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,7 +2,9 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, @@ -10,6 +12,7 @@ type TurnMetricsEntry, } from "../../../core/metrics"; import { Markdown } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; import LazyCollapse from "./LazyCollapse.svelte"; import ToolCard from "./ToolCard.svelte"; @@ -27,6 +30,7 @@ thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; turnMetrics?: readonly TurnMetricsEntry[]; @@ -55,6 +59,14 @@ * "" (root-relative — a browser resolves `/images/…` against its origin). */ apiBaseUrl?: string; + /** + * The cache timer (gaps between model requests): a running step gap shows + * live at the bottom (under the executing tools), a running turn gap live + * on that turn's metrics row; finished gaps are stamped on their step / + * turn metrics line. `null` (the default) hides it entirely — the caller + * gates on the model. + */ + cacheTimer?: CacheTimerState | null; } = $props(); // True while a show-earlier page-in is awaited (disables the button). @@ -74,6 +86,31 @@ const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most + // recent turn's row (when no user message follows it). + const seededTurnRow = $derived( + cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null + ? lastTurnRowIndex(rows) + : null, + ); + + /** Start of the running turn gap if it belongs on this turn row, else undefined. */ + function liveTurnSince(turnId: string, rowIndex: number): number | undefined { + const r = cacheTimer?.running; + if (r?.kind !== "turn") return undefined; + if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) { + return r.startedAt; + } + return undefined; + } + + // A running step gap with tool calls → the live timer under the executing tools. + const liveStepGap = $derived( + cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0 + ? cacheTimer.running + : null, + ); + // Tool cards use toolCallId keys across pending/result/seal and orphan page-in. // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that // survives the provisional→committed (seq null → seq N) transition, so the @@ -180,13 +217,14 @@ </button> </div> {/if} - {#each keyedRows as { row, key } (key)} + {#each keyedRows as { row, key }, i (key)} <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint for rows outside the viewport (see app.css). --> <div class="chat-row"> {#if row.kind === "step-metrics"} {@const sv = viewStepMetrics(row.step, row.index)} {@const stepCache = viewCacheRate(row.step.usage)} + {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-wrap items-center gap-x-1 text-xs"> @@ -203,6 +241,9 @@ class="badge badge-xs {badgeClass[stepCache.level]}" data-testid="step-cache-badge">{stepCache.pct}%</span > + {#if stepStamp !== undefined} + <CacheTimerBadge elapsedMs={stepStamp} /> + {/if} </div> </div> </div> @@ -211,6 +252,8 @@ {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)} + {@const turnLive = liveTurnSince(row.turn.turnId, i)} + {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> @@ -236,6 +279,11 @@ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> </span> {/if} + {#if turnLive !== undefined} + <CacheTimerBadge since={turnLive} /> + {:else if turnStamp !== undefined} + <CacheTimerBadge elapsedMs={turnStamp} /> + {/if} </div> </div> </div> @@ -252,6 +300,18 @@ {/if} </div> {/each} + {#if liveStepGap} + <!-- Live cache timer while the last step's tools execute; stamped onto that + step's metrics line once they return (or the next step starts). --> + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools"> + <span class="opacity-70">Running tools</span> + <CacheTimerBadge since={liveStepGap.startedAt} /> + </div> + </div> + </div> + {/if} {#if providerRetry} {@const rv = viewProviderRetry(providerRetry)} <!-- Transient yellow warning: a provider error is being retried with backoff. |
