diff options
Diffstat (limited to 'src/features/chat')
| -rw-r--r-- | src/features/chat/index.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/logic/tool-summary.test.ts | 281 | ||||
| -rw-r--r-- | src/features/chat/logic/tool-summary.ts | 263 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 6 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 81 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.test.ts | 180 | ||||
| -rw-r--r-- | src/features/chat/ui/LazyCollapse.svelte | 2 | ||||
| -rw-r--r-- | src/features/chat/ui/ToolCard.svelte | 111 |
8 files changed, 845 insertions, 81 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 6de8164..59da39b 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,7 +2,7 @@ export type { ProviderRetryView, RenderedChunk, RenderGroup, - ToolBatchEntry, + ToolEntry, } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts new file mode 100644 index 0000000..5caa1f1 --- /dev/null +++ b/src/features/chat/logic/tool-summary.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import type { ToolEntry } from "../../../core/chunks"; +import { viewToolEntry } from "./tool-summary"; + +function entry( + name: string, + input: unknown, + content: string | null = "ok", + isError = false, +): ToolEntry { + return { + toolCallId: "a", + toolName: name, + call: { type: "tool-call", toolCallId: "a", toolName: name, input }, + result: + content === null + ? null + : { type: "tool-result", toolCallId: "a", toolName: name, content, isError }, + }; +} + +const view = (name: string, input: unknown, content: string | null = "ok", isError = false) => + viewToolEntry(entry(name, input, content, isError)); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`); + +describe("tool summaries", () => { + it.each([ + ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], + ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"], + ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"], + ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"], + ["read_file", { path: "src/a.ts" }, "src/a.ts"], + ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"], + ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"], + ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"], + ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"], + [ + "lsp", + { operation: "references", path: "src/a.ts", line: 12, character: 3 }, + "references src/a.ts:12:3", + ], + ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"], + [ + "todo_write", + { todos: [{ status: "pending" }, { status: "completed" }, null] }, + "3 tasks · 1 completed", + ], + ["load_skill", { name: "research_methodology" }, "research_methodology"], + [ + "consult_vision", + { question: "What is in this picture?", imageIds: [1] }, + "What is in this picture?", + ], + ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"], + ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"], + ["summon_agent", { task: "Review the patch" }, "Review the patch"], + ["summon_agent", { agent: "Opus" }, "Opus"], + ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"], + ["read_agent", { conversationId: "abcd" }, "abcd"], + ["open_agent", { conversationId: "abcd" }, "abcd"], + ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"], + ["list_agents", { relationship: "descendants" }, "descendants"], + ["list_agents", { query: "review" }, "review"], + ["list_agent_models", { query: "Opus" }, "Opus"], + ])("%s derives a meaningful call summary from %j", (name, input, expected) => { + expect(view(name, input).callSummary).toBe(expected); + }); + + it("formats edit call badges and old/new details without hiding multiline text", () => { + const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" }; + const card = view("edit_file", input); + expect(card.callBadges).toEqual(["+3 −2"]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: "a.ts", + oldText: input.oldString, + newText: input.newString, + }); + expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([ + "+1 −0", + ]); + expect(view("edit_file", { path: "a" }).callBadges).toEqual([]); + }); + it("preserves exact shell commands in the expanded detail", () => { + expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({ + kind: "command", + text: "ls\n pwd", + }); + }); + it("shows scalar fields and pretty nested JSON", () => { + expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({ + kind: "fields", + fields: [ + { key: "path", value: "a.ts", multiline: false }, + { key: "count", value: "2", multiline: false }, + { key: "nested", value: '{\n "a": true\n}', multiline: true }, + ], + }); + }); + it.each([ + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ])("generic fallback recognizes %s", (key) => { + expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target"); + }); + it("generic fallback prefers the first meaningful argument, else compact JSON", () => { + expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a"); + expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe('{"a":1,"b":"x"}'); + }); + it.each([ + "not JSON", + "{bad", + null, + undefined, + 42, + true, + [1, "a"], + {}, + "null", + '"hi"', + ])("tolerates malformed/non-object input %j", (input) => { + expect(() => view("read_file", input)).not.toThrow(); + expect(view("read_file", input).callSummary).not.toBe(""); + expect(view("read_file", input).callDetail.kind).toBe("fields"); + }); + it.each([ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "youtube_transcript", + "summon_agent", + "read_agent", + ])("%s falls back safely for missing arguments", (name) => { + for (const input of [null, undefined, {}, "{bad"]) { + expect(() => view(name, input)).not.toThrow(); + expect(view(name, input).callSummary.trim()).not.toBe(""); + } + }); + it("parses JSON-string inputs", () => { + const card = view("run_shell", '{"command":"bun run test"}'); + expect(card.callSummary).toBe("bun run test"); + expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" }); + }); + it("handles unexpected built-in argument types and prototype-looking tool names", () => { + expect(view("todo_write", { todos: "wrong" }).callSummary).toBe('{"todos":"wrong"}'); + expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields"); + expect(view("constructor", { path: "a" }).callSummary).toBe("a"); + expect(view("__proto__", {}).callSummary).toBe("{}"); + expect( + view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary, + ).toBe("good"); + }); + it("does not throw on unserializable input", () => { + const cyclic: Record<string, unknown> = {}; + cyclic.self = cyclic; + expect(view("mcp", cyclic).callSummary).toBe("[unserializable input]"); + expect(() => view("mcp", { integer: 1n })).not.toThrow(); + }); + it("caps and whitespace-collapses header strings but retains full detail", () => { + const command = ` echo\n${"x".repeat(300)}`; + const card = view("run_shell", { command }); + expect(card.callSummary.length).toBe(120); + expect(card.callSummary.endsWith("…")).toBe(true); + expect(card.callSummary).not.toMatch(/\n/); + expect(card.callDetail).toEqual({ kind: "command", text: command }); + }); + it("represents pending entries with no response summary/output", () => { + expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({ + status: "pending", + resultSummary: null, + output: null, + }); + }); + it("represents orphan results with unknown call details, not pending", () => { + const e = { ...entry("read_file", {}, "data"), call: null }; + expect(viewToolEntry(e)).toMatchObject({ + name: "read_file", + status: "ok", + callSummary: "call unavailable", + resultSummary: "1 line", + }); + expect(viewToolEntry(e).callDetail).toEqual({ + kind: "fields", + fields: [ + { key: "call", value: "Call unavailable (earlier history unloaded)", multiline: false }, + ], + }); + }); +}); + +describe("tool response facts", () => { + it.each([ + ["run_shell", "one\ntwo\n", false, "2 lines"], + ["run_shell", "done", false, "1 line"], + ["read_file", "1\tfile text\n", false, "1 line"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ["mcp", "", false, "no output"], + ["mcp", " \n\t", false, "no output"], + ["mcp", "a short response", false, "a short response"], + ["mcp", "x".repeat(200), false, "1 line"], + ["web_search", "a\nb\nc", false, "3 lines"], + ["run_shell", "", true, "failed"], + ["mcp", "Permission denied", true, "failed · Permission denied"], + ["run_shell", "error\ndetail", true, "failed · 2 lines"], + ["run_shell", "partial\n\n[Command timed out]", true, "timed out"], + ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"], + ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · 2 lines · truncated"], + ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"], + [ + "edit_file", + 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2', + false, + "3 replacements · diagnostics", + ], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"], + ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"], + ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => { + const card = view(name, {}, content, isError); + expect(card.resultSummary).toBe(expected); + expect(card.status).toBe(isError ? "error" : "ok"); + expect(card.output?.isError).toBe(isError); + }); + it("does not infer success on an error that resembles a write result", () => { + expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch( + /^failed/, + ); + }); +}); + +describe("tool output elision", () => { + it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => { + expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({ + kind: "full", + lines: lines(n), + isError: false, + }); + }); + it.each([ + 13, 14, 100, + ])("shows first/last six of %i lines, retaining everything for expansion", (n) => { + expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({ + kind: "elided", + head: lines(n).slice(0, 6), + hiddenCount: n - 12, + tail: lines(n).slice(-6), + all: lines(n), + isError: true, + }); + }); + it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => { + expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full"); + expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({ + kind: "elided", + hiddenCount: 1, + }); + }); + it("preserves meaningful blank lines including an extra trailing blank line", () => { + expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({ + kind: "full", + lines: ["a", "", "b", ""], + isError: false, + }); + }); +}); diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts new file mode 100644 index 0000000..3b75a6b --- /dev/null +++ b/src/features/chat/logic/tool-summary.ts @@ -0,0 +1,263 @@ +import type { ToolEntry } from "../../../core/chunks"; + +export interface ToolCardView { + readonly name: string; + readonly status: "pending" | "ok" | "error"; + readonly callSummary: string; + readonly callBadges: readonly string[]; + readonly resultSummary: string | null; + readonly callDetail: + | { readonly kind: "command"; readonly text: string } + | { + readonly kind: "edit"; + readonly path: string; + readonly oldText: string; + readonly newText: string; + } + | { + readonly kind: "fields"; + readonly fields: readonly { + readonly key: string; + readonly value: string; + readonly multiline: boolean; + }[]; + }; + readonly output: ToolOutputView | null; +} + +export type ToolOutputView = + | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean } + | { + readonly kind: "elided"; + readonly head: readonly string[]; + readonly hiddenCount: number; + readonly tail: readonly string[]; + readonly all: readonly string[]; + readonly isError: boolean; + }; + +type Args = Readonly<Record<string, unknown>>; +type CallSummary = (args: Args) => string; + +function oneLine(text: string, cap = 120): string { + const compact = text.replace(/\s+/g, " ").trim(); + return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact; +} + +function normalize(input: unknown): unknown { + if (typeof input !== "string") return input; + try { + return JSON.parse(input); + } catch { + return input; + } +} + +function asArgs(input: unknown): Args { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Args) + : {}; +} + +function serialize(input: unknown, pretty = false): string { + if (typeof input === "string") return input; + try { + return JSON.stringify(input, null, pretty ? 2 : undefined) ?? String(input); + } catch { + return "[unserializable input]"; + } +} + +function arg(args: Args, ...keys: string[]): string { + for (const key of keys) { + const value = Object.hasOwn(args, key) ? args[key] : undefined; + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + } + return ""; +} + +function lineCount(text: string): number { + return text === "" ? 0 : outputLines(text).length; +} + +/** Drop one final newline terminator, not meaningful blank lines within the output. */ +function outputLines(content: string): string[] { + const normalized = content.replace(/\r\n/g, "\n"); + return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n"); +} + +function readSummary(args: Args): string { + const path = arg(args, "path"); + const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null; + return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`; +} + +function lspSummary(args: Args): string { + const line = arg(args, "line"); + const character = arg(args, "character"); + return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`; +} + +function todoSummary(args: Args): string { + if (!Array.isArray(args.todos)) return ""; + const done = args.todos.filter((t) => asArgs(t).status === "completed").length; + return `${args.todos.length} tasks · ${done} completed`; +} + +const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model"); +const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message"); + +/** Optional built-in polish; all unknown/MCP names use the same generic path. */ +const summaries: Readonly<Record<string, CallSummary>> = { + run_shell: (args) => arg(args, "command"), + read_file: readSummary, + edit_file: (args) => arg(args, "path"), + write_file: (args) => arg(args, "path"), + web_search: (args) => arg(args, "query", "url"), + lsp: lspSummary, + todo_write: todoSummary, + load_skill: (args) => arg(args, "name"), + consult_vision: (args) => arg(args, "question", "path"), + youtube_transcript: (args) => arg(args, "url"), + summon_agent: summarizeAgent, + send_agent_message: summarizeConversation, + read_agent: summarizeConversation, + open_agent: summarizeConversation, + cancel_agent_message: summarizeConversation, + list_agents: (args) => arg(args, "query", "relationship", "workspaceId"), + list_agent_models: (args) => arg(args, "query"), +}; + +function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] { + if (name === "run_shell" && typeof args.command === "string") { + return { kind: "command", text: args.command }; + } + if (name === "edit_file" && typeof args.path === "string") { + return { + kind: "edit", + path: args.path, + oldText: typeof args.oldString === "string" ? args.oldString : "", + newText: typeof args.newString === "string" ? args.newString : "", + }; + } + const entries = Object.entries(args); + return { + kind: "fields", + fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => { + const text = serialize(value, true); + return { key: String(key), value: text, multiline: text.includes("\n") }; + }), + }; +} + +function bytesLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function responseSummary(name: string, content: string, isError: boolean): string { + const lines = outputLines(content); + const first = oneLine(lines.find((line) => line.trim()) ?? "", 72); + const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`; + const timeout = /\[Command timed out\]/i.test(content); + const truncated = /\[Output truncated[^\]]*\]/i.test(content); + let summary: string; + if (timeout) summary = "timed out"; + else if (isError) + summary = `failed${lines.length > 1 ? ` · ${count}` : first ? ` · ${first}` : ""}`; + else if (!content.trim()) summary = "no output"; + else if (/^\(empty file\b/i.test(content)) summary = "empty file"; + else { + const replaced = + name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null; + const written = + name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null; + if (replaced) { + summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`; + if (content.trim().includes("\n")) summary += " · diagnostics"; + } else if (written) { + summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`; + } else if ( + name === "run_shell" || + name === "read_file" || + lines.length > 1 || + first.length > 60 + ) { + summary = count; + } else summary = first; + } + return oneLine(`${summary}${truncated ? " · truncated" : ""}`, 96); +} + +export function viewToolEntry(entry: ToolEntry): ToolCardView { + const input = normalize(entry.call?.input); + const args = asArgs(input); + const summarize = Object.hasOwn(summaries, entry.toolName) + ? summaries[entry.toolName] + : undefined; + const generic = arg( + args, + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ); + const builtInSummary = summarize?.(args).trim(); + const callSummary = + entry.call === null + ? "call unavailable" + : oneLine(builtInSummary || generic || serialize(input)); + const callBadges: string[] = []; + if (entry.toolName === "edit_file" && entry.call !== null) { + if (typeof args.oldString === "string" && typeof args.newString === "string") { + callBadges.push(`+${lineCount(args.newString)} −${lineCount(args.oldString)}`); + } + } + const result = entry.result; + const lines = result === null ? [] : outputLines(result.content); + return { + name: entry.toolName, + status: result === null ? "pending" : result.isError ? "error" : "ok", + callSummary, + callBadges, + callDetail: + entry.call === null + ? { + kind: "fields", + fields: [ + { + key: "call", + value: "Call unavailable (earlier history unloaded)", + multiline: false, + }, + ], + } + : detail(entry.toolName, input, args), + resultSummary: + result === null ? null : responseSummary(entry.toolName, result.content, result.isError), + output: + result === null + ? null + : lines.length <= 12 + ? { kind: "full", lines, isError: result.isError } + : { + kind: "elided", + head: lines.slice(0, 6), + hiddenCount: lines.length - 12, + tail: lines.slice(-6), + all: lines, + isError: result.isError, + }, + }; +} diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 3c7fd0d..c3a76d4 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -210,7 +210,7 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); - it("groups batched tool calls (shared stepId) into one DaisyUI list", async () => { + it("renders parallel tool calls (shared stepId) as separate bubbles", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -254,10 +254,12 @@ describe("ChatView", () => { const { container } = render(ChatView, { props: { chunks } }); await openAllCollapses(container); - // Batched calls render as collapsible cards (one per call), not a list. + // Parallel calls render as independent collapsible cards (one per bubble). const collapses = container.querySelectorAll(".collapse"); expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).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(); 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> |
