summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/ui.test.ts33
-rw-r--r--src/features/chat/ui/CacheTimerBadge.svelte24
-rw-r--r--src/features/chat/ui/ChatView.svelte4
3 files changed, 53 insertions, 8 deletions
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 @@
</script>
<span
- class="badge badge-sm gap-1 font-mono tabular-nums {badgeClass[view.level]}"
+ class="badge font-mono tabular-nums {badgeClass[view.level]}"
+ class:badge-xs={size === "xs"}
+ class:badge-sm={size === "sm"}
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
+ >{view.label}</span
>
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}%</span
>
{#if stepStamp !== undefined}
- <CacheTimerBadge elapsedMs={stepStamp} />
+ <CacheTimerBadge elapsedMs={stepStamp} size="xs" />
{/if}
</div>
</div>
@@ -307,7 +307,7 @@
<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} />
+ <CacheTimerBadge since={liveStepGap.startedAt} size="xs" />
</div>
</div>
</div>