From 6122a90cf0c9f938945e684e19e74153b38e7b4b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 19 Oct 2026 17:16:48 +0900 Subject: fix(chat): cache timer no longer stalls transcript rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live CacheTimerBadge effect read the `current` state it wrote, so on a real (advancing) clock it re-ran itself until Svelte threw effect_update_depth_exceeded, aborting the reactive flush — turn data rendered late. The effect now uses locals and never reads its own write. Also: drop the timer icon (time only), and size step-line timers xs to match the step cache badge (turn lines stay sm). --- src/features/chat/ui.test.ts | 33 +++++++++++++++++++++++++++++ src/features/chat/ui/CacheTimerBadge.svelte | 24 +++++++++++++++------ src/features/chat/ui/ChatView.svelte | 4 ++-- 3 files changed, 53 insertions(+), 8 deletions(-) (limited to 'src/features/chat') diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 6b7176d..10af76c 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -9,6 +9,7 @@ import { type TurnMetricsEntry, } from "../../core/metrics"; import type { ConvertedJpeg } from "./adapter/image-encode"; +import CacheTimerBadge from "./ui/CacheTimerBadge.svelte"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; @@ -1664,6 +1665,9 @@ describe("ChatView — cache timer", () => { }); const stamp = screen.getByTestId("cache-timer-stamp"); expect(stamp).toHaveTextContent("1:05"); + // Sized to match the step's (xs) cache badge. + expect(stamp).toHaveClass("badge-xs"); + expect(stamp).not.toHaveClass("badge-sm"); // Same line as the step's cache badge. expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge")); expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); @@ -1682,11 +1686,40 @@ describe("ChatView — cache timer", () => { }); const live = screen.getByTestId("cache-timer-live"); expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + expect(live).toHaveClass("badge-sm"); const stamp = screen.getByTestId("cache-timer-stamp"); expect(stamp).toHaveTextContent("10+ min"); expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/); }); + it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => { + // Regression: the badge's effect once READ the `current` state it wrote, so + // with a real (always-advancing) clock it re-ran itself until Svelte threw + // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late. + let calls = 0; + const now = () => { + calls++; + return 1_000_000 + calls; + }; + render(CacheTimerBadge, { props: { since: 1_000_000, now } }); + await new Promise((r) => setTimeout(r, 20)); + expect(calls).toBeLessThan(5); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + }); + + it("renders the turn's metrics alongside a live turn timer", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [turn("t1")], + cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }), + }, + }); + expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument(); + expect(screen.getByText("a-t1")).toBeInTheDocument(); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/); + }); + it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => { render(ChatView, { props: { diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte index e5abed5..9e6f96d 100644 --- a/src/features/chat/ui/CacheTimerBadge.svelte +++ b/src/features/chat/ui/CacheTimerBadge.svelte @@ -12,12 +12,15 @@ let { since, elapsedMs, + size = "sm", now = Date.now, }: { /** Live: epoch ms the gap started. */ since?: number; /** Stamped: the gap's final duration (wins over `since`). */ elapsedMs?: number; + /** Badge size — match the neighbouring cache badge (`xs` on step lines). */ + size?: "xs" | "sm"; /** Wall clock (injected for tests). */ now?: () => number; } = $props(); @@ -25,14 +28,21 @@ // Initial read only (the effect below keeps it current while live). let current = $state(untrack(() => now())); + // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current` + // (it writes it): reading its own write makes the effect re-run itself on + // every clock change → `effect_update_depth_exceeded`, which aborts the whole + // reactive flush and stalls the transcript (turn data rendering late). $effect(() => { if (elapsedMs !== undefined || since === undefined) return; const start = since; - current = now(); - if (current - start >= CACHE_TIMER_MAX_MS) return; + const clock = now; + const t0 = clock(); + current = t0; + if (t0 - start >= CACHE_TIMER_MAX_MS) return; const id = setInterval(() => { - current = now(); - if (current - start >= CACHE_TIMER_MAX_MS) clearInterval(id); + const t = clock(); + current = t; + if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id); }, 1000); return () => clearInterval(id); }); @@ -48,11 +58,13 @@ {view.label}{view.label} diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index e641791..4fd0911 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -242,7 +242,7 @@ data-testid="step-cache-badge">{stepCache.pct}% {#if stepStamp !== undefined} - + {/if} @@ -307,7 +307,7 @@
Running tools - +
-- cgit v1.2.3