summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-10 01:36:39 +0900
committerAdam Malczewski <[email protected]>2026-10-10 01:36:39 +0900
commit551a7a050e8cd25e795026a1060c9d5b5ea028bf (patch)
treecba29eb5ada26778c09aea7cc767e2ddf665bce8
parentd8381e201cb1407a939b496642a0803aec58a47d (diff)
downloaddispatch-web-551a7a050e8cd25e795026a1060c9d5b5ea028bf.tar.gz
dispatch-web-551a7a050e8cd25e795026a1060c9d5b5ea028bf.zip
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.
-rw-r--r--scripts/bench-pure.ts71
-rw-r--r--scripts/probe-replay.ts36
-rw-r--r--src/app.css10
-rw-r--r--src/app/App.test.ts1
-rw-r--r--src/app/store.svelte.ts29
-rw-r--r--src/app/store.test.ts2
-rw-r--r--src/core/chunks/selectors.test.ts145
-rw-r--r--src/core/chunks/selectors.ts83
-rw-r--r--src/features/chat/store.svelte.ts91
-rw-r--r--src/features/chat/store.test.ts83
-rw-r--r--src/features/chat/ui.test.ts26
-rw-r--r--src/features/chat/ui/ChatView.svelte144
-rw-r--r--src/features/chat/ui/ChatView.test.ts42
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte44
14 files changed, 692 insertions, 115 deletions
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 <conversationId> [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<string, number>();
+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-<id>.json.
+ * bun scripts/probe-replay.ts <conversationId> [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<string, number> = {};
+ 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 <h>` 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<string, ChatStore>();
+ /**
+ * 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 = <S, R>(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<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.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 @@
});
</script>
+{#snippet wrench()}
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+{/snippet}
+
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -130,18 +147,15 @@
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">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <LazyCollapse label="Toggle thoughts">
+ {#snippet title()}
<span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
{#if rendered.streaming}
<span class="loading loading-dots loading-sm" aria-label="Generating"></span>
{/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
+ {/snippet}
+ <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p>
+ </LazyCollapse>
</div>
</div>
{:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
@@ -151,61 +165,26 @@
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
- <span>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- rendered.chunk.input,
- null,
- 2,
- )}</pre>
- </div>
- </div>
+ {@const call = rendered.chunk}
+ <LazyCollapse label="Toggle tool call">
+ {#snippet title()}
+ {@render wrench()}
+ <span>{call.toolName}</span>
+ {/snippet}
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(call.input, null, 2)}</pre>
+ </LazyCollapse>
{:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div
- class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
- class:text-error={rendered.chunk.isError}
- >
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
- <span>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
+ {@const res = rendered.chunk}
+ <LazyCollapse label="Toggle tool result" titleClass={res.isError ? "text-error" : ""}>
+ {#snippet title()}
+ {@render wrench()}
+ <span>{res.toolName}</span>
+ {#if res.isError}
<span class="badge badge-error badge-xs">error</span>
{/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
+ {/snippet}
+ <pre class="max-h-96 overflow-auto text-xs">{res.content}</pre>
+ </LazyCollapse>
{/if}
</div>
</div>
@@ -246,6 +225,9 @@
</div>
{/if}
{#each keyedRows as { row, key } (key)}
+ <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint
+ for rows outside the viewport (see app.css). -->
+ <div class="chat-row">
{#if row.kind === "step-metrics"}
{@const sv = viewStepMetrics(row.step, row.index)}
{@const stepCache = viewCacheRate(row.step.usage)}
@@ -311,47 +293,29 @@
<div class="chat-bubble w-full bg-transparent">
<div class="flex flex-col gap-1">
{#each row.group.entries as entry, entryIndex (entryIndex)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
+ <LazyCollapse label="Toggle tool call">
+ {#snippet title()}
+ {@render wrench()}
<span>{entry.call.toolName}</span>
{#if entry.result?.isError}
<span class="badge badge-error badge-xs">error</span>
{:else if entry.result === null}
<span class="loading loading-spinner loading-xs" aria-label="Running"></span>
{/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- entry.call.input,
- null,
- 2,
- )}</pre>
- {#if entry.result}
- <pre
- class="mt-1 max-h-96 overflow-auto text-xs"
- class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
+ {/snippet}
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre>
+ {#if entry.result}
+ <pre
+ class="mt-1 max-h-96 overflow-auto text-xs"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </LazyCollapse>
{/each}
</div>
</div>
</div>
{/if}
+ </div>
{/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<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.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 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+
+ /**
+ * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside
+ * `.collapse-content` is created until the user first opens it. A long turn
+ * has hundreds of collapsed tool/thinking cards, each holding a full JSON
+ * input / tool output / thought — building all of that DOM up front (only to
+ * be hidden) dominated first paint on slow devices. Once opened the body
+ * stays mounted, so the close animation and any inner scroll state work as
+ * before. Same markup + classes as the inline collapses it replaces.
+ */
+ let {
+ label,
+ titleClass = "",
+ title,
+ children,
+ }: {
+ /** aria-label of the toggle checkbox. */
+ label: string;
+ /** Extra classes on the `.collapse-title` row. */
+ titleClass?: string;
+ title: Snippet;
+ children: Snippet;
+ } = $props();
+
+ let open = $state(false);
+ let everOpened = $state(false);
+ $effect(() => {
+ if (open) everOpened = true;
+ });
+</script>
+
+<div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label={label} bind:checked={open} />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium {titleClass}">
+ {@render title()}
+ </div>
+ <div class="collapse-content">
+ {#if open || everOpened}
+ {@render children()}
+ {/if}
+ </div>
+</div>