summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/index.ts3
-rw-r--r--src/features/chat/ports.ts9
-rw-r--r--src/features/chat/store.svelte.ts29
-rw-r--r--src/features/chat/store.test.ts308
-rw-r--r--src/features/chat/test-helpers.ts40
-rw-r--r--src/features/chat/ui.test.ts219
-rw-r--r--src/features/chat/ui/ChatView.svelte54
7 files changed, 649 insertions, 13 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 4f2091a..ae3e1f8 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,6 +1,7 @@
export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks";
export { groupRenderedChunks } from "../../core/chunks";
-export type { ChatTransport, HistorySync } from "./ports";
+export type { TurnMetricsEntry } from "../../core/metrics";
+export type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 07943c7..e28ebf6 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,4 +1,8 @@
-import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type {
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
/** Injected transport port — sends chat messages to the server. */
export interface ChatTransport {
@@ -10,3 +14,6 @@ export type HistorySync = (
conversationId: string,
sinceSeq: number,
) => Promise<ConversationHistoryResponse>;
+
+/** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */
+export type MetricsSync = (conversationId: string) => Promise<ConversationMetricsResponse>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 1d8ab17..f4ad07b 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -13,20 +13,29 @@ import {
selectChunks,
selectMessages,
} from "../../core/chunks";
+import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectOrderedTurnMetrics,
+} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
readonly conversationId: string;
readonly model?: string;
readonly transport: ChatTransport;
readonly historySync: HistorySync;
+ readonly metricsSync: MetricsSync;
readonly cache: ConversationCache;
}
export interface ChatStore {
readonly messages: readonly ChatMessage[];
readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
@@ -39,6 +48,7 @@ export interface ChatStore {
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
let transcript = $state<TranscriptState>(initialState());
+ let metrics = $state<MetricsState>(initialMetricsState());
let _pendingSync = $state(false);
let _error = $state<string | null>(null);
let _model = $state<string | undefined>(deps.model);
@@ -60,6 +70,17 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
}
+ async function syncMetrics(): Promise<void> {
+ if (disposed) return;
+ try {
+ const res = await deps.metricsSync(deps.conversationId);
+ metrics = applyDurableMetrics(metrics, res.turns);
+ } catch {
+ // Metrics fetch failure must not block history sync or throw;
+ // live-folded metrics remain intact.
+ }
+ }
+
return {
get messages(): readonly ChatMessage[] {
return selectMessages(transcript);
@@ -67,6 +88,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get chunks(): readonly RenderedChunk[] {
return selectChunks(transcript);
},
+ get turnMetrics(): readonly TurnMetricsEntry[] {
+ return selectOrderedTurnMetrics(metrics);
+ },
get pendingSync(): boolean {
return _pendingSync;
},
@@ -89,8 +113,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return;
}
transcript = foldEvent(transcript, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
if (transcript.sealedTurnId !== null) {
void syncTail();
+ void syncMetrics();
}
},
@@ -115,6 +141,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
transcript = applyHistory(transcript, cached);
}
await syncTail();
+ await syncMetrics();
},
dispose(): void {
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 71781ac..1c99e7c 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,7 +1,12 @@
import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
-import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
const CONV_ID = "test-conv-1";
@@ -21,11 +26,13 @@ describe("createChatStore", () => {
it("folding a chat.delta updates messages", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -51,11 +58,13 @@ describe("createChatStore", () => {
it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -92,11 +101,13 @@ describe("createChatStore", () => {
it("send posts a chat.send with conversationId", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -114,12 +125,14 @@ describe("createChatStore", () => {
it("send posts a chat.send with model when set", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -134,11 +147,13 @@ describe("createChatStore", () => {
it("chat.error sets error", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -154,6 +169,7 @@ describe("createChatStore", () => {
it("load hydrates from cache then syncs the tail", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
// Pre-populate cache
@@ -166,6 +182,7 @@ describe("createChatStore", () => {
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -184,6 +201,7 @@ describe("createChatStore", () => {
it("load with empty cache still syncs", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
historySync.returnChunks = [makeStoredChunk(1, "assistant")];
@@ -192,6 +210,7 @@ describe("createChatStore", () => {
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -206,11 +225,13 @@ describe("createChatStore", () => {
it("error is cleared on successful sync", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -236,11 +257,13 @@ describe("createChatStore", () => {
it("dispose prevents further syncs", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -262,6 +285,7 @@ describe("createChatStore", () => {
it("overlapping syncs are guarded", async () => {
const transport = createFakeTransport();
const _historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
// Make the first sync slow
@@ -283,6 +307,7 @@ describe("createChatStore", () => {
conversationId: CONV_ID,
transport: transport.impl,
historySync: slowHistorySync,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -310,11 +335,13 @@ describe("createChatStore", () => {
it("handles tool-call and tool-result chunks", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -353,12 +380,14 @@ describe("createChatStore", () => {
it("setModel changes the model used by the next send", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -375,11 +404,13 @@ describe("createChatStore", () => {
it("setModel from undefined to a model", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -396,11 +427,13 @@ describe("createChatStore", () => {
it("handleDelta ignores a chat.delta for a different conversationId", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -424,11 +457,13 @@ describe("createChatStore", () => {
it("handleDelta ignores a chat.error for a different conversationId", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -442,11 +477,13 @@ describe("createChatStore", () => {
it("send optimistically shows the user message immediately", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -464,11 +501,13 @@ describe("createChatStore", () => {
it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
cache: cache.impl,
});
@@ -496,4 +535,271 @@ describe("createChatStore", () => {
store.dispose();
});
+
+ it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.turnMetrics).toHaveLength(0);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ durationMs: 1200,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
+ expect(entry?.steps[0]?.genTotalMs).toBe(800);
+ expect(entry?.total).not.toBeNull();
+ expect(entry?.total?.usage.inputTokens).toBe(100);
+ expect(entry?.total?.usage.outputTokens).toBe(50);
+ expect(entry?.total?.durationMs).toBe(1200);
+
+ store.dispose();
+ });
+
+ it("turnMetrics entry has total: null before done (progressive turn)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.total).toBeNull();
+
+ store.dispose();
+ });
+
+ it("metricsSync durable result overrides live by turnId", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold gives some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // Durable sync returns different numbers for the same turnId
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 200, outputTokens: 80 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80 },
+ genTotalMs: 400,
+ },
+ ],
+ },
+ ];
+
+ // Trigger metrics sync via turn-sealed
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Durable should now override live (syncMetrics is async, wait for it)
+ await vi.waitFor(() => {
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
+ });
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
+
+ store.dispose();
+ });
+
+ it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+
+ // Make the metrics sync reject
+ metricsSync.nextError = "metrics endpoint unavailable";
+
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Live metrics should still be intact
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // No error should have been thrown to the store
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("load calls metricsSync after history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 300, outputTokens: 100 },
+ durationMs: 900,
+ steps: [],
+ },
+ ];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ expect(metricsSync.calls[0]).toBe(CONV_ID);
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
+
+ store.dispose();
+ });
});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index d37b59e..07dad26 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,6 +1,6 @@
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface FakeTransport {
readonly sent: import("@dispatch/transport-contract").ChatSendMessage[];
@@ -46,6 +46,44 @@ export function createFakeHistorySync(): FakeHistorySync {
};
}
+export interface FakeMetricsSync {
+ readonly calls: string[];
+ returnTurns: import("@dispatch/wire").TurnMetrics[];
+ /** If set, the next call will reject with this error. */
+ nextError: string | undefined;
+ readonly impl: MetricsSync;
+}
+
+export function createFakeMetricsSync(): FakeMetricsSync {
+ const calls: string[] = [];
+ let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
+ let nextError: string | undefined;
+ return {
+ calls,
+ get returnTurns() {
+ return returnTurns;
+ },
+ set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
+ returnTurns = v;
+ },
+ get nextError() {
+ return nextError;
+ },
+ set nextError(v: string | undefined) {
+ nextError = v;
+ },
+ impl: async (conversationId) => {
+ calls.push(conversationId);
+ if (nextError !== undefined) {
+ const err = nextError;
+ nextError = undefined;
+ throw new Error(err);
+ }
+ return { turns: returnTurns };
+ },
+ };
+}
+
export interface FakeCache {
readonly store: Map<string, StoredChunk[]>;
readonly impl: ConversationCache;
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index b31cbf1..4abf717 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,6 +3,7 @@ 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 { TurnMetricsEntry } from "../../core/metrics";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
@@ -278,6 +279,224 @@ describe("ChatView", () => {
expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
expect(container).toHaveTextContent("hmm, all done");
});
+
+ it("renders step and turn metrics as separate rows", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ durationMs: 1200,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
+ expect(screen.getByText(/turn · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
+ });
+
+ it("renders step-metrics inline after tool group", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { command: "ls" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: { type: "text", text: "Done!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 80, outputTokens: 20 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Both step-metrics and turn-metrics render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/turn · 100 tok/)).toBeInTheDocument();
+
+ // They are in separate elements (different rows)
+ const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
+ const turnEl = screen.getByText(/turn · 100 tok/).closest("div");
+ expect(stepEl).not.toBe(turnEl);
+ });
+
+ it("renders no metrics bubble when turnMetrics is empty", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics: [] } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.queryByText(/step 1/)).toBeNull();
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
+
+ it("omits null view values from metrics bubbles", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Response" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics rendered
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ // Turn metrics rendered
+ expect(screen.getByText(/turn · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ // No "null" or "undefined" in the DOM
+ expect(screen.queryByText("null")).toBeNull();
+ expect(screen.queryByText("undefined")).toBeNull();
+ });
+
+ it("renders step text but no turn total for a progressive turn (total: null)", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: null,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics should render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+
+ // Turn total should NOT render (total is null — turn still in progress)
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
});
describe("Composer", () => {
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 3a078fb..ba6e961 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,17 +1,33 @@
<script lang="ts">
import { groupRenderedChunks, type RenderedChunk } from "../index";
+ import { interleaveTurnMetrics, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry } from "../../../core/metrics";
- let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+ let {
+ chunks,
+ turnMetrics = [],
+ }: {
+ chunks: readonly RenderedChunk[];
+ turnMetrics?: readonly TurnMetricsEntry[];
+ } = $props();
const groups = $derived(groupRenderedChunks(chunks));
+ const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+
// 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}.)
- const rows = $derived.by(() => {
+ const keyedRows = $derived.by(() => {
let thinking = 0;
- return groups.map((group, i) => {
+ return rows.map((row, i) => {
+ if (row.kind === "step-metrics") {
+ return { row, key: `s${row.step.stepId}` };
+ }
+ if (row.kind === "turn-metrics") {
+ return { row, key: `m${row.turn.turnId}` };
+ }
+ const group = row.group;
let key: string;
if (group.kind === "tool-batch") {
key = `b${group.stepId}`;
@@ -22,7 +38,7 @@
} else {
key = `p${i}`;
}
- return { group, key };
+ return { row, key };
});
});
</script>
@@ -102,9 +118,31 @@
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each rows as { group, key } (key)}
- {#if group.kind === "single"}
- {@render chunkRow(group.chunk)}
+ {#each keyedRows as { row, key } (key)}
+ {#if row.kind === "step-metrics"}
+ {@const sv = viewStepMetrics(row.step, row.index)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="text-xs opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps} · {sv.tps}{/if}
+ {#if sv.genTotal} · {sv.genTotal}{/if}
+ </div>
+ </div>
+ </div>
+ {:else if row.kind === "turn-metrics"}
+ {@const turnView = viewTurnMetrics(row.turn)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="text-xs opacity-70">
+ turn · {turnView.tokensLabel} ({turnView.breakdown})
+ {#if turnView.tps} · {turnView.tps}{/if}
+ {#if turnView.duration} · {turnView.duration}{/if}
+ </div>
+ </div>
+ </div>
+ {:else if row.group.kind === "single"}
+ {@render chunkRow(row.group.chunk)}
{: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
@@ -112,7 +150,7 @@
<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)}
+ {#each row.group.entries as entry (entry.call.toolCallId)}
<li class="list-row">
<div>
<strong>{entry.call.toolName}</strong>