summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 08:27:26 +0900
committerAdam Malczewski <[email protected]>2026-10-15 08:27:26 +0900
commit8676bd29452f7539622901cb5ae703a3fdaebd07 (patch)
tree8472958c36ff915045605ee671152cdfeda39c61 /src/features/chat/ui
parenta8b15ac6a495eae27577e6161f320574bcf795f0 (diff)
downloaddispatch-web-8676bd29452f7539622901cb5ae703a3fdaebd07.tar.gz
dispatch-web-8676bd29452f7539622901cb5ae703a3fdaebd07.zip
feat(tool-call-cards): pair calls and results in summarized collapsible bubbles
Diffstat (limited to 'src/features/chat/ui')
-rw-r--r--src/features/chat/ui/ChatView.svelte81
-rw-r--r--src/features/chat/ui/ChatView.test.ts180
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte2
-rw-r--r--src/features/chat/ui/ToolCard.svelte111
4 files changed, 296 insertions, 78 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 5e4513c..18fea7c 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -11,6 +11,7 @@
} from "../../../core/metrics";
import { Markdown } from "../../markdown";
import LazyCollapse from "./LazyCollapse.svelte";
+ import ToolCard from "./ToolCard.svelte";
const badgeClass = {
success: "badge-success",
@@ -73,6 +74,7 @@
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
// survives the provisional→committed (seq null → seq N) transition, so the
// collapse's open/close state is NOT lost when a turn seals. The ordinal
@@ -89,8 +91,8 @@
}
const group = row.group;
let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
+ if (group.kind === "tool") {
+ key = `t${group.entry.toolCallId}`;
} else if (group.chunk.chunk.type === "thinking") {
key = `think${thinking++}`;
} else if (group.chunk.seq != null) {
@@ -103,22 +105,6 @@
});
</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
@@ -158,36 +144,6 @@
</LazyCollapse>
</div>
</div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <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"}
- {@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}
- {@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}
- {/snippet}
- <pre class="max-h-96 overflow-auto text-xs">{res.content}</pre>
- </LazyCollapse>
- {/if}
- </div>
- </div>
{:else}
<!-- Assistant text / system / error: an INVISIBLE speech bubble — same
chat-start grid as the user bubble, so it inherits identical left spacing. -->
@@ -287,31 +243,10 @@
{:else if row.group.kind === "single"}
{@render chunkRow(row.group.chunk)}
{:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry, entryIndex (entryIndex)}
- <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}
- {/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>
+ <!-- Each call owns one bubble; the matching result is folded into its card. -->
+ <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble min-w-0 w-full bg-transparent">
+ <ToolCard entry={row.group.entry} />
</div>
</div>
{/if}
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index fcac312..d12088b 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -19,7 +19,7 @@ async function openAllCollapses(container: HTMLElement): Promise<void> {
// 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", async () => {
+ it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -96,7 +96,7 @@ describe("ChatView — duplicate toolCallIds", () => {
},
provisional: false,
},
- // Duplicate results too — absorbed into the single deduped batch.
+ // Duplicate results too — absorbed into the corresponding cards.
{
seq: 7,
role: "tool",
@@ -128,10 +128,11 @@ describe("ChatView — duplicate toolCallIds", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- // No each_key_duplicate crash. Batched calls render as collapsible cards —
+ // No each_key_duplicate crash. Calls render as independent collapsible cards —
// exactly ONE per unique toolCallId (deduped), not 4.
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
// Both unique tool names shown; each result rendered once (deduped).
expect(screen.getByText("read_file")).toBeInTheDocument();
@@ -161,7 +162,7 @@ describe("ChatView — lazy collapse bodies", () => {
expect(screen.getByText("run_shell")).toBeInTheDocument();
expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
- const box = screen.getByRole("checkbox", { name: "Toggle tool result" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
await userEvent.click(box);
expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
@@ -170,3 +171,174 @@ describe("ChatView — lazy collapse bodies", () => {
expect(container.querySelectorAll(".chat-row")).toHaveLength(1);
});
});
+
+function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] {
+ const call: RenderedChunk = {
+ seq: committed ? 1 : null,
+ role: "assistant",
+ provisional: !committed,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ input: { command: "bun run test" },
+ },
+ };
+ return content === null
+ ? [call]
+ : [
+ call,
+ {
+ seq: committed ? 2 : null,
+ role: "tool",
+ provisional: !committed,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ content,
+ isError,
+ },
+ },
+ ];
+}
+
+describe("ChatView — tool cards", () => {
+ it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => {
+ const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(container.querySelectorAll(".collapse")).toHaveLength(1);
+ expect(screen.getByText("bun run test")).toBeInTheDocument();
+ expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
+ expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ expect(box).not.toBeChecked();
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ expect(screen.getByText("first second")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
+ });
+ it("transitions pending → result → sealed without losing open state", async () => {
+ const { container, rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-spinner");
+ expect(container.querySelector(".collapse-title")).not.toHaveTextContent("→");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ expect(screen.getByText("running…")).toBeInTheDocument();
+ await rerender({ chunks: shellChunks("done") });
+ expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ await rerender({ chunks: shellChunks("done", false, true) });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("done")).toBeInTheDocument();
+ });
+ it("keeps errors collapsed with a failure icon and tints the expanded output", async () => {
+ const { container } = render(ChatView, {
+ props: { chunks: shellChunks("Permission denied", true) },
+ });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ expect(box).not.toBeChecked();
+ expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
+ expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
+ expect(screen.getByText("→ failed · Permission denied")).toHaveClass("text-error");
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error");
+ });
+ it("renders an orphan result as the same card and retains state when its call is paged back in", async () => {
+ const chunks = shellChunks("contents", false, true);
+ const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(screen.getByText("call unavailable")).toBeInTheDocument();
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ expect(screen.getByText(/Call unavailable \(earlier history unloaded\)/)).toBeInTheDocument();
+ expect(screen.getByText("contents")).toBeInTheDocument();
+ await rerender({ chunks });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ });
+ it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
+ const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ await userEvent.click(box);
+ const response = screen.getByRole("region", { name: "Response" });
+ expect(response).toHaveTextContent("output-1");
+ expect(response).toHaveTextContent("output-6");
+ expect(response).not.toHaveTextContent("output-7");
+ expect(response).toHaveTextContent("output-8");
+ expect(response).toHaveTextContent("output-13");
+ const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ expect(response).toHaveTextContent("output-7");
+ expect(response.querySelectorAll("pre")).toHaveLength(1);
+ expect(response.querySelector("pre")?.textContent).toBe(output);
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" }));
+ expect(response).not.toHaveTextContent("output-7");
+ expect(box).toBeChecked();
+ // The separate output-fold state also survives sealing.
+ await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" }));
+ expect(response).toHaveTextContent("output-7");
+ await rerender({ chunks: shellChunks(output, false, true) });
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(response).toHaveTextContent("output-7");
+ });
+ it("shows 12 output lines without an inner toggle", async () => {
+ const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
+ render(ChatView, { props: { chunks: shellChunks(output) } });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" }));
+ expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
+ expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
+ });
+ it("renders edit details and treats tool output as text, not executable markup", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ input: { path: "a.ts", oldString: "before", newString: "after\nsecond" },
+ },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>',
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("+2 −1")).toBeInTheDocument();
+ expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
+ expect(screen.getByText("before")).toHaveClass("text-error");
+ expect(screen.getByText("after second")).toHaveClass("text-success");
+ expect(container.querySelector("script")).toBeNull();
+ expect(container).toHaveTextContent("<script>alert(1)</script>");
+ });
+});
diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte
index 0f66451..912adb4 100644
--- a/src/features/chat/ui/LazyCollapse.svelte
+++ b/src/features/chat/ui/LazyCollapse.svelte
@@ -33,7 +33,7 @@
<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}">
+ <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}">
{@render title()}
</div>
<div class="collapse-content">
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
new file mode 100644
index 0000000..8c6e325
--- /dev/null
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -0,0 +1,111 @@
+<script lang="ts">
+ import type { ToolEntry } from "../index";
+ import { viewToolEntry } from "../logic/tool-summary";
+ import LazyCollapse from "./LazyCollapse.svelte";
+
+ let { entry }: { entry: ToolEntry } = $props();
+ const card = $derived(viewToolEntry(entry));
+ let showAll = $state(false);
+</script>
+
+<LazyCollapse label={`Toggle tool call ${card.name}`}>
+ {#snippet title()}
+ {#if card.status === "pending"}
+ <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="shrink-0 text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span class="shrink-0 text-success" aria-label="Succeeded">✓</span>
+ {/if}
+ <svg
+ class="h-4 w-4 shrink-0 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"
+ aria-hidden="true"
+ >
+ <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 class="max-w-[35%] shrink-0 truncate" title={card.name}>{card.name}</span>
+ <span class="min-w-0 flex-1 truncate opacity-70" title={card.callSummary}
+ >{card.callSummary}</span
+ >
+ {#each card.callBadges as badge}
+ <span class="shrink-0 text-xs opacity-70">{badge}</span>
+ {/each}
+ {#if card.resultSummary !== null}
+ <span
+ class="max-w-[40%] shrink-0 truncate text-xs"
+ class:text-error={card.status === "error"}
+ title={card.resultSummary}
+ >
+ → {card.resultSummary}
+ </span>
+ {/if}
+ {/snippet}
+ <div class="min-w-0 space-y-3 text-xs">
+ <section aria-label="Call">
+ <div class="mb-1 font-medium opacity-60">Call</div>
+ {#if card.callDetail.kind === "command"}
+ <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
+ {:else if card.callDetail.kind === "edit"}
+ <div class="mb-1 break-all font-mono">{card.callDetail.path}</div>
+ <div class="space-y-1">
+ <div class="opacity-60">− old</div>
+ <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre>
+ <div class="opacity-60">+ new</div>
+ <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre>
+ </div>
+ {:else}
+ <dl class="space-y-1">
+ {#each card.callDetail.fields as field}
+ <div>
+ <dt class="opacity-60">{field.key}</dt>
+ <dd>
+ <pre
+ class="overflow-x-auto"
+ class:whitespace-pre-wrap={!field.multiline}>{field.value}</pre>
+ </dd>
+ </div>
+ {/each}
+ </dl>
+ {/if}
+ </section>
+ <section aria-label="Response">
+ <div class="mb-1 font-medium opacity-60">Response</div>
+ {#if card.output === null}
+ <span class="opacity-60">running…</span>
+ {:else}
+ <div class:text-error={card.output.isError}>
+ {#if card.output.kind === "full"}
+ <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
+ {:else}
+ {#if showAll}
+ <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {:else}
+ <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
+ {/if}
+ <button
+ class="btn btn-ghost btn-xs my-1"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
+ </button>
+ {#if !showAll}
+ <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+</LazyCollapse>