From 17bc0a2cdaeefd4974f785c907d3515a38d45363 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 16:22:31 +0900 Subject: 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. --- src/features/chat/index.ts | 3 +- src/features/chat/store.test.ts | 4 +- src/features/chat/ui.test.ts | 56 ++++++++++++++ src/features/chat/ui/ChatView.svelte | 143 ++++++++++++++++++++--------------- 4 files changed, 145 insertions(+), 61 deletions(-) (limited to 'src/features') 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
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 @@ -
- {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} - {#if rendered.role === "user"} - -
-
- {#if rendered.chunk.type === "text"} -

{rendered.chunk.text}

- {/if} -
+{#snippet chunkRow(rendered: RenderedChunk)} + {#if rendered.role === "user"} + +
+
+ {#if rendered.chunk.type === "text"} +

{rendered.chunk.text}

+ {/if}
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - -
-
- {#if rendered.chunk.type === "tool-call"} -
- {rendered.chunk.toolName} -
{JSON.stringify(rendered.chunk.input, null, 2)}
-
- {:else} -
- {rendered.chunk.toolName} -
{rendered.chunk.content}
-
- {/if} -
+
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + +
+
+ {#if rendered.chunk.type === "tool-call"} +
+ {rendered.chunk.toolName} +
{JSON.stringify(rendered.chunk.input, null, 2)}
+
+ {:else} +
+ {rendered.chunk.toolName} +
{rendered.chunk.content}
+
+ {/if}
- {:else} - -
-
- {#if rendered.chunk.type === "text"} +
+ {:else} + +
+
+ {#if rendered.chunk.type === "text"} +

{rendered.chunk.text}

+ {:else if rendered.chunk.type === "thinking"} +
+ Thinking

{rendered.chunk.text}

- {:else if rendered.chunk.type === "thinking"} -
- Thinking -

{rendered.chunk.text}

-
- {:else if rendered.chunk.type === "error"} - - {:else if rendered.chunk.type === "system"} -
{rendered.chunk.text}
- {/if} +
+ {:else if rendered.chunk.type === "error"} + + {:else if rendered.chunk.type === "system"} +
{rendered.chunk.text}
+ {/if} +
+
+ {/if} +{/snippet} + +
+ {#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} + +
+
+
    + {#each group.entries as entry (entry.call.toolCallId)} +
  • +
    + {entry.call.toolName} +
    {JSON.stringify(entry.call.input, null, 2)}
    + {#if entry.result} +
    {entry.result.content}
    + {/if} +
    +
  • + {/each} +
{/if} -- cgit v1.2.3