summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/TurnSummary.svelte
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
commit9a4fc0aa0b56e7bb083ecc615647ed8f6b2ef4c2 (patch)
tree1fd5b4081736cf48c7b916abcdbeab267b4322d6 /src/features/chat/ui/TurnSummary.svelte
parentdcf5a2993c9cbeb266f2629cf33fbc43f52c7ca8 (diff)
downloaddispatch-web-9a4fc0aa0b56e7bb083ecc615647ed8f6b2ef4c2.tar.gz
dispatch-web-9a4fc0aa0b56e7bb083ecc615647ed8f6b2ef4c2.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/ui/TurnSummary.svelte')
-rw-r--r--src/features/chat/ui/TurnSummary.svelte75
1 files changed, 75 insertions, 0 deletions
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}