diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index b907b7e74d..1724921c22 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -32,8 +32,10 @@ import { shouldBypassDeferredMessages, } from "@/browser/utils/messages/messageUtils"; import { computeTaskReportLinking } from "@/browser/utils/messages/taskReportLinking"; +import { computeToolCoalesceInfos } from "@/browser/utils/messages/toolCoalescing"; import { BashCollapsedSummaryModeProvider } from "@/browser/features/Tools/BashCollapsedSummaryModeContext"; import { BashOutputCollapsedIndicator } from "@/browser/features/Tools/BashOutputCollapsedIndicator"; +import { CoalescedToolCall } from "@/browser/features/Tools/CoalescedToolCall"; import { getInterruptionContext, getLastMainRetryCandidateMessage, @@ -338,6 +340,13 @@ const ChatPaneContent: React.FC = (props) => { // Track which bash_output groups are expanded (keyed by first message ID) const [expandedBashGroups, setExpandedBashGroups] = useState>(new Set()); + // Track which tool-coalesce groups (file_read / file_edit bursts) the user + // has expanded. Keyed by the head message ID so the entry survives later + // additions to the same group without changing identity. + const [expandedToolCoalesceGroups, setExpandedToolCoalesceGroups] = useState>( + new Set() + ); + // Extract state from workspace state // Keep a ref to the latest workspace state so event handlers (passed to memoized children) @@ -429,6 +438,12 @@ const ChatPaneContent: React.FC = (props) => { [deferredMessages] ); + // Precompute tool-coalesce metadata (file_read / file_edit bursts) once per snapshot. + const toolCoalesceInfos = useMemo( + () => computeToolCoalesceInfos(deferredMessages), + [deferredMessages] + ); + const autoCompactionResult = useMemo( () => checkAutoCompaction( @@ -681,6 +696,7 @@ const ChatPaneContent: React.FC = (props) => { useEffect(() => { setEditingState({ workspaceId, message: undefined }); setExpandedBashGroups(new Set()); + setExpandedToolCoalesceGroups(new Set()); }, [workspaceId]); const handleChatInputReady = useCallback((api: ChatInputAPI) => { @@ -1132,6 +1148,21 @@ const ChatPaneContent: React.FC = (props) => { return null; } + // Tool-coalesce groups (file_read / file_edit bursts). The head + // call is replaced by a summary row when collapsed; members are + // hidden entirely until the user expands the group. + const toolCoalesceGroup = toolCoalesceInfos[index]; + const coalesceHeadId = toolCoalesceGroup + ? deferredMessages[toolCoalesceGroup.headIndex]?.id + : undefined; + const isToolCoalesceExpanded = coalesceHeadId + ? expandedToolCoalesceGroups.has(coalesceHeadId) + : false; + + if (toolCoalesceGroup?.position === "member" && !isToolCoalesceExpanded) { + return null; + } + const isAtCutoff = editCutoffHistoryId !== undefined && msg.type !== "history-hidden" && @@ -1145,27 +1176,61 @@ const ChatPaneContent: React.FC = (props) => { ? taskReportLinking : undefined; + // Render order at the head of a coalesced group: + // - collapsed: summary row replaces the head's MessageRenderer. + // - expanded: summary row sits at the top (acts as the + // collapse toggle) and the head's normal + // MessageRenderer renders directly below, with + // the rest of the group's members following on + // subsequent iterations. + const renderCoalesceSummary = + toolCoalesceGroup?.position === "head" && coalesceHeadId; + const renderNormalMessage = !renderCoalesceSummary || isToolCoalesceExpanded; + const toggleCoalesceGroup = + coalesceHeadId !== undefined + ? () => + setExpandedToolCoalesceGroups((prev) => { + const next = new Set(prev); + if (next.has(coalesceHeadId)) { + next.delete(coalesceHeadId); + } else { + next.add(coalesceHeadId); + } + return next; + }) + : undefined; + return ( - + {renderCoalesceSummary && toolCoalesceGroup && toggleCoalesceGroup && ( + + )} + {renderNormalMessage && ( + + )} {/* Show collapsed indicator after the first item in a bash_output group */} {bashOutputGroup?.position === "first" && groupKey && ( ), - parameters: { - chromatic: CHROMATIC_DISABLED, - }, + // (meta-level `chromatic: CHROMATIC_DISABLED` already covers this story.) play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/src/browser/features/Tools/CoalescedToolCall.stories.tsx b/src/browser/features/Tools/CoalescedToolCall.stories.tsx new file mode 100644 index 0000000000..36a60a3637 --- /dev/null +++ b/src/browser/features/Tools/CoalescedToolCall.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; + +import { CoalescedToolCall } from "@/browser/features/Tools/CoalescedToolCall"; +import { CHROMATIC_DISABLED, lightweightMeta } from "@/browser/stories/meta.js"; + +/** + * Layout shell rendered inside each story so the meta-level decorator + * stack (which provides `TooltipProvider` via `StoryUiShell`) is not + * shadowed by a story-local `decorators` override. + */ +function StoryLayout(props: { children: ReactNode }) { + return ( +
+
{props.children}
+
+ ); +} + +/** + * Interactive wrapper that owns the expansion state so play functions can + * exercise the real click-to-expand / click-to-collapse UX. The static + * `expanded` prop on stories is treated as the initial state. + */ +function InteractiveCoalesced( + args: React.ComponentProps +): React.ReactElement { + const [expanded, setExpanded] = useState(args.expanded); + return ( + + setExpanded((prev) => !prev)} + /> + + ); +} + +// Chromatic visual coverage is intentionally disabled — the global snapshot +// budget is tight, and the meaningful surface (header copy, click toggle, +// dedupe, kind verb) is exercised here via `play` functions that run under +// `test-storybook` in CI. +const meta = { + ...lightweightMeta, + title: "App/Chat/Tools/CoalescedToolCall", + component: CoalescedToolCall, + parameters: { + ...lightweightMeta.parameters, + chromatic: CHROMATIC_DISABLED, + }, + render: (args) => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const NOOP = () => undefined; + +// Storybook's test runner uses the document body as the canvas root in some +// configurations. Querying the body ensures we still find rendered content +// when the decorator stack inserts wrappers above the story root. +function getCanvas(canvasElement: HTMLElement) { + return within(canvasElement.ownerDocument.body); +} + +/** + * Two consecutive file_read calls coalesce into a "Read files …" row. The + * play function clicks it and verifies it toggles to expanded. + */ +export const TwoReadsClickToExpand: Story = { + args: { + kind: "file_read", + filePaths: ["src/App.tsx", "src/main.ts"], + expanded: false, + onToggle: NOOP, + }, + play: async ({ canvasElement }) => { + const canvas = getCanvas(canvasElement); + + // Header copy: past-tense verb + plural noun + joined paths. + const summary = await canvas.findByText(/Read files/); + await expect(canvas.findByText("src/App.tsx, src/main.ts")).resolves.toBeTruthy(); + + // Toggle starts collapsed, then expanded after a click. + const header = summary.closest("[aria-expanded]"); + if (!(header instanceof HTMLElement)) { + throw new Error("Coalesce header missing aria-expanded element"); + } + await expect(header).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(header); + await waitFor(() => expect(header).toHaveAttribute("aria-expanded", "true")); + + // Clicking again collapses; covers both branches of the toggle. + await userEvent.click(header); + await waitFor(() => expect(header).toHaveAttribute("aria-expanded", "false")); + }, +}; + +/** + * A burst of five reads — exercises the joined-paths layout and the icon + * column hugging the leading path. + */ +export const ManyReads: Story = { + args: { + kind: "file_read", + filePaths: [ + "src/App.tsx", + "src/main.ts", + "src/preload.ts", + "src/config.ts", + "src/browser/features/Tools/CoalescedToolCall.tsx", + ], + expanded: false, + onToggle: NOOP, + }, + play: async ({ canvasElement }) => { + const canvas = getCanvas(canvasElement); + await canvas.findByText(/Read files/); + // All five paths render in chronological order, joined by ", ". + await expect( + canvas.findByText( + "src/App.tsx, src/main.ts, src/preload.ts, src/config.ts, src/browser/features/Tools/CoalescedToolCall.tsx" + ) + ).resolves.toBeTruthy(); + }, +}; + +/** + * file_edit groups use the past-tense "Wrote" verb. Mirrors the canonical + * file-edit icon variant. + */ +export const TwoEdits: Story = { + args: { + kind: "file_edit", + filePaths: ["src/App.tsx", "src/main.ts"], + expanded: false, + onToggle: NOOP, + }, + play: async ({ canvasElement }) => { + const canvas = getCanvas(canvasElement); + await canvas.findByText(/Wrote files/); + await expect(canvas.findByText("src/App.tsx, src/main.ts")).resolves.toBeTruthy(); + }, +}; + +/** + * Display-only dedupe: a burst that touches the same file repeatedly should + * still render it once. Verifies the React.useMemo dedupe path in + * `CoalescedToolCall`. + */ +export const DeduplicatedPaths: Story = { + args: { + kind: "file_edit", + // 5 raw calls; 3 unique files. First-occurrence order: a, b, c. + filePaths: ["src/a.ts", "src/b.ts", "src/a.ts", "src/c.ts", "src/b.ts"], + expanded: false, + onToggle: NOOP, + }, + play: async ({ canvasElement }) => { + const canvas = getCanvas(canvasElement); + await canvas.findByText(/Wrote files/); + + // Each unique path appears exactly once in the joined list, in + // first-occurrence order. Asserting on the exact string is the simplest + // way to express both the order and the dedupe simultaneously. + await expect(canvas.findByText("src/a.ts, src/b.ts, src/c.ts")).resolves.toBeTruthy(); + }, +}; + +/** + * Expanded state — verifies aria-expanded reflects the initial prop and the + * chevron indicator rotates. + */ +export const InitiallyExpanded: Story = { + args: { + kind: "file_read", + filePaths: ["src/App.tsx", "src/main.ts", "src/preload.ts"], + expanded: true, + onToggle: NOOP, + }, + play: async ({ canvasElement }) => { + const canvas = getCanvas(canvasElement); + const summary = await canvas.findByText(/Read files/); + const header = summary.closest("[aria-expanded]"); + if (!(header instanceof HTMLElement)) { + throw new Error("Coalesce header missing aria-expanded element"); + } + await expect(header).toHaveAttribute("aria-expanded", "true"); + }, +}; diff --git a/src/browser/features/Tools/CoalescedToolCall.test.tsx b/src/browser/features/Tools/CoalescedToolCall.test.tsx new file mode 100644 index 0000000000..5db19816c3 --- /dev/null +++ b/src/browser/features/Tools/CoalescedToolCall.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; + +import { CoalescedToolCall } from "./CoalescedToolCall"; + +const noop = (): void => undefined; + +let windowInstance: GlobalWindow | null = null; + +beforeEach(() => { + windowInstance = new GlobalWindow(); + globalThis.window = windowInstance as unknown as Window & typeof globalThis; + globalThis.document = windowInstance.document as unknown as Document; +}); + +afterEach(() => { + cleanup(); + void windowInstance?.happyDOM.abort(); + windowInstance = null; + delete (globalThis as { window?: Window }).window; + delete (globalThis as { document?: Document }).document; +}); + +describe("CoalescedToolCall", () => { + test("renders 'Read files ' summary when collapsed", () => { + const view = render( + + + + ); + + expect(view.getByText(/Read files/)).toBeTruthy(); + expect(view.getByText("src/App.tsx, src/main.ts")).toBeTruthy(); + }); + + test("renders 'Wrote files' for file_edit kind", () => { + const view = render( + + + + ); + + expect(view.getByText(/Wrote files/)).toBeTruthy(); + }); + + test("clicking the header fires onToggle and reflects aria-expanded state", () => { + const onToggle = mock(noop); + const view = render( + + + + ); + + const header = view.container.querySelector('[aria-expanded="false"]'); + expect(header).toBeTruthy(); + fireEvent.click(header!); + expect(onToggle).toHaveBeenCalledTimes(1); + + view.rerender( + + + + ); + + expect(view.container.querySelector('[aria-expanded="true"]')).toBeTruthy(); + }); + + test("uses singular noun for a single-path group", () => { + const view = render( + + + + ); + + expect(view.getByText(/Read file\b/)).toBeTruthy(); + }); + + test("deduplicates repeated file paths while preserving first-occurrence order", () => { + const view = render( + + + + ); + + // Order kept by first occurrence; duplicates removed from the rendered list. + expect(view.getByText("a.ts, b.ts, c.ts")).toBeTruthy(); + // Plural noun reflects the unique count (3), not the raw tool-call count (5). + expect(view.getByText(/Wrote files/)).toBeTruthy(); + }); +}); diff --git a/src/browser/features/Tools/CoalescedToolCall.tsx b/src/browser/features/Tools/CoalescedToolCall.tsx new file mode 100644 index 0000000000..e221fe99ac --- /dev/null +++ b/src/browser/features/Tools/CoalescedToolCall.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { FileIcon } from "@/browser/components/FileIcon/FileIcon"; +import { ToolContainer, ToolHeader, ExpandIcon, ToolIcon } from "./Shared/ToolPrimitives"; +import type { ToolCoalesceKind } from "@/browser/utils/messages/toolCoalescing"; + +/** + * Summary row that takes the place of the head call in a coalesced tool group. + * + * Sizing intentionally mirrors a collapsed single-file tool call (same + * ToolContainer padding, same row height). That keeps the 1 -> N transition + * flicker-free: when a second file_read lands mid-stream the head row stays + * pinned in place and only its content changes from "Read /a.ts" to + * "Read files /a.ts, /b.ts". + * + * The component is purely presentational. Expansion state lives on the + * transcript so the same Set can drive both the head row and the visibility + * of follow-up member rows. + */ + +interface CoalesceKindCopy { + /** Past-tense verb used in the summary. */ + verb: string; + /** + * Tool name routed through {@link ToolIcon}. We deliberately pick a + * representative variant (e.g. `file_edit_replace_string` for edits) so the + * icon registry already has a mapping. + */ + iconToolName: string; +} + +const KIND_COPY: Record = { + file_read: { verb: "Read", iconToolName: "file_read" }, + // `file_edit_*` all map to the same Pencil icon via the registry; the + // specific variant here is just a stable key for ToolIcon's lookup. + file_edit: { verb: "Wrote", iconToolName: "file_edit_replace_string" }, +}; + +interface CoalescedToolCallProps { + /** Which tool kind the group represents. */ + kind: ToolCoalesceKind; + /** + * File paths involved in the group, in chronological order. Duplicates are + * left in place — repeated edits to the same file are a meaningful signal + * that the user can see by expanding. + */ + filePaths: string[]; + /** Whether the group is currently expanded. */ + expanded: boolean; + /** Toggle the group's expansion state. */ + onToggle: () => void; +} + +export const CoalescedToolCall: React.FC = ({ + kind, + filePaths, + expanded, + onToggle, +}) => { + const { verb, iconToolName } = KIND_COPY[kind]; + // Display dedupe: the same file is often read/edited several times in a + // burst (e.g. multi-hunk edits to one file). Showing "a.ts, a.ts, b.ts" + // adds noise without information. Preserve chronological order by keeping + // the first occurrence of each path. + const uniquePaths = React.useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const path of filePaths) { + if (seen.has(path)) continue; + seen.add(path); + out.push(path); + } + return out; + }, [filePaths]); + + // The summary is only rendered for groups of >= 2, but guard against zero + // for type safety and to keep the copy sensible. + const fileCount = uniquePaths.length; + const noun = fileCount === 1 ? "file" : "files"; + // Use the first path to drive the small file-type icon — gives the row a + // visual anchor that matches the single-call layout. + const leadingPath = uniquePaths[0] ?? ""; + const joinedPaths = uniquePaths.join(", "); + + return ( + + + + +
+ + {verb} {noun} + + {leadingPath && ( + + )} + + {joinedPaths} + +
+ + {fileCount} + +
+
+ ); +}; diff --git a/src/browser/features/Tools/CoalescedToolCall.visual.stories.tsx b/src/browser/features/Tools/CoalescedToolCall.visual.stories.tsx new file mode 100644 index 0000000000..180c358cac --- /dev/null +++ b/src/browser/features/Tools/CoalescedToolCall.visual.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactNode } from "react"; + +import { CoalescedToolCall } from "@/browser/features/Tools/CoalescedToolCall"; +import { lightweightMeta } from "@/browser/stories/meta.js"; + +/** + * Chromatic-snapshotted story for the new coalesced-tool-call row. + * + * Most coverage for `CoalescedToolCall` lives in the sibling + * `CoalescedToolCall.stories.tsx` file, which is Chromatic-disabled and uses + * `play` functions to exercise click-to-expand, dedupe, and the kind-specific + * verb. This file deliberately contains a single visual baseline — the + * collapsed `Read files …` row — so reviewers get one pinned snapshot of the + * canonical new behavior without pushing the global snapshot budget over. + */ + +function StoryLayout(props: { children: ReactNode }) { + return ( +
+
{props.children}
+
+ ); +} + +const NOOP = () => undefined; + +const meta = { + ...lightweightMeta, + title: "App/Chat/Tools/CoalescedToolCall (visual)", + component: CoalescedToolCall, + render: (args) => ( + + + + ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Collapsed coalesce row for a two-file read burst — the headline new UX. The + * adjacent interactions file covers expansion, dedupe, and the `file_edit` + * verb in play functions. + */ +export const CollapsedReadFiles: Story = { + args: { + kind: "file_read", + filePaths: ["src/App.tsx", "src/main.ts"], + expanded: false, + onToggle: NOOP, + }, +}; diff --git a/src/browser/utils/messages/toolCoalescing.test.ts b/src/browser/utils/messages/toolCoalescing.test.ts new file mode 100644 index 0000000000..e59f20ec34 --- /dev/null +++ b/src/browser/utils/messages/toolCoalescing.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from "@jest/globals"; + +import { computeToolCoalesceInfos, type ToolCoalesceInfo } from "./toolCoalescing"; +import type { DisplayedMessage } from "@/common/types/message"; + +function fileReadMessage( + id: string, + path: string, + historySequence: number +): Extract { + return { + type: "tool", + id, + historyId: `h-${id}`, + toolCallId: `tc-${id}`, + toolName: "file_read", + args: { path }, + result: { success: true, content: "", file_size: 0, modifiedTime: "", lines_read: 0 }, + status: "completed", + isPartial: false, + historySequence, + }; +} + +function fileEditMessage( + id: string, + toolName: "file_edit_replace_string" | "file_edit_insert" | "file_edit_replace_lines", + path: string, + historySequence: number +): DisplayedMessage { + return { + type: "tool", + id, + historyId: `h-${id}`, + toolCallId: `tc-${id}`, + toolName, + args: + toolName === "file_edit_insert" + ? { path, content: "x" } + : toolName === "file_edit_replace_string" + ? { path, old_string: "a", new_string: "b" } + : { path, start_line: 1, end_line: 2, new_content: "x" }, + result: { success: true, diff: "", edits_applied: 1 }, + status: "completed", + isPartial: false, + historySequence, + }; +} + +function userMessage(id: string, historySequence: number): DisplayedMessage { + return { + type: "user", + id, + historyId: `h-${id}`, + content: "hi", + historySequence, + }; +} + +function infoAt(messages: DisplayedMessage[], index: number): ToolCoalesceInfo | undefined { + return computeToolCoalesceInfos(messages)[index]; +} + +describe("computeToolCoalesceInfos", () => { + it("does not coalesce a single file_read", () => { + const messages = [fileReadMessage("1", "/a.ts", 1)]; + expect(infoAt(messages, 0)).toBeUndefined(); + }); + + it("coalesces two consecutive file_reads with head/member positions", () => { + const messages = [fileReadMessage("1", "/a.ts", 1), fileReadMessage("2", "/b.ts", 2)]; + + const head = infoAt(messages, 0); + const member = infoAt(messages, 1); + + expect(head).toMatchObject({ + kind: "file_read", + position: "head", + totalCount: 2, + headIndex: 0, + filePaths: ["/a.ts", "/b.ts"], + }); + expect(member).toMatchObject({ + kind: "file_read", + position: "member", + totalCount: 2, + headIndex: 0, + filePaths: ["/a.ts", "/b.ts"], + }); + }); + + it("coalesces three or more consecutive file_reads", () => { + const messages = [ + fileReadMessage("1", "/a.ts", 1), + fileReadMessage("2", "/b.ts", 2), + fileReadMessage("3", "/c.ts", 3), + ]; + + expect(infoAt(messages, 0)?.position).toBe("head"); + expect(infoAt(messages, 1)?.position).toBe("member"); + expect(infoAt(messages, 2)?.position).toBe("member"); + expect(infoAt(messages, 0)?.totalCount).toBe(3); + expect(infoAt(messages, 0)?.filePaths).toEqual(["/a.ts", "/b.ts", "/c.ts"]); + }); + + it("coalesces mixed file_edit variants under the file_edit kind", () => { + const messages = [ + fileEditMessage("1", "file_edit_replace_string", "/a.ts", 1), + fileEditMessage("2", "file_edit_insert", "/b.ts", 2), + fileEditMessage("3", "file_edit_replace_lines", "/c.ts", 3), + ]; + + expect(infoAt(messages, 0)).toMatchObject({ + kind: "file_edit", + position: "head", + totalCount: 3, + filePaths: ["/a.ts", "/b.ts", "/c.ts"], + }); + expect(infoAt(messages, 2)?.position).toBe("member"); + }); + + it("does not coalesce across kinds (file_read then file_edit)", () => { + const messages = [ + fileReadMessage("1", "/a.ts", 1), + fileEditMessage("2", "file_edit_replace_string", "/a.ts", 2), + ]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 1)).toBeUndefined(); + }); + + it("does not coalesce across an intervening non-tool message", () => { + const messages = [ + fileReadMessage("1", "/a.ts", 1), + userMessage("u", 2), + fileReadMessage("2", "/b.ts", 3), + ]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 2)).toBeUndefined(); + }); + + it("does not coalesce across an intervening unrelated tool", () => { + const messages: DisplayedMessage[] = [ + fileReadMessage("1", "/a.ts", 1), + { + type: "tool", + id: "bash-1", + historyId: "h-bash-1", + toolCallId: "tc-bash-1", + toolName: "bash", + args: { script: "ls", timeout_secs: 1, display_name: "ls" }, + status: "completed", + isPartial: false, + historySequence: 2, + }, + fileReadMessage("2", "/b.ts", 3), + ]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 2)).toBeUndefined(); + }); + + it("supports multiple independent groups separated by other content", () => { + const messages = [ + fileReadMessage("r1", "/a.ts", 1), + fileReadMessage("r2", "/b.ts", 2), + userMessage("u1", 3), + fileEditMessage("e1", "file_edit_replace_string", "/c.ts", 4), + fileEditMessage("e2", "file_edit_insert", "/d.ts", 5), + ]; + + expect(infoAt(messages, 0)?.kind).toBe("file_read"); + expect(infoAt(messages, 0)?.position).toBe("head"); + expect(infoAt(messages, 1)?.position).toBe("member"); + expect(infoAt(messages, 2)).toBeUndefined(); + expect(infoAt(messages, 3)?.kind).toBe("file_edit"); + expect(infoAt(messages, 3)?.position).toBe("head"); + expect(infoAt(messages, 4)?.position).toBe("member"); + }); + + it("falls back to (unknown) when a tool args object has no recognizable path", () => { + const messages: DisplayedMessage[] = [ + { + type: "tool", + id: "1", + historyId: "h-1", + toolCallId: "tc-1", + toolName: "file_read", + args: {}, + status: "completed", + isPartial: false, + historySequence: 1, + }, + fileReadMessage("2", "/b.ts", 2), + ]; + + expect(infoAt(messages, 0)?.filePaths).toEqual(["(unknown)", "/b.ts"]); + }); + + it("does not coalesce a group that contains an interrupted (partial) member", () => { + // The transcript renders an InterruptedBarrier for interrupted tool rows; + // hiding such a row would eat the user-visible interruption signal. + const partial = fileReadMessage("2", "/b.ts", 2); + partial.isPartial = true; + const messages: DisplayedMessage[] = [ + fileReadMessage("1", "/a.ts", 1), + partial, + fileReadMessage("3", "/c.ts", 3), + ]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 1)).toBeUndefined(); + expect(infoAt(messages, 2)).toBeUndefined(); + }); + + it("does not coalesce a group with a failed member (preserves error visibility)", () => { + const failed = fileReadMessage("2", "/b.ts", 2); + failed.status = "failed"; + const messages: DisplayedMessage[] = [ + fileReadMessage("1", "/a.ts", 1), + failed, + fileReadMessage("3", "/c.ts", 3), + ]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 1)).toBeUndefined(); + expect(infoAt(messages, 2)).toBeUndefined(); + }); + + it("does not coalesce a group with a still-running member", () => { + // Mid-stream a fresh tool call is `executing`; the summary row hides + // that status until the user expands, so refuse to coalesce. + const running = fileReadMessage("2", "/b.ts", 2); + running.status = "executing"; + const messages: DisplayedMessage[] = [fileReadMessage("1", "/a.ts", 1), running]; + + expect(infoAt(messages, 0)).toBeUndefined(); + expect(infoAt(messages, 1)).toBeUndefined(); + }); + + it("still coalesces a group when every member is completed", () => { + const messages = [ + fileReadMessage("1", "/a.ts", 1), + fileReadMessage("2", "/b.ts", 2), + fileReadMessage("3", "/c.ts", 3), + ]; + + expect(infoAt(messages, 0)?.position).toBe("head"); + }); + + it("returns an array sized to match the input", () => { + const messages = [ + userMessage("u1", 1), + fileReadMessage("1", "/a.ts", 2), + fileReadMessage("2", "/b.ts", 3), + ]; + + const infos = computeToolCoalesceInfos(messages); + expect(infos).toHaveLength(3); + expect(infos[0]).toBeUndefined(); + expect(infos[1]?.position).toBe("head"); + expect(infos[2]?.position).toBe("member"); + }); +}); diff --git a/src/browser/utils/messages/toolCoalescing.ts b/src/browser/utils/messages/toolCoalescing.ts new file mode 100644 index 0000000000..42e86aa835 --- /dev/null +++ b/src/browser/utils/messages/toolCoalescing.ts @@ -0,0 +1,161 @@ +import type { DisplayedMessage } from "@/common/types/message"; +import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath"; + +/** + * Tool coalescing groups consecutive tool calls of related kinds into a single + * "summary" row that takes the place of the head call. The follow-up calls are + * hidden from the transcript until the user expands the group. + * + * Why: when an agent reads or writes a burst of files, each individual row + * pushes more useful content (assistant prose, terminal output, etc.) off + * screen. Coalescing keeps those bursts skimmable while still allowing the + * user to drill into any individual tool call. + * + * Why the head-replacement design: the coalesce row reuses the head's slot in + * the transcript so the 1 -> N transition is a content swap rather than a + * row insertion. The summary row is sized to match a collapsed single-file + * tool call so the user does not see a layout flash when the second member + * arrives mid-stream. + */ + +/** + * Kinds of tool calls that can be coalesced. + * + * Both Anthropic-flavored and historical `file_edit_*` variants are grouped + * under a single "file_edit" kind so a mixed run of inserts/replaces still + * forms one logical "Wrote files …" block in the transcript. + */ +export type ToolCoalesceKind = "file_read" | "file_edit"; + +const FILE_READ_TOOL_NAMES = new Set(["file_read"]); + +// All current and legacy file-edit variants. `file_edit_replace_lines` is no +// longer wired into the live tool registry, but historical transcripts may +// still contain it, so we include it here to keep older sessions consistent. +const FILE_EDIT_TOOL_NAMES = new Set([ + "file_edit_replace_string", + "file_edit_replace_lines", + "file_edit_insert", +]); + +/** Minimum group size before we coalesce. Below this we just render normally. */ +const MIN_COALESCE_GROUP_SIZE = 2; + +/** + * Information about a tool message's position within a coalesced group. + * Aligned with the messages array passed to {@link computeToolCoalesceInfos}. + */ +export interface ToolCoalesceInfo { + /** Which kind of tool group this message belongs to. */ + kind: ToolCoalesceKind; + /** + * "head" — the first call in the group. Rendered as the coalesce summary + * row when collapsed; rendered both as the summary row and normally when + * expanded. + * + * "member" — every other call in the group. Hidden when collapsed; rendered + * normally when expanded. + */ + position: "head" | "member"; + /** Total number of tool calls coalesced into this group (>= 2). */ + totalCount: number; + /** Index of the head message; used as the expansion-state key. */ + headIndex: number; + /** + * File paths involved in the group, in chronological order. Used by the + * head's summary row to render "Read files a, b, c". May contain duplicates + * if the same file appears in multiple consecutive calls. + */ + filePaths: string[]; +} + +function getCoalesceKind(msg: DisplayedMessage | undefined): ToolCoalesceKind | undefined { + if (msg?.type !== "tool") return undefined; + if (FILE_READ_TOOL_NAMES.has(msg.toolName)) return "file_read"; + if (FILE_EDIT_TOOL_NAMES.has(msg.toolName)) return "file_edit"; + return undefined; +} + +/** + * A coalesceable member is a tool call whose row carries no actionable + * signal the user would lose if hidden. Individual tool rows surface + * status (pending/executing/failed/interrupted/redacted) and inline + * errors, and partial rows render an InterruptedBarrier. The summary + * row deliberately renders none of those, so any non-completed or + * partial member forces the whole group back to normal rendering. + */ +function isCoalesceableToolMember(msg: DisplayedMessage | undefined): boolean { + if (msg?.type !== "tool") return false; + if (msg.isPartial) return false; + return msg.status === "completed"; +} + +/** + * Compute coalesce metadata for every message in one linear pass. + * + * Returns an array aligned with `messages` where each entry is either a + * {@link ToolCoalesceInfo} (this message participates in a group) or + * `undefined` (rendered normally). + * + * Performance: workspace-open commits the full history in one pass; we keep + * this O(n) so the coalescing work scales with transcript size. + */ +export function computeToolCoalesceInfos( + messages: DisplayedMessage[] +): Array { + const infos = new Array(messages.length); + + let index = 0; + while (index < messages.length) { + const kind = getCoalesceKind(messages[index]); + if (!kind) { + index++; + continue; + } + + // Walk forward while the next message has the same coalesce kind. + let groupEnd = index; + while (groupEnd < messages.length - 1 && getCoalesceKind(messages[groupEnd + 1]) === kind) { + groupEnd++; + } + + const groupSize = groupEnd - index + 1; + + // Skip coalescing if any member is not safely coalesceable (still + // running, failed, interrupted, redacted, or partial). Those rows + // carry status/error UI or an InterruptedBarrier that the summary + // row deliberately does not render, so hiding them would eat the + // signal until the user manually expands the group. + let allMembersCoalesceable = true; + for (let j = index; j <= groupEnd; j++) { + if (!isCoalesceableToolMember(messages[j])) { + allMembersCoalesceable = false; + break; + } + } + + if (groupSize >= MIN_COALESCE_GROUP_SIZE && allMembersCoalesceable) { + const filePaths: string[] = []; + for (let j = index; j <= groupEnd; j++) { + const candidate = messages[j]; + // Guarded by the walk above, but narrow defensively for TypeScript. + if (candidate?.type !== "tool") continue; + filePaths.push(extractToolFilePath(candidate.args) ?? "(unknown)"); + } + + for (let j = index; j <= groupEnd; j++) { + infos[j] = { + kind, + position: j === index ? "head" : "member", + totalCount: groupSize, + headIndex: index, + filePaths, + }; + } + } + + index = groupEnd + 1; + } + + return infos; +}