diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 18:41:27 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 18:41:27 +0900 |
| commit | 9a4fc0aa0b56e7bb083ecc615647ed8f6b2ef4c2 (patch) | |
| tree | 1fd5b4081736cf48c7b916abcdbeab267b4322d6 /src/features/chat/ui/TurnSummary.svelte | |
| parent | dcf5a2993c9cbeb266f2629cf33fbc43f52c7ca8 (diff) | |
| download | dispatch-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.svelte | 75 |
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} |
