summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 18:41:27 +0900
committerAdam Malczewski <[email protected]>2026-06-07 18:41:27 +0900
commit48c6d85c3cc5a57a729f14068e2346b17ed62088 (patch)
treeec56590653f399f4a5feae0245652eba8f352ad5 /src/features/chat
parent2e79dd122e5664353e02e0d33715ae8c1041a379 (diff)
downloaddispatch-web-48c6d85c3cc5a57a729f14068e2346b17ed62088.tar.gz
dispatch-web-48c6d85c3cc5a57a729f14068e2346b17ed62088.zip
feat(chat): live turn metrics — telemetry reducer + rendering
Consume wire/transport-contract 0.3.0 (step-complete event + timing fields on usage/tool-result/done). Pure core/telemetry module: foldMetricEvent (reducer) + derived selectors (stepTps, turnTps, etc). TelemetryState is pure data, no active-turn tracking — consumers pass turnId to selectors. ChatStore wires foldMetricEvent into handleDelta and exposes telemetry + currentTurnId. ChatView shows step-metrics footer (time/TPS/tokens) on assistant text bubbles and durationMs badge on tool cards. New TurnSummary component renders turn-level stats (wall-clock, tokens, steps, TPS) in a DaisyUI stats block. Extended live-probe to verify telemetry events against bin/up (pending backend restart). 336 tests, typecheck 0, biome clean, build ok.
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/store.svelte.ts12
-rw-r--r--src/features/chat/store.test.ts46
-rw-r--r--src/features/chat/ui.test.ts150
-rw-r--r--src/features/chat/ui/ChatView.svelte93
-rw-r--r--src/features/chat/ui/TurnSummary.svelte75
6 files changed, 341 insertions, 37 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 4f2091a..b096cca 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,8 +1,10 @@
export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks";
export { groupRenderedChunks } from "../../core/chunks";
+export type { StepMetrics, TelemetryState, TurnMetrics } from "../../core/telemetry";
export type { ChatTransport, HistorySync } from "./ports";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as TurnSummary } from "./ui/TurnSummary.svelte";
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 1d8ab17..58c165f 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -13,6 +13,8 @@ import {
selectChunks,
selectMessages,
} from "../../core/chunks";
+import type { TelemetryState } from "../../core/telemetry";
+import { foldMetricEvent, initialState as telemetryInitialState } from "../../core/telemetry";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync } from "./ports";
@@ -30,6 +32,8 @@ export interface ChatStore {
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
+ readonly telemetry: TelemetryState;
+ readonly currentTurnId: string | null;
handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
send(text: string): void;
setModel(model: string): void;
@@ -42,6 +46,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
let _pendingSync = $state(false);
let _error = $state<string | null>(null);
let _model = $state<string | undefined>(deps.model);
+ let telemetry = $state<TelemetryState>(telemetryInitialState());
let disposed = false;
async function syncTail(): Promise<void> {
@@ -76,6 +81,12 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get model(): string | undefined {
return _model;
},
+ get telemetry(): TelemetryState {
+ return telemetry;
+ },
+ get currentTurnId(): string | null {
+ return transcript.currentTurnId;
+ },
handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
if (msg.type === "chat.error") {
@@ -89,6 +100,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return;
}
transcript = foldEvent(transcript, msg.event);
+ telemetry = foldMetricEvent(telemetry, msg.event);
if (transcript.sealedTurnId !== null) {
void syncTail();
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 71781ac..347cdd7 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -393,6 +393,52 @@ describe("createChatStore", () => {
store.dispose();
});
+ it("folding step-complete and usage events populates telemetry", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 300,
+ decodeMs: 700,
+ genTotalMs: 1000,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 50, outputTokens: 20 },
+ }),
+ );
+
+ const turn = store.telemetry.turns.get("t1");
+ expect(turn).toBeDefined();
+ expect(turn?.steps).toHaveLength(1);
+ const step = turn?.steps.find((s) => s.stepId === ("t1#0" as StepId));
+ expect(step).toBeDefined();
+ expect(step?.ttftMs).toBe(300);
+ expect(step?.decodeMs).toBe(700);
+ expect(step?.usage?.inputTokens).toBe(50);
+ expect(step?.usage?.outputTokens).toBe(20);
+
+ store.dispose();
+ });
+
it("handleDelta ignores a chat.delta for a different conversationId", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index b31cbf1..02d3c5a 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,9 +3,15 @@ import { render, screen } 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 { TelemetryState } from "../../core/telemetry";
+import { initialState } from "../../core/telemetry";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
+import TurnSummary from "./ui/TurnSummary.svelte";
+
+const emptyTelemetry = initialState();
+const noTurnId = null;
describe("ChatView", () => {
it("renders a message's text chunk", () => {
@@ -18,7 +24,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
@@ -34,7 +40,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("Hi there")).toBeInTheDocument();
expect(screen.getByText("Hello!")).toBeInTheDocument();
@@ -55,7 +61,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("read_file")).toBeInTheDocument();
const pre = screen.getByText((content, element) => {
@@ -80,7 +86,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("read_file")).toBeInTheDocument();
expect(screen.getByText("file contents here")).toBeInTheDocument();
@@ -96,7 +102,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Something failed");
@@ -112,7 +118,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("Rate limited")).toBeInTheDocument();
expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
@@ -128,7 +134,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
expect(screen.getByText("System context loaded")).toBeInTheDocument();
});
@@ -143,7 +149,7 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId } });
// In-flight chunks render at full opacity (no faded "disabled" look).
const wrapper = screen.getByText("Streaming...").closest("div");
@@ -151,7 +157,7 @@ describe("ChatView", () => {
});
it("renders empty transcript", () => {
- render(ChatView, { props: { chunks: [] } });
+ render(ChatView, { props: { chunks: [], telemetry: emptyTelemetry, currentTurnId: noTurnId } });
const log = screen.getByRole("log");
expect(log).toBeInTheDocument();
@@ -199,7 +205,9 @@ describe("ChatView", () => {
},
];
- const { container } = render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, {
+ props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId },
+ });
// One DaisyUI list with two rows (one per call), not separate cards.
const lists = container.querySelectorAll("ul.list");
@@ -224,7 +232,9 @@ describe("ChatView", () => {
},
];
- const { container } = render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, {
+ props: { chunks, telemetry: emptyTelemetry, currentTurnId: noTurnId },
+ });
const collapse = container.querySelector(".collapse");
expect(collapse).not.toBeNull();
@@ -247,7 +257,9 @@ describe("ChatView", () => {
},
];
- const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
+ const { container, rerender } = render(ChatView, {
+ props: { chunks: streaming, telemetry: emptyTelemetry, currentTurnId: noTurnId },
+ });
// Streaming: "Thinking" + loading dots.
expect(screen.getByText("Thinking")).toBeInTheDocument();
@@ -269,6 +281,8 @@ describe("ChatView", () => {
provisional: false,
},
],
+ telemetry: emptyTelemetry,
+ currentTurnId: noTurnId,
});
// Completed: "Thoughts", no dots — and the open state survived the transition.
@@ -278,6 +292,118 @@ describe("ChatView", () => {
expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
expect(container).toHaveTextContent("hmm, all done");
});
+
+ it("assistant text shows step metrics footer when step-complete data is available", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Here is my answer" },
+ provisional: false,
+ },
+ ];
+
+ const telemetry: TelemetryState = {
+ turns: new Map([
+ [
+ "turn-1",
+ {
+ wallMs: 2500,
+ steps: [
+ {
+ stepId: "turn-1#0" as StepId,
+ genTotalMs: 1200,
+ decodeMs: 1000,
+ usage: { inputTokens: 100, outputTokens: 86 },
+ },
+ ],
+ },
+ ],
+ ]),
+ };
+
+ render(ChatView, { props: { chunks, telemetry, currentTurnId: "turn-1" } });
+
+ expect(screen.getByText("Here is my answer")).toBeInTheDocument();
+ expect(screen.getByText("1.2s")).toBeInTheDocument();
+ expect(screen.getByText("86 t/s")).toBeInTheDocument();
+ expect(screen.getByText("86 tok")).toBeInTheDocument();
+ });
+
+ it("does not show metrics footer when no step data exists", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Still streaming" },
+ provisional: true,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, telemetry: emptyTelemetry, currentTurnId: "turn-1" } });
+
+ expect(screen.getByText("Still streaming")).toBeInTheDocument();
+ expect(screen.queryByText("t/s")).toBeNull();
+ expect(screen.queryByText("tok")).toBeNull();
+ });
+});
+
+describe("TurnSummary", () => {
+ it("renders turn stats when telemetry has data", () => {
+ const telemetry: TelemetryState = {
+ turns: new Map([
+ [
+ "turn-1",
+ {
+ wallMs: 4200,
+ steps: [
+ {
+ stepId: "turn-1#0" as StepId,
+ genTotalMs: 2000,
+ decodeMs: 1500,
+ usage: { inputTokens: 500, outputTokens: 300 },
+ },
+ {
+ stepId: "turn-1#1" as StepId,
+ genTotalMs: 1800,
+ decodeMs: 1200,
+ usage: { inputTokens: 600, outputTokens: 200 },
+ },
+ ],
+ },
+ ],
+ ]),
+ };
+
+ render(TurnSummary, { props: { telemetry, turnId: "turn-1" } });
+
+ expect(screen.getByText("Turn")).toBeInTheDocument();
+ expect(screen.getByText("4.2s")).toBeInTheDocument();
+ expect(screen.getByText("Tokens")).toBeInTheDocument();
+ expect(screen.getByText("1,600")).toBeInTheDocument();
+ expect(screen.getByText("Output")).toBeInTheDocument();
+ expect(screen.getByText("500")).toBeInTheDocument();
+ expect(screen.getByText("Input")).toBeInTheDocument();
+ expect(screen.getByText("1,100")).toBeInTheDocument();
+ expect(screen.getByText("Steps")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByText("TPS")).toBeInTheDocument();
+ expect(screen.getByText("185 t/s")).toBeInTheDocument();
+ });
+
+ it("renders nothing when turnId is null", () => {
+ const { container } = render(TurnSummary, {
+ props: { telemetry: emptyTelemetry, turnId: null },
+ });
+ expect(container.querySelector(".stats")).toBeNull();
+ });
+
+ it("renders nothing when turn metrics not found", () => {
+ const { container } = render(TurnSummary, {
+ props: { telemetry: emptyTelemetry, turnId: "nonexistent" },
+ });
+ expect(container.querySelector(".stats")).toBeNull();
+ });
});
describe("Composer", () => {
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 3a078fb..6acda53 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,16 +1,27 @@
<script lang="ts">
import { groupRenderedChunks, type RenderedChunk } from "../index";
+ import type { TelemetryState } from "../../../core/telemetry";
+ import { stepMetrics, stepTps } from "../../../core/telemetry";
- let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+ interface Props {
+ chunks: readonly RenderedChunk[];
+ telemetry: TelemetryState;
+ currentTurnId: string | null;
+ }
+
+ let { chunks, telemetry, currentTurnId }: Props = $props();
const groups = $derived(groupRenderedChunks(chunks));
- // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
- // survives the provisional→committed (seq null → seq N) transition, so the
- // collapse's open/close state is NOT lost when a turn seals. (App isolates
- // these keys per conversation via {#key}.)
+ function formatMs(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
+ }
+
const rows = $derived.by(() => {
let thinking = 0;
+ let stepIdx = 0;
return groups.map((group, i) => {
let key: string;
if (group.kind === "tool-batch") {
@@ -22,14 +33,17 @@
} else {
key = `p${i}`;
}
- return { group, key };
+ const si = stepIdx;
+ if (group.kind === "tool-batch" || (group.kind === "single" && (group.chunk.chunk.type === "tool-call" || group.chunk.chunk.type === "tool-result"))) {
+ stepIdx++;
+ }
+ return { group, key, stepIdx: si };
});
});
</script>
-{#snippet chunkRow(rendered: RenderedChunk)}
+{#snippet chunkRow(rendered: RenderedChunk, sIdx: number)}
{#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
<div class="chat chat-start">
<div class="chat-bubble chat-bubble-primary">
{#if rendered.chunk.type === "text"}
@@ -38,9 +52,6 @@
</div>
</div>
{:else if rendered.chunk.type === "thinking"}
- <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
- (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
- while generating, then "Thoughts" with no dots once complete. -->
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
<div class="chat-bubble w-full bg-transparent">
<div class="collapse w-full rounded-box bg-base-200 text-sm">
@@ -58,14 +69,18 @@
</div>
</div>
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a regular (non-speech) card. Nested in the
- chat-start grid via a transparent, padding-stripped chat-bubble shim so
- the card inherits the same left offset as the bubble bodies. -->
+ {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined}
+ {@const toolDur = step?.toolDurationMs}
<div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
<div class="chat-bubble bg-transparent">
{#if rendered.chunk.type === "tool-call"}
<div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
- <strong>{rendered.chunk.toolName}</strong>
+ <div class="flex items-center gap-2">
+ <strong>{rendered.chunk.toolName}</strong>
+ {#if toolDur !== undefined && toolDur > 0}
+ <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
+ {/if}
+ </div>
<pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
</div>
{:else}
@@ -73,19 +88,43 @@
class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
class:text-error={rendered.chunk.isError}
>
- <strong>{rendered.chunk.toolName}</strong>
+ <div class="flex items-center gap-2">
+ <strong>{rendered.chunk.toolName}</strong>
+ {#if toolDur !== undefined && toolDur > 0}
+ <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
+ {/if}
+ </div>
<pre class="text-xs mt-1">{rendered.chunk.content}</pre>
</div>
{/if}
</div>
</div>
{:else}
- <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
- chat-start grid as the user bubble, so it inherits identical left spacing. -->
+ {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, sIdx) : undefined}
+ {@const tps = step ? stepTps(step) : undefined}
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
+ <ul class="list rounded-box text-sm">
+ <li class="list-row">
+ <p>{rendered.chunk.text}</p>
+ </li>
+ {#if step && (step.genTotalMs !== undefined || tps !== undefined || step.usage?.outputTokens !== undefined)}
+ <li class="list-row">
+ {#if step.genTotalMs !== undefined}
+ <span class="badge badge-ghost badge-xs">{formatMs(step.genTotalMs)}</span>
+ {/if}
+ <span>·</span>
+ {#if tps !== undefined}
+ <span class="badge badge-ghost badge-xs">{Math.round(tps)} t/s</span>
+ {/if}
+ <span>·</span>
+ {#if step.usage?.outputTokens !== undefined}
+ <span class="badge badge-ghost badge-xs">{step.usage.outputTokens} tok</span>
+ {/if}
+ </li>
+ {/if}
+ </ul>
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -102,20 +141,24 @@
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each rows as { group, key } (key)}
+ {#each rows as { group, key, stepIdx } (key)}
{#if group.kind === "single"}
- {@render chunkRow(group.chunk)}
+ {@render chunkRow(group.chunk, stepIdx)}
{:else}
- <!-- Batched tool calls (one step): a single bubble holding a DaisyUI list,
- one row per call paired with its result. Same chat-start grid shim as
- the single tool card so it lines up with the other messages. -->
+ {@const step = currentTurnId ? stepMetrics(telemetry, currentTurnId, stepIdx) : undefined}
+ {@const toolDur = step?.toolDurationMs}
<div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
<div class="chat-bubble bg-transparent">
<ul class="list w-fit max-w-full rounded-box bg-base-200 text-sm">
{#each group.entries as entry (entry.call.toolCallId)}
<li class="list-row">
<div>
- <strong>{entry.call.toolName}</strong>
+ <div class="flex items-center gap-2">
+ <strong>{entry.call.toolName}</strong>
+ {#if toolDur !== undefined && toolDur > 0}
+ <span class="badge badge-ghost badge-xs ml-auto">{formatMs(toolDur)}</span>
+ {/if}
+ </div>
<pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre>
{#if entry.result}
<pre
diff --git a/src/features/chat/ui/TurnSummary.svelte b/src/features/chat/ui/TurnSummary.svelte
new file mode 100644
index 0000000..eedb0cc
--- /dev/null
+++ b/src/features/chat/ui/TurnSummary.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ import type { TelemetryState } from "../../../core/telemetry";
+ import {
+ stepCount,
+ totalInputTokens,
+ totalOutputTokens,
+ turnMetrics,
+ turnTps,
+ } from "../../../core/telemetry";
+
+ interface Props {
+ telemetry: TelemetryState;
+ turnId: string | null;
+ }
+
+ let { telemetry, turnId }: Props = $props();
+
+ function formatMs(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
+ }
+
+ const stats = $derived.by(() => {
+ if (turnId === null) return null;
+ const metrics = turnMetrics(telemetry, turnId);
+ if (metrics === undefined) return null;
+
+ const items: { label: string; value: string }[] = [];
+
+ if (metrics.wallMs !== undefined) {
+ items.push({ label: "Turn", value: formatMs(metrics.wallMs) });
+ }
+
+ const outTokens = totalOutputTokens(telemetry, turnId);
+ const inTokens = totalInputTokens(telemetry, turnId);
+ if (outTokens !== undefined || inTokens !== undefined) {
+ const total = (outTokens ?? 0) + (inTokens ?? 0);
+ items.push({ label: "Tokens", value: total.toLocaleString() });
+ }
+ if (outTokens !== undefined) {
+ items.push({ label: "Output", value: outTokens.toLocaleString() });
+ }
+ if (inTokens !== undefined) {
+ items.push({ label: "Input", value: inTokens.toLocaleString() });
+ }
+
+ const count = stepCount(telemetry, turnId);
+ if (count > 0) {
+ items.push({ label: "Steps", value: String(count) });
+ }
+
+ const tps = turnTps(telemetry, turnId);
+ if (tps !== undefined) {
+ items.push({ label: "TPS", value: `${Math.round(tps)} t/s` });
+ }
+
+ return items;
+ });
+</script>
+
+{#if stats !== null}
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="stats stats-vertical lg:stats-horizontal">
+ {#each stats as stat}
+ <div class="stat">
+ <div class="stat-title">{stat.label}</div>
+ <div class="stat-value text-sm">{stat.value}</div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+{/if}