summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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
6 files changed, 320 insertions, 110 deletions
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>