summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 16:22:31 +0900
committerAdam Malczewski <[email protected]>2026-06-07 16:22:31 +0900
commit17bc0a2cdaeefd4974f785c907d3515a38d45363 (patch)
tree1834867d2f0ad5e82fbb985d7f602d8e1dffdb42 /src/features
parent635cb6de7342ac87b27243652b1ad3b3a133d6a4 (diff)
downloaddispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.tar.gz
dispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.zip
feat(chat): group batched tool calls into one DaisyUI list
Consume the backend's new stepId grouping key (wire/transport-contract 0.1.0 -> 0.2.0). foldEvent copies event.stepId onto live tool chunks so live and replay group identically. New pure selector groupRenderedChunks (core/chunks) folds a step's 2+ tool calls into one tool-batch group, pairing each call with its result by toolCallId; single/no-stepId calls stay as cards. ChatView renders a batch as a DaisyUI list (list-row per pair). Fixtures updated for the now-required event stepId.
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/index.ts3
-rw-r--r--src/features/chat/store.test.ts4
-rw-r--r--src/features/chat/ui.test.ts56
-rw-r--r--src/features/chat/ui/ChatView.svelte143
4 files changed, 145 insertions, 61 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index f1e8e29..4f2091a 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,4 +1,5 @@
-export type { RenderedChunk } from "../../core/chunks";
+export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks";
+export { groupRenderedChunks } from "../../core/chunks";
export type { ChatTransport, HistorySync } from "./ports";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index de60b14..71781ac 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,4 +1,4 @@
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers";
@@ -327,6 +327,7 @@ describe("createChatStore", () => {
toolCallId: "tc1",
toolName: "read_file",
input: { path: "/tmp/test.txt" },
+ stepId: "t1#0" as StepId,
}),
);
store.handleDelta(
@@ -338,6 +339,7 @@ describe("createChatStore", () => {
toolName: "read_file",
content: "file contents",
isError: false,
+ stepId: "t1#0" as StepId,
}),
);
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 2099257..43822a7 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1,3 +1,4 @@
+import type { StepId } from "@dispatch/wire";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -158,6 +159,61 @@ describe("ChatView", () => {
expect(log.children).toHaveLength(0);
});
+ it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ // One DaisyUI list with two rows (one per call), not separate cards.
+ const lists = container.querySelectorAll("ul.list");
+ expect(lists).toHaveLength(1);
+ expect(container.querySelectorAll("ul.list > li.list-row")).toHaveLength(2);
+
+ // Both call names + the available result are shown; the result is absorbed
+ // (no standalone tool-result card).
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ });
+
it("thinking <details> stays open across a streaming update", async () => {
const initial: RenderedChunk[] = [
{
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 0234852..60da571 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,70 +1,95 @@
<script lang="ts">
- import type { RenderedChunk } from "../index";
+ import { groupRenderedChunks, type RenderedChunk } from "../index";
let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+
+ const groups = $derived(groupRenderedChunks(chunks));
</script>
-<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)}
- {#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
- <div class="chat chat-start">
- <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}>
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {/if}
- </div>
+{#snippet chunkRow(rendered: RenderedChunk)}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}>
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {/if}
</div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Tool: a regular (non-speech) card. Nested in the chat-start grid via
- a transparent, padding-stripped chat-bubble shim so the card inherits
- the same left offset as the bubble bodies (no magic margin). -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
- <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}>
- {#if rendered.chunk.type === "tool-call"}
- <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- {:else}
- <div
- class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
- class:text-error={rendered.chunk.isError}
- >
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
- </div>
- {/if}
- </div>
+ </div>
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
+ <!-- Single tool call/result: a regular (non-speech) card. Nested in the
+ chat-start grid via a transparent, padding-stripped chat-bubble shim so
+ the card inherits the same left offset as the bubble bodies. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
+ <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}>
+ {#if rendered.chunk.type === "tool-call"}
+ <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm">
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
+ </div>
+ {:else}
+ <div
+ class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"
+ class:text-error={rendered.chunk.isError}
+ >
+ <strong>{rendered.chunk.toolName}</strong>
+ <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
+ </div>
+ {/if}
</div>
- {:else}
- <!-- Assistant / system / error: an INVISIBLE speech bubble — the same
- DaisyUI chat-start grid as the user bubble, so it inherits the
- identical left spacing (incl. the small leading gap). Transparent
- bg means no visible body and no visible tail; full width capped to
- a readable column. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div
- class="chat-bubble w-full bg-transparent"
- class:opacity-50={rendered.provisional}
- >
- {#if rendered.chunk.type === "text"}
+ </div>
+ {:else}
+ <!-- Assistant / system / error: an INVISIBLE speech bubble — same chat-start
+ grid as the user bubble, so it inherits identical left spacing. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent" class:opacity-50={rendered.provisional}>
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {:else if rendered.chunk.type === "thinking"}
+ <details>
+ <summary>Thinking</summary>
<p>{rendered.chunk.text}</p>
- {:else if rendered.chunk.type === "thinking"}
- <details>
- <summary>Thinking</summary>
- <p>{rendered.chunk.text}</p>
- </details>
- {:else if rendered.chunk.type === "error"}
- <div class="text-error" role="alert">
- {rendered.chunk.message}
- {#if rendered.chunk.code}
- <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
- {/if}
- </div>
- {:else if rendered.chunk.type === "system"}
- <div class="text-sm opacity-70">{rendered.chunk.text}</div>
- {/if}
+ </details>
+ {:else if rendered.chunk.type === "error"}
+ <div class="text-error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="text-sm opacity-70">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
+{/snippet}
+
+<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
+ {#each groups as group, i (group.kind === "tool-batch" ? `b${group.stepId}` : group.chunk.seq != null ? `c${group.chunk.seq}` : `p${i}`)}
+ {#if group.kind === "single"}
+ {@render chunkRow(group.chunk)}
+ {:else}
+ <!-- Batched tool calls (one step): a single bubble holding a DaisyUI list,
+ one row per call paired with its result. Same chat-start grid shim as
+ the single tool card so it lines up with the other messages. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0">
+ <div class="chat-bubble bg-transparent" class:opacity-50={group.provisional}>
+ <ul class="list w-fit max-w-full rounded-box bg-base-200 text-sm">
+ {#each group.entries as entry (entry.call.toolCallId)}
+ <li class="list-row">
+ <div>
+ <strong>{entry.call.toolName}</strong>
+ <pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre>
+ {#if entry.result}
+ <pre
+ class="text-xs mt-1"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </div>
+ </li>
+ {/each}
+ </ul>
</div>
</div>
{/if}