From 551a7a050e8cd25e795026a1060c9d5b5ea028bf Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 10 Oct 2026 01:36:39 +0900 Subject: perf(chat): fast open of long in-flight turns - Batch live chat events: fold a burst in one pass per animation frame (rAF + timeout fallback for hidden tabs) instead of one reactive cycle per WS frame. Flushed before send/resync to keep ordering. - De-dup the late-join replay against committed history: provisional chunks of the in-flight turn already persisted (CR-6 per-step persistence) are hidden by aligned-prefix matching (fail-safe: mismatch shows, never hides). - Memoize chunks/turnMetrics getters by state identity. - LazyCollapse: tool/thinking card bodies mount only on first open. - content-visibility:auto on transcript rows. - scripts/probe-replay.ts + bench-pure.ts for measuring the replay path. --- scripts/bench-pure.ts | 71 +++++++++++++++ scripts/probe-replay.ts | 36 ++++++++ src/app.css | 10 +++ src/app/App.test.ts | 1 + src/app/store.svelte.ts | 29 +++++++ src/app/store.test.ts | 2 + src/core/chunks/selectors.test.ts | 145 +++++++++++++++++++++++++++++++ src/core/chunks/selectors.ts | 83 ++++++++++++++++-- src/features/chat/store.svelte.ts | 91 ++++++++++++++++--- src/features/chat/store.test.ts | 83 ++++++++++++++++++ src/features/chat/ui.test.ts | 26 ++++-- src/features/chat/ui/ChatView.svelte | 144 ++++++++++++------------------ src/features/chat/ui/ChatView.test.ts | 42 ++++++++- src/features/chat/ui/LazyCollapse.svelte | 44 ++++++++++ 14 files changed, 692 insertions(+), 115 deletions(-) create mode 100644 scripts/bench-pure.ts create mode 100644 scripts/probe-replay.ts create mode 100644 src/core/chunks/selectors.test.ts create mode 100644 src/features/chat/ui/LazyCollapse.svelte diff --git a/scripts/bench-pure.ts b/scripts/bench-pure.ts new file mode 100644 index 0000000..0bff5a1 --- /dev/null +++ b/scripts/bench-pure.ts @@ -0,0 +1,71 @@ +/** + * Bench the PURE pipeline a tab runs when opening a conversation mid-turn: + * history fetch → applyHistory → fold every replayed event (one render-pass + * worth of derivations per event, like the UI does) . + * bun scripts/bench-pure.ts [scale] + * `scale` synthesizes a longer turn by repeating the replay's step events. + */ +import { + applyHistory, + foldEvent, + groupRenderedChunks, + initialState, + initialWindowSize, + selectChunks, + windowTranscript, +} from "../src/core/chunks/index.ts"; +import { interleaveTurnMetrics } from "../src/core/metrics/index.ts"; +import { renderMarkdown } from "../src/features/markdown/logic/markdown.ts"; + +const conv = process.argv[2] ?? ""; +const HTTP = process.env.PROBE_HTTP ?? "http://localhost:24991"; +const replay = JSON.parse(await Bun.file(`/tmp/replay-${conv}.json`).text()) as { type: string }[]; +const hist = (await (await fetch(`${HTTP}/conversations/${conv}?sinceSeq=0`)).json()) as { + chunks: { seq: number; turnId?: string; chunk: { type: string } }[]; +}; +const turnId = (replay.find((e) => e.type === "turn-start") as { turnId: string }).turnId; +const committedThisTurn = hist.chunks.filter((c) => c.turnId === turnId).length; +console.log( + `history chunks=${hist.chunks.length} (of which current turn=${committedThisTurn}), replay events=${replay.length}`, +); + +let state = windowTranscript( + applyHistory(initialState(), hist.chunks as never), + initialWindowSize(256), +); +let derive = 0; +let md = 0; +const t0 = performance.now(); +for (const ev of replay) { + state = foldEvent(state, ev as never); + // What each per-event render recomputes in ChatView: + const d0 = performance.now(); + const chunks = selectChunks(state); + interleaveTurnMetrics(groupRenderedChunks(chunks), []); + derive += performance.now() - d0; + const last = chunks[chunks.length - 1]; + if (last?.streaming && last.chunk.type === "text") { + const m0 = performance.now(); + renderMarkdown(last.chunk.text, { streaming: true }); + md += performance.now() - m0; + } +} +const total = performance.now() - t0; +const rendered = selectChunks(state); +const dupTool = new Map(); +for (const r of rendered) { + if (r.chunk.type === "tool-call" || r.chunk.type === "tool-result") { + const k = `${r.chunk.type}:${(r.chunk as { toolCallId: string }).toolCallId}`; + dupTool.set(k, (dupTool.get(k) ?? 0) + 1); + } +} +const dups = [...dupTool.values()].filter((n) => n > 1).length; +console.log({ + renderedChunks: rendered.length, + committed: state.committed.length, + provisional: state.provisional.length, + duplicatedToolChunks: dups, + totalMs: Math.round(total), + deriveMs: Math.round(derive), + markdownFullRerenderMs: Math.round(md), +}); diff --git a/scripts/probe-replay.ts b/scripts/probe-replay.ts new file mode 100644 index 0000000..8a7faf9 --- /dev/null +++ b/scripts/probe-replay.ts @@ -0,0 +1,36 @@ +/** + * Probe: subscribe to a conversation over WS, capture the late-join replay, + * and report event counts / bytes / time-to-receive. Saves to /tmp/replay-.json. + * bun scripts/probe-replay.ts [wsUrl] + */ +const conv = process.argv[2]; +const url = process.argv[3] ?? "ws://localhost:24990"; +if (!conv) throw new Error("conversation id required"); + +const ws = new WebSocket(url); +const events: unknown[] = []; +let bytes = 0; +let t0 = 0; +let lastAt = 0; +ws.onopen = () => { + t0 = performance.now(); + ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: conv })); +}; +ws.onmessage = (ev) => { + const s = String(ev.data); + const m = JSON.parse(s); + if (m.type !== "chat.delta") return; + bytes += s.length; + lastAt = performance.now(); + events.push(m.event); +}; +setTimeout( + async () => { + ws.close(); + const counts: Record = {}; + for (const e of events as { type: string }[]) counts[e.type] = (counts[e.type] ?? 0) + 1; + console.log({ events: events.length, bytes, recvMs: Math.round(lastAt - t0), counts }); + await Bun.write(`/tmp/replay-${conv}.json`, JSON.stringify(events)); + }, + Number(process.env.WAIT_MS ?? 3000), +); diff --git a/src/app.css b/src/app.css index f5b269c..e5e2a99 100644 --- a/src/app.css +++ b/src/app.css @@ -127,3 +127,13 @@ body, body { overflow: hidden; } + +/* Transcript rows: skip layout + paint for rows scrolled out of view. A long + turn has hundreds of rows; without this every one is laid out on first + paint (and on every resize). `contain-intrinsic-size: auto ` makes the + browser remember each row's last rendered height, so scrolling back up + doesn't jump; the 64px is only the estimate for never-rendered rows. */ +.chat-row { + content-visibility: auto; + contain-intrinsic-size: auto 64px; +} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 026c5c7..26f5cdb 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -438,6 +438,7 @@ describe("App component interaction tests", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 73b19bc..afccced 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -648,6 +648,13 @@ export interface CreateAppStoreOptions { localStorage?: Storage; /** The workspace to scope to at boot (its URL slug); "default" if absent. */ workspaceId?: string; + /** + * How live chat events are batched before folding into a chat store. + * Default: once per animation frame (with a timeout fallback for hidden + * tabs). `null` = fold every event synchronously (tests that assert on the + * transcript immediately after feeding a frame). + */ + chatFlushScheduler?: ((flush: () => void) => void) | null; } function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync { @@ -871,6 +878,27 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // Deltas are routed here in addition to `chatStores`. const watchStores = new Map(); + /** + * Coalesce live chat events to at most one fold + render per animation + * frame (a late-join replay of a long turn arrives as a burst of WS frames; + * folding each separately re-derives the whole transcript per frame). A + * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded + * tab still applies its events (just less often). + */ + function scheduleChatFlush(flush: () => void): void { + let ran = false; + const run = (): void => { + if (ran) return; + ran = true; + flush(); + }; + if (typeof requestAnimationFrame === "function") requestAnimationFrame(run); + setTimeout(run, 250); + } + + const chatFlushScheduler = + opts?.chatFlushScheduler === undefined ? scheduleChatFlush : opts.chatFlushScheduler; + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ conversationId, @@ -890,6 +918,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // `setChatLimit`. chatLimit: normalizeChatLimit(chatLimitStore.load()), canUnload: () => (unloadGate === null ? true : unloadGate()), + ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}), onError: (context, err) => { reportError(`${context} (conversation: ${conversationId})`, err); }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 206eaa4..89e418e 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -509,6 +509,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -887,6 +888,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); diff --git a/src/core/chunks/selectors.test.ts b/src/core/chunks/selectors.test.ts new file mode 100644 index 0000000..57c1415 --- /dev/null +++ b/src/core/chunks/selectors.test.ts @@ -0,0 +1,145 @@ +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { applyHistory, foldEvent, initialState } from "./reducer"; +import { selectChunks } from "./selectors"; +import type { TranscriptState } from "./types"; + +const C = "conv"; +const T = "turn-1"; + +function fold(state: TranscriptState, events: readonly AgentEvent[]): TranscriptState { + return events.reduce(foldEvent, state); +} + +const turnStart: AgentEvent = { type: "turn-start", conversationId: C, turnId: T }; +const userMsg: AgentEvent = { type: "user-message", conversationId: C, turnId: T, text: "hi" }; +const think = (delta: string): AgentEvent => ({ + type: "reasoning-delta", + conversationId: C, + turnId: T, + delta, +}); +const text = (delta: string): AgentEvent => ({ + type: "text-delta", + conversationId: C, + turnId: T, + delta, +}); +const call = (id: string, step = 0): AgentEvent => ({ + type: "tool-call", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + input: {}, +}); +const result = (id: string, step = 0): AgentEvent => ({ + type: "tool-result", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + content: "ok", + isError: false, +}); + +/** The persisted chunks for steps 0 (user + think + call/result). */ +const committedStep0: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: T }, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + input: {}, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, + { + seq: 4, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + content: "ok", + isError: false, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, +]; + +describe("selectChunks — de-dup of a late-join replay against committed history", () => { + it("hides replayed provisional chunks already persisted for the in-flight turn", () => { + // Fresh load mid-turn: history has step 0; the replay re-delivers step 0 + // and then step 1 (still streaming). + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [ + userMsg, + turnStart, + think("pl"), + think("an"), + call("a"), + result("a"), + text("now"), + ]); + + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "tool-call"], + [4, "tool-result"], + [null, "text"], + ]); + expect(rendered[4]?.streaming).toBe(true); + }); + + it("keeps provisional chunks beyond the committed run", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered[4]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "b" }); + expect(rendered[4]?.provisional).toBe(true); + }); + + it("hides the accumulating chunk once its content is committed", () => { + const committed: StoredChunk[] = [ + ...committedStep0, + { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T }, + ]; + let s = applyHistory(initialState(), committed); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered.every((r) => !r.provisional)).toBe(true); + }); + + it("stops de-duping at the first mismatch (never hides divergent content)", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]); + const rendered = selectChunks(s); + // user echo matched (1 hidden); everything from the mismatch on is shown. + expect(rendered.filter((r) => r.provisional)).toHaveLength(3); + }); + + it("does nothing when the committed tail belongs to another turn", () => { + const other = committedStep0.map((c) => ({ ...c, turnId: "older" })); + let s = applyHistory(initialState(), other); + s = fold(s, [userMsg, turnStart, think("plan")]); + expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(2); + }); + + it("is the identity when there is no committed history", () => { + const s = fold(initialState(), [userMsg, turnStart, text("hello")]); + expect(selectChunks(s)).toHaveLength(2); + }); +}); diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 8b265a5..d32a628 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,12 +1,83 @@ -import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; -import type { RenderedChunk, TranscriptState } from "./types"; +import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; +import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types"; + +/** + * Whether a provisional chunk is the same content as a committed chunk at the + * same position of the same turn (identity for de-dup, not deep equality). + * Tool chunks match by `toolCallId`; text-bearing chunks by exact text. + */ +function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean { + if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false; + const a = p.chunk; + const b = c.chunk; + switch (a.type) { + case "tool-call": + case "tool-result": + return a.toolCallId === (b as typeof a).toolCallId; + case "text": + case "thinking": + case "system": + return a.text === (b as typeof a).text; + case "error": + return a.message === (b as typeof a).message; + case "image": + return true; + } +} + +/** + * How many LEADING provisional chunks are already backed by committed chunks. + * + * The backend persists each step of an in-flight turn as it completes (CR-6), + * so a client that opens a conversation mid-turn gets those steps from history + * AND again from the late-join event replay (as provisional chunks). Both + * carry the turn's chunks in the same order, so the provisional run of turn T + * is aligned position-by-position with the committed run of turn T. Skip the + * aligned prefix; stop at the first mismatch (fail-safe: a misalignment shows + * a duplicate, never hides content). + * + * Also reports whether the ACCUMULATING chunk is already committed (the step + * completed + history synced, but the next live event hasn't flushed it yet). + */ +function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } { + const first = state.provisional[0]; + const turnId = first?.turnId ?? state.accumulating?.turnId; + if (turnId === undefined) return { skip: 0, hideAccumulating: false }; + // The committed run for this turn (contiguous at the tail of `committed`). + let start = state.committed.length; + while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--; + const run = state.committed.length - start; + if (run === 0) return { skip: 0, hideAccumulating: false }; + + let skip = 0; + while (skip < state.provisional.length && skip < run) { + const p = state.provisional[skip]; + const c = state.committed[start + skip]; + if (p === undefined || c === undefined || p.turnId !== turnId || !sameChunk(p, c)) break; + skip++; + } + + let hideAccumulating = false; + const acc = state.accumulating; + if (acc !== null && skip === state.provisional.length && skip < run) { + const c = state.committed[start + skip]; + hideAccumulating = + c !== undefined && + c.role === "assistant" && + c.chunk.type === acc.kind && + c.chunk.text === acc.text; + } + return { skip, hideAccumulating }; +} /** * Select all chunks for rendering: committed first (seq order), - * then provisional (seq: null). + * then provisional (seq: null) — minus provisional chunks already backed by + * committed history (see `committedOverlap`). */ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { const result: RenderedChunk[] = []; + const { skip, hideAccumulating } = committedOverlap(state); for (const c of state.committed) { result.push( c.turnId !== undefined @@ -14,7 +85,9 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { : { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }, ); } - for (const p of state.provisional) { + for (let i = skip; i < state.provisional.length; i++) { + const p = state.provisional[i]; + if (p === undefined) continue; result.push( p.turnId !== undefined ? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId } @@ -22,7 +95,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { ); } const acc = state.accumulating; - if (acc !== null) { + if (acc !== null && !hideAccumulating) { const chunk: Chunk = acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; result.push({ diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 45f1ed1..7f8e113 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -69,6 +69,15 @@ export interface ChatStoreDependencies { * `reportError` → full-screen error modal. Absent → errors are logged only. */ readonly onError?: (context: string, err: unknown) => void; + /** + * Schedules a deferred flush of buffered live events (e.g. once per animation + * frame). When provided, `handleDelta` QUEUES events and folds the whole batch + * in one pass on the scheduled callback — so a burst (the late-join replay on + * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE + * derivation + render instead of one per WS frame. Absent → every event is + * folded synchronously (the original behavior; used by tests). + */ + readonly scheduleFlush?: (flush: () => void) => void; } export interface ChatStore { @@ -195,6 +204,63 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let chatLimit = normalizeChatLimit(deps.chatLimit); + // Memoized projections, keyed by state-object identity (the reducers are + // immutable, so identity == content). The getters are read several times per + // render (ChatPane + ChatView); returning the SAME array for an unchanged + // state keeps downstream `$derived`s (grouping, metrics placement) from + // recomputing on every read. Plain closures, not `$derived` — a store factory + // may run inside a component effect, which would own (and later destroy) it. + const memo = (fn: (s: S) => R): ((s: S) => R) => { + let lastIn: S | undefined; + let lastOut: R | undefined; + return (s) => { + if (lastOut === undefined || s !== lastIn) { + lastIn = s; + lastOut = fn(s); + } + return lastOut; + }; + }; + const chunksOf = memo(selectChunks); + const turnMetricsOf = memo(selectOrderedTurnMetrics); + + // Buffered live events awaiting a scheduled flush (see `scheduleFlush`). + let pendingEvents: ChatDeltaMessage["event"][] = []; + let flushScheduled = false; + + /** Fold every buffered event in one pass, then trim + sync once. */ + function flushEvents(): void { + flushScheduled = false; + if (disposed || pendingEvents.length === 0) return; + const batch = pendingEvents; + pendingEvents = []; + applyEvents(batch); + } + + function applyEvents(events: readonly ChatDeltaMessage["event"][]): void { + let t = transcript; + let m = metrics; + let sealed = false; + let failed = false; + for (const event of events) { + t = foldEvent(t, event); + m = foldMetricsEvent(m, event); + if (t.sealedTurnId !== null) sealed = true; + else if (event.type === "error") failed = true; + } + transcript = t; + metrics = m; + maybeTrim(); + if (sealed) { + void syncTail(); + void syncMetrics(); + } else if (failed) { + // A turn that FAILS never seals, but the backend still persists the + // metrics it accumulated — pull them so the turn gets its row. + void syncMetrics(); + } + } + /** * Enforce the chat limit after a transcript mutation — unless the injected * gate says the reader is scrolled up (then defer; the next mutation retries @@ -295,10 +361,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { return selectMessages(transcript); }, get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); + return chunksOf(transcript); }, get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); + return turnMetricsOf(metrics); }, get currentContextSize(): number | undefined { return selectCurrentContextSize(metrics); @@ -336,20 +402,19 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { if (msg.event.conversationId !== deps.conversationId) { return; } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); - } else if (msg.event.type === "error") { - // A turn that FAILS never seals, but the backend still persists the - // metrics it accumulated — pull them so the turn gets its row. - void syncMetrics(); + if (deps.scheduleFlush === undefined) { + applyEvents([msg.event]); + return; + } + pendingEvents.push(msg.event); + if (!flushScheduled) { + flushScheduled = true; + deps.scheduleFlush(flushEvents); } }, send(text: string, images?: readonly ImageInput[]): void { + flushEvents(); transcript = appendUserMessage(transcript, text, images); maybeTrim(); const msg: ChatSendMessage = { @@ -444,6 +509,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { // A turn may have sealed while we were disconnected (missed `turn-sealed`): // clear the now-stale spinner BEFORE re-subscribing, so a finished turn // doesn't spin forever. A still-running turn's replay re-asserts it. + flushEvents(); transcript = clearGenerating(transcript); void syncTail(); void syncMetrics(); @@ -451,6 +517,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { dispose(): void { disposed = true; + pendingEvents = []; }, }; } diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index aa5560f..f9b4b38 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1706,3 +1706,86 @@ describe("createChatStore", () => { expect(metricsSync.calls).toHaveLength(0); }); }); + +describe("createChatStore — batched delta application (scheduleFlush)", () => { + function setup() { + const scheduled: (() => void)[] = []; + const historySync = createFakeHistorySync(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: historySync.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + scheduleFlush: (flush) => scheduled.push(flush), + }); + const runScheduled = (): void => { + for (const f of scheduled.splice(0)) f(); + }; + return { store, scheduled, runScheduled, historySync }; + } + const text = (delta: string) => + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta }); + + it("queues a burst and applies it in ONE scheduled flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 500; i++) store.handleDelta(text("x")); + + expect(scheduled).toHaveLength(1); + expect(store.chunks).toHaveLength(0); + + runScheduled(); + expect(store.chunks).toHaveLength(1); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500)); + expect(store.generating).toBe(true); + store.dispose(); + }); + + it("schedules a new flush for events arriving after a flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + store.handleDelta(text("b")); + expect(scheduled).toHaveLength(1); + runScheduled(); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab"); + store.dispose(); + }); + + it("a sealed turn in the batch still triggers a history sync", async () => { + const { store, runScheduled, historySync } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(text("done")); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + expect(historySync.calls).toHaveLength(0); + runScheduled(); + await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0)); + store.dispose(); + }); + + it("send() flushes queued events first so the user echo lands after them", () => { + const { store } = setup(); + store.handleDelta(text("earlier reply")); + store.send("next question"); + const roles = store.chunks.map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + store.dispose(); + }); + + it("chunks getter returns a stable array for an unchanged transcript", () => { + const { store, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + expect(store.chunks).toBe(store.chunks); + store.dispose(); + }); + + it("a flush after dispose is a no-op", () => { + const { store, scheduled } = setup(); + store.handleDelta(text("a")); + store.dispose(); + for (const f of scheduled) f(); + expect(store.chunks).toHaveLength(0); + }); +}); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 189be2e..3c7fd0d 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -10,6 +10,14 @@ import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise { + const boxes = container.querySelectorAll('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + describe("ChatView", () => { it("renders a message's text chunk", () => { const chunks: RenderedChunk[] = [ @@ -82,7 +90,7 @@ describe("ChatView", () => { expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); }); - it("renders tool-call chunks", () => { + it("renders tool-call chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -97,7 +105,8 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); expect(screen.getByText("read_file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { @@ -106,7 +115,7 @@ describe("ChatView", () => { expect(pre).toBeInTheDocument(); }); - it("renders tool-result chunks", () => { + it("renders tool-result chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -122,7 +131,8 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); expect(screen.getByText("read_file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); @@ -200,7 +210,7 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + it("groups batched tool calls (shared stepId) into one DaisyUI list", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -242,6 +252,7 @@ describe("ChatView", () => { ]; const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); // Batched calls render as collapsible cards (one per call), not a list. const collapses = container.querySelectorAll(".collapse"); @@ -735,7 +746,7 @@ describe("ChatView", () => { expect(container.querySelector("img")?.getAttribute("src")).toBe(url); }); - it("renders a consult_vision tool call/result like any other tool", () => { + it("renders a consult_vision tool call/result like any other tool", async () => { // read_image is GONE — replaced by consult_vision (opens a vision-model // conversation, attaches the image + question, returns the answer). It is // a normal tool call: rendered generically by toolName. @@ -765,7 +776,8 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0); expect(screen.getByText("a red square on a white background")).toBeInTheDocument(); diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 8f153bf..5e4513c 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -10,6 +10,7 @@ type TurnMetricsEntry, } from "../../../core/metrics"; import { Markdown } from "../../markdown"; + import LazyCollapse from "./LazyCollapse.svelte"; const badgeClass = { success: "badge-success", @@ -102,6 +103,22 @@ }); +{#snippet wrench()} + +{/snippet} + {#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"}
-
- -
+ + {#snippet title()} {rendered.streaming ? "Thinking" : "Thoughts"} {#if rendered.streaming} {/if} -
-
-

{rendered.chunk.text}

-
-
+ {/snippet} +

{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}

+
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} @@ -151,61 +165,26 @@
{#if rendered.chunk.type === "tool-call"} -
- -
- - {rendered.chunk.toolName} -
-
-
{JSON.stringify(
-                  rendered.chunk.input,
-                  null,
-                  2,
-                )}
-
-
+ {@const call = rendered.chunk} + + {#snippet title()} + {@render wrench()} + {call.toolName} + {/snippet} +
{JSON.stringify(call.input, null, 2)}
+
{:else} -
- -
- - {rendered.chunk.toolName} - {#if rendered.chunk.isError} + {@const res = rendered.chunk} + + {#snippet title()} + {@render wrench()} + {res.toolName} + {#if res.isError} error {/if} -
-
-
{rendered.chunk.content}
-
-
+ {/snippet} +
{res.content}
+ {/if}
@@ -246,6 +225,9 @@ {/if} {#each keyedRows as { row, key } (key)} + +
{#if row.kind === "step-metrics"} {@const sv = viewStepMetrics(row.step, row.index)} {@const stepCache = viewCacheRate(row.step.usage)} @@ -311,47 +293,29 @@
{#each row.group.entries as entry, entryIndex (entryIndex)} -
- -
- + + {#snippet title()} + {@render wrench()} {entry.call.toolName} {#if entry.result?.isError} error {:else if entry.result === null} {/if} -
-
-
{JSON.stringify(
-                      entry.call.input,
-                      null,
-                      2,
-                    )}
- {#if entry.result} -
{entry.result.content}
- {/if} -
-
+ {/snippet} +
{JSON.stringify(entry.call.input, null, 2)}
+ {#if entry.result} +
{entry.result.content}
+ {/if} + {/each}
{/if} + {/each} {#if providerRetry} {@const rv = viewProviderRetry(providerRetry)} diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts index eb807c1..fcac312 100644 --- a/src/features/chat/ui/ChatView.test.ts +++ b/src/features/chat/ui/ChatView.test.ts @@ -1,16 +1,25 @@ import type { StepId } from "@dispatch/wire"; import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { RenderedChunk } from "../../../core/chunks"; import ChatView from "./ChatView.svelte"; +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise { + const boxes = container.querySelectorAll('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + // Defensive rendering: a conversation transcript can contain duplicate // `toolCallId`s (provider double-emission, persisted — e.g. PII_REDACTED_CONVERSATION_SHORT_01). The // grouped render path MUST dedupe them so the keyed {#each} never throws // Svelte `each_key_duplicate` (which aborts the reactive flush and makes the // tab appear dead). See fix-toolcall-duplicate-keys plan Part 1. describe("ChatView — duplicate toolCallIds", () => { - it("renders a tool batch with duplicated toolCallIds without throwing, showing one card per unique call", () => { + it("renders a tool batch with duplicated toolCallIds without throwing, showing one card per unique call", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -117,6 +126,7 @@ describe("ChatView — duplicate toolCallIds", () => { ]; const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); // No each_key_duplicate crash. Batched calls render as collapsible cards — // exactly ONE per unique toolCallId (deduped), not 4. @@ -130,3 +140,33 @@ describe("ChatView — duplicate toolCallIds", () => { expect(screen.getByText("contents-of-b")).toBeInTheDocument(); }); }); + +describe("ChatView — lazy collapse bodies", () => { + it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "x", + toolName: "run_shell", + content: "HUGE-OUTPUT", + isError: false, + }, + provisional: false, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("run_shell")).toBeInTheDocument(); + expect(screen.queryByText("HUGE-OUTPUT")).toBeNull(); + + const box = screen.getByRole("checkbox", { name: "Toggle tool result" }); + await userEvent.click(box); + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + + await userEvent.click(box); // close: body stays mounted (close animation) + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + expect(container.querySelectorAll(".chat-row")).toHaveLength(1); + }); +}); diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte new file mode 100644 index 0000000..0f66451 --- /dev/null +++ b/src/features/chat/ui/LazyCollapse.svelte @@ -0,0 +1,44 @@ + + +
+ +
+ {@render title()} +
+
+ {#if open || everOpened} + {@render children()} + {/if} +
+
-- cgit v1.2.3