summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--.dispatch/transport-contract.reference.md10
-rw-r--r--.dispatch/wire.reference.md63
-rw-r--r--src/core/chunks/groups.test.ts125
-rw-r--r--src/core/chunks/groups.ts95
-rw-r--r--src/core/chunks/index.ts2
-rw-r--r--src/core/chunks/reducer.test.ts12
-rw-r--r--src/core/chunks/reducer.ts2
-rw-r--r--src/core/wire/conformance.test.ts4
-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
12 files changed, 448 insertions, 71 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 3a7a59c..fcc2cbf 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,9 +5,15 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
> this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes.
-> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract`
+> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes.
+> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/ui-contract`
> (see `ui-contract.reference.md`).
+>
+> **0.2.0 change (step grouping):** no shape change HERE — this contract's own types are
+> identical. It only re-exports the bumped `@dispatch/wire`, whose `AgentEvent` tool variants
+> now carry a required `stepId` and whose tool `Chunk`s carry an optional `stepId`. The
+> `chat.delta` events streamed over WS and the `ConversationHistoryResponse.chunks` you already
+> consume therefore now carry the step grouping key (see `wire.reference.md`).
## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205)
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index ccf07bd..ed95351 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,7 +4,13 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes.
+>
+> **0.2.0 change (step grouping):** `ToolCallChunk`/`ToolResultChunk` gained an OPTIONAL
+> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` gained a REQUIRED `stepId: StepId`.
+> A `StepId` is the per-step grouping key for batched/parallel tool calls — group by equality.
+> Live: read `event.stepId`. Replay: read `storedChunk.chunk.stepId` (NOT the envelope; absent on
+> pre-0.2.0 rows / non-tool chunks — tolerate absence). `StoredChunk` envelope is UNCHANGED.
```ts
/**
@@ -23,7 +29,16 @@ export type Role = "system" | "user" | "assistant" | "tool";
/** Opaque identifier for a turn (one user→assistant cycle). */
export type TurnId = string & { readonly __brand: "TurnId" };
-/** Opaque identifier for a step (one LLM round-trip within a turn). */
+/**
+ * Opaque identifier for a step (one LLM round-trip within a turn). It is the
+ * authoritative grouping key for the tool calls a model batches together in a
+ * single step (parallel/batched calls): every `tool-call`/`tool-result` event
+ * and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the
+ * same step carries the SAME `stepId`, so a client groups a batch purely by
+ * equality — identically on the live stream and in replayed history. Per-turn
+ * unique and gap-free in step order; treat it as opaque (do not parse it). The
+ * runtime derives it deterministically from the turn id + 0-based step index.
+ */
export type StepId = string & { readonly __brand: "StepId" };
/**
@@ -60,6 +75,18 @@ export interface ToolCallChunk {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
+ /**
+ * The step that produced this call — generation provenance stamped by the
+ * runtime when the model emits the call (NOT storage metadata like `seq`,
+ * which is why it lives on the chunk and travels with it through persistence
+ * and replay). Tool calls a model batches together in one step share the same
+ * `stepId`: the grouping key for rendering a parallel batch as one unit, and
+ * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional:
+ * absent on chunks reconstructed outside a turn and on rows persisted before
+ * this field existed, so a consumer must tolerate its absence (render
+ * ungrouped).
+ */
+ readonly stepId?: StepId;
}
/**
@@ -73,6 +100,15 @@ export interface ToolResultChunk {
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
+ /**
+ * The step that produced the originating call — equal to the `stepId` on the
+ * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result`
+ * AgentEvent, so a consumer groups a step's calls with their results.
+ * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`).
+ * Optional for the same reasons; `reconcile` copies it from the originating
+ * call onto a synthesized (interrupted) result.
+ */
+ readonly stepId?: StepId;
}
/** An error that occurred during generation or tool dispatch. */
@@ -107,9 +143,11 @@ export interface ChatMessage {
* sync cursor, assigned in append order) and records the `role` of the message
* it belongs to. This makes a flat seq-ordered stream both incrementally
* syncable ("give me chunks after seq N") and regroupable into messages by the
- * client. `chunk` is the pure content unit, unchanged — `Chunk` itself never
- * carries storage metadata (it is also passed to/from the provider, which has
- * no use for a cursor).
+ * client. `chunk` is the content unit — `Chunk` carries no storage/sync cursor
+ * (`seq` lives here on the envelope, not on the chunk, since it is assigned by
+ * the store and the provider has no use for it). A chunk MAY still carry
+ * generation provenance assigned at production time (e.g. a tool chunk's
+ * `stepId`), which is intrinsic to the content and so travels with it.
*/
export interface StoredChunk {
readonly seq: number;
@@ -184,6 +222,14 @@ export interface TurnToolCallEvent {
readonly type: "tool-call";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced this call. Tool calls a model batches together in
+ * one step share the same `stepId` — the grouping key for rendering a
+ * parallel batch as one unit. Matches the `stepId` on the matching
+ * `tool-result` event and on the persisted tool chunk
+ * (`StoredChunk.chunk.stepId`).
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
@@ -194,6 +240,13 @@ export interface TurnToolResultEvent {
readonly type: "tool-result";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced the originating call. Equal to the `stepId` on the
+ * matching `tool-call` event (same `toolCallId`) and on the persisted tool
+ * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with
+ * their results.
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly content: string;
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
new file mode 100644
index 0000000..fbfda83
--- /dev/null
+++ b/src/core/chunks/groups.test.ts
@@ -0,0 +1,125 @@
+import type { Role, StepId } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { groupRenderedChunks } from "./groups";
+import type { RenderedChunk } from "./types";
+
+const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({
+ seq,
+ role,
+ chunk: { type: "text", text: t },
+ provisional,
+});
+
+const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: id,
+ toolName: `tool-${id}`,
+ input: { id },
+ ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
+ },
+ provisional,
+});
+
+const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
+ seq,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: id,
+ toolName: `tool-${id}`,
+ content: `result-${id}`,
+ isError: false,
+ ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
+ },
+ provisional,
+});
+
+describe("groupRenderedChunks", () => {
+ it("returns no groups for an empty stream", () => {
+ expect(groupRenderedChunks([])).toEqual([]);
+ });
+
+ it("passes non-tool chunks through as single groups, in order", () => {
+ const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]);
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.kind === "single")).toBe(true);
+ });
+
+ it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => {
+ const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]);
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "single"]);
+ });
+
+ it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a"),
+ call(2, "b"),
+ result(3, "a"),
+ result(4, "b"),
+ ]);
+ expect(groups).toHaveLength(4);
+ expect(groups.every((g) => g.kind === "single")).toBe(true);
+ });
+
+ it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a", "s1"),
+ call(2, "b", "s1"),
+ result(3, "a", "s1"),
+ result(4, "b", "s1"),
+ ]);
+ expect(groups).toHaveLength(1);
+ const g = groups[0];
+ if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(g.stepId).toBe("s1");
+ expect(g.entries).toHaveLength(2);
+ expect(g.entries[0]?.call.toolCallId).toBe("a");
+ expect(g.entries[0]?.result?.content).toBe("result-a");
+ expect(g.entries[1]?.call.toolCallId).toBe("b");
+ expect(g.entries[1]?.result?.content).toBe("result-b");
+ });
+
+ it("positions the batch at the first call and keeps surrounding chunks in order", () => {
+ const groups = groupRenderedChunks([
+ text(1, "assistant", "before"),
+ call(2, "a", "s1"),
+ call(3, "b", "s1"),
+ result(4, "a", "s1"),
+ result(5, "b", "s1"),
+ text(6, "assistant", "after"),
+ ]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
+ });
+
+ it("marks the batch provisional when any of its calls/results is provisional", () => {
+ const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]);
+ const g = groups[0];
+ if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(g.provisional).toBe(true);
+ expect(g.entries).toHaveLength(2);
+ expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet)
+ });
+
+ it("batches one step while leaving a different single-call step ungrouped", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a", "s1"),
+ call(2, "b", "s1"),
+ call(3, "c", "s2"),
+ result(4, "a", "s1"),
+ result(5, "b", "s1"),
+ result(6, "c", "s2"),
+ ]);
+ expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]);
+ const batch = groups[0];
+ if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(batch.entries).toHaveLength(2);
+ // the s2 single call + its result remain as separate single groups
+ const singles = groups.slice(1);
+ expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call");
+ expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result");
+ });
+});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
new file mode 100644
index 0000000..6dc7e10
--- /dev/null
+++ b/src/core/chunks/groups.ts
@@ -0,0 +1,95 @@
+import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire";
+import type { RenderedChunk } from "./types";
+
+/**
+ * One tool call within a batch, paired with its result (matched by `toolCallId`).
+ * `result` is null while the call is still pending (no result chunk yet).
+ */
+export interface ToolBatchEntry {
+ readonly call: ToolCallChunk;
+ readonly result: ToolResultChunk | null;
+}
+
+/**
+ * A render group: either a single rendered chunk (rendered as today) or a batch
+ * of tool calls the model emitted together in one step (shared `stepId`), to be
+ * rendered as one grouped unit.
+ */
+export type RenderGroup =
+ | { readonly kind: "single"; readonly chunk: RenderedChunk }
+ | {
+ readonly kind: "tool-batch";
+ readonly stepId: string;
+ readonly entries: readonly ToolBatchEntry[];
+ readonly provisional: boolean;
+ };
+
+/**
+ * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId`
+ * (the backend's authoritative batch key) where the step has 2+ calls become one
+ * `tool-batch` group, positioned at the first call and pairing each call with its
+ * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on
+ * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0
+ * replay rows) and every non-tool chunk render as `single` groups, in order.
+ *
+ * Pure: input → output, no DOM, no Svelte.
+ */
+export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] {
+ // 1. Steps that batched 2+ tool calls.
+ const callsPerStep = new Map<string, number>();
+ for (const rc of rendered) {
+ if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) {
+ callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1);
+ }
+ }
+ const batchSteps = new Set<string>();
+ for (const [stepId, count] of callsPerStep) {
+ if (count >= 2) batchSteps.add(stepId);
+ }
+
+ // 2. toolCallIds belonging to a batch (so their results are absorbed), and a
+ // lookup of result chunks by toolCallId for pairing.
+ const batchCallIds = new Set<string>();
+ const resultByCallId = new Map<string, ToolResultChunk>();
+ for (const rc of rendered) {
+ const chunk = rc.chunk;
+ if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
+ batchCallIds.add(chunk.toolCallId);
+ } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) {
+ resultByCallId.set(chunk.toolCallId, chunk);
+ }
+ }
+
+ // 3. Emit groups in stream order; each batch lands at its first call.
+ const groups: RenderGroup[] = [];
+ const emittedSteps = new Set<string>();
+ for (const rc of rendered) {
+ const chunk = rc.chunk;
+
+ if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
+ const stepId = chunk.stepId;
+ if (emittedSteps.has(stepId)) continue;
+ emittedSteps.add(stepId);
+
+ const entries: ToolBatchEntry[] = [];
+ let provisional = false;
+ for (const inner of rendered) {
+ if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) {
+ const result = resultByCallId.get(inner.chunk.toolCallId) ?? null;
+ entries.push({ call: inner.chunk, result });
+ if (inner.provisional) provisional = true;
+ }
+ }
+ groups.push({ kind: "tool-batch", stepId, entries, provisional });
+ continue;
+ }
+
+ if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
+ continue; // absorbed into its batch
+ }
+
+ groups.push({ kind: "single", chunk: rc });
+ }
+
+ return groups;
+}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index 67739bc..0718c0d 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,3 +1,5 @@
+export type { RenderGroup, ToolBatchEntry } from "./groups";
+export { groupRenderedChunks } from "./groups";
export { appendUserMessage, applyHistory, foldEvent, initialState } from "./reducer";
export { selectChunks, selectMessages } from "./selectors";
export type {
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index b7165e4..7ecc349 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -1,4 +1,5 @@
import type {
+ StepId,
StoredChunk,
TurnDoneEvent,
TurnErrorEvent,
@@ -39,6 +40,7 @@ const toolCall = (
toolCallId: string,
toolName: string,
input: unknown,
+ stepId = "s0",
): TurnToolCallEvent => ({
type: "tool-call",
conversationId: "c1",
@@ -46,6 +48,7 @@ const toolCall = (
toolCallId,
toolName,
input,
+ stepId: stepId as StepId,
});
const toolResult = (
@@ -53,6 +56,7 @@ const toolResult = (
toolCallId: string,
toolName: string,
content: string,
+ stepId = "s0",
): TurnToolResultEvent => ({
type: "tool-result",
conversationId: "c1",
@@ -61,6 +65,7 @@ const toolResult = (
toolName,
content,
isError: false,
+ stepId: stepId as StepId,
});
const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({
@@ -161,15 +166,17 @@ describe("foldEvent — tool-call then tool-result", () => {
it("tool-call then tool-result render in order", () => {
let s = initialState();
s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }));
- s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0"));
+ s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0"));
expect(s.provisional).toHaveLength(2);
expect(s.provisional[0]?.role).toBe("assistant");
+ // foldEvent copies the event's stepId onto the chunk (grouping key).
expect(s.provisional[0]?.chunk).toEqual({
type: "tool-call",
toolCallId: "tc1",
toolName: "bash",
input: { cmd: "ls" },
+ stepId: "t1#0",
});
expect(s.provisional[1]?.role).toBe("tool");
expect(s.provisional[1]?.chunk).toEqual({
@@ -178,6 +185,7 @@ describe("foldEvent — tool-call then tool-result", () => {
toolName: "bash",
content: "file.txt",
isError: false,
+ stepId: "t1#0",
});
});
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index d3b999d..1dcfa39 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -106,6 +106,7 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
+ stepId: event.stepId,
};
return {
...state,
@@ -122,6 +123,7 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript
toolName: event.toolName,
content: event.content,
isError: event.isError,
+ stepId: event.stepId,
};
return {
...state,
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index c0f276f..50b7f35 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -1,5 +1,5 @@
import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
assertAgentEventExhaustive,
@@ -36,6 +36,7 @@ describe("classifies every AgentEvent type", () => {
toolCallId: "tc1",
toolName: "read",
input: {},
+ stepId: "t1#0" as StepId,
},
{
type: "tool-result",
@@ -45,6 +46,7 @@ describe("classifies every AgentEvent type", () => {
toolName: "read",
content: "ok",
isError: false,
+ stepId: "t1#0" as StepId,
},
{
type: "tool-output",
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}