From 010cd6131eb935d76c0b6b3a13f26bb2a8162caa Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:58:02 +0800 Subject: [PATCH] fix(app): merge adjacent file-changing tools (#44977) Co-authored-by: Brendonovich --- ...ession-timeline-reducer-projection.spec.ts | 3 +- .../session-ui/src/timeline/projection.ts | 8 +- .../src/timeline/rows-current.test.ts | 18 +- .../session-ui/src/tools/tool-renderer.tsx | 244 ++++++++++-------- 4 files changed, 146 insertions(+), 127 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index ba757c344c1..16f9178d6dd 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async ( }) const group = page.locator('[data-component="collapsed-tool-group"]') await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click() - await expect(group.getByText("2 files", { exact: true })).toBeVisible() + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"]) await timeline.send( partUpdated( toolPart( @@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async ( "true", ) await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1) - await expect(group.getByText("3 files", { exact: true })).toBeVisible() await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"]) }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 0b99742a4e5..47c33900881 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -597,7 +597,7 @@ function groupContent( detail?: TimelineDetail, ): PartGroup[] { const groups: PartGroup[] = [] - let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined + let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined const flush = () => { const current = adjacent const first = current?.refs[0] @@ -665,8 +665,7 @@ function toolGroupType( const category = timelineCategory(content)! if (detail[category].placement === "grouped") return "context" if (currentToolFailed(content)) return undefined - if (content.name === "patch") return "patch" - if (content.name === "edit") return "edit" + if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file" return undefined } if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined @@ -684,8 +683,7 @@ function toolGroupType( ) return undefined if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context" - if (content.name === "patch") return "patch" - if (content.name === "edit") return "edit" + if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file" return undefined } diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 95705a60a9a..337ccf90083 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -691,6 +691,13 @@ describe("current session timeline rows", () => { state: { status: "running", input: {}, metadata: { files: [] } }, time: { created: 10 }, }, + { + type: "tool", + id: "tool_write_1", + name: "write", + state: { status: "running", input: {}, metadata: { files: [] } }, + time: { created: 11 }, + }, ], time: { created: 2, completed: 8 }, }, @@ -716,14 +723,11 @@ describe("current session timeline rows", () => { { type: "file", key: "part:msg_assistant:tool_patch_3", - refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }], - }, - { - type: "file", - key: "part:msg_assistant:tool_edit_1", refs: [ + { messageID: "msg_assistant", partID: "tool_patch_3" }, { messageID: "msg_assistant", partID: "tool_edit_1" }, { messageID: "msg_assistant", partID: "tool_edit_2" }, + { messageID: "msg_assistant", partID: "tool_write_1" }, ], }, ]) @@ -790,8 +794,8 @@ describe("current session timeline rows", () => { test.each([ { shell: false, edit: false, types: ["context"] }, { shell: true, edit: false, types: ["part", "context"] }, - { shell: false, edit: true, types: ["context", "file", "part", "file", "context"] }, - { shell: true, edit: true, types: ["part", "file", "part", "file", "context"] }, + { shell: false, edit: true, types: ["context", "file", "context"] }, + { shell: true, edit: true, types: ["part", "file", "context"] }, ])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => { const source = [ { id: "msg_user", type: "user", text: "work", time: { created: 1 } }, diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 9e3193c459d..4f78faa473d 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -16,12 +16,12 @@ import { type JSX, } from "solid-js" import stripAnsi from "strip-ansi" +import { createTwoFilesPatch } from "diff" import { Dynamic } from "solid-js/web" import { type SessionSummary, useData } from "../context" import { useFileComponent } from "@opencode/ui/context/file" import { type UiI18n, useI18n } from "@opencode/ui/context/i18n" import { BasicTool, GenericTool } from "../components/basic-tool" -import { Collapsible } from "@opencode/ui/collapsible" import { FileIcon } from "@opencode/ui/file-icon" import { Icon, type IconProps } from "@opencode/ui/icon" import { ToolErrorCard } from "../components/tool-error-card" @@ -837,8 +837,24 @@ export function CurrentFileToolGroup(props: { const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => { const next = props.tools.flatMap((tool) => { const files = currentToolMetadata(tool).files - if (!Array.isArray(files)) return [] - return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value })) + if (Array.isArray(files) && files.length > 0) + return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value })) + if (tool.name !== "write") return [] + const input = currentToolInput(tool) + if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return [] + return [ + { + key: `${tool.id}:0`, + toolID: tool.id, + value: { + file: input.path, + patch: createTwoFilesPatch(input.path, input.path, "", input.content), + additions: input.content.split("\n").length - Number(input.content.endsWith("\n")), + deletions: 0, + status: "modified", + }, + }, + ] }) const updates = new Map(next.map((entry) => [entry.key, entry.value])) const existing = new Set(previous.map((entry) => entry.key)) @@ -864,7 +880,10 @@ export function CurrentFileToolGroup(props: { props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"), ) const render = ToolRegistry.render("patch") ?? GenericTool - const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch")) + const tool = createMemo(() => { + const name = props.tools[0]?.name + return name === "edit" || name === "write" ? name : "patch" + }) return (
(typeof props.input.path === "string" ? props.input.path : "")) const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : "")) const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path())) return (
- - - -
- -
-
-
- -
+ + +
+ +
+
+
+
) }, @@ -1967,7 +1983,11 @@ ToolRegistry.register({ const files = createMemo(() => patchFileGroups(props.metadata.files)) const [expanded, setExpanded] = createSignal([]) const title = createMemo(() => - props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"), + props.tool === "edit" + ? i18n.t("ui.messagePart.title.edit") + : props.tool === "write" + ? i18n.t("ui.messagePart.title.write") + : i18n.t("ui.tool.patch"), ) const open = createMemo(() => { if (!props.fileOpen) return expanded() @@ -1984,92 +2004,90 @@ ToolRegistry.register({ return (
- - 0}> - - - {(file) => { - const value = () => file().path - const active = createMemo(() => open().includes(value())) - const [visible, setVisible] = createSignal(false) + 0} fallback={}> + + + {(file) => { + const value = () => file().path + const active = createMemo(() => open().includes(value())) + const [visible, setVisible] = createSignal(false) - createEffect(() => { - if (!active()) { - setVisible(false) - return - } + createEffect(() => { + if (!active()) { + setVisible(false) + return + } - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) }) + }) - return ( - - change(expanded ? [...open(), value()] : open().filter((path) => path !== value())) - } - type={file().type} - header={ -
-
- -
- - {`\u202A${displayDirectory(file().path)}\u202C`} - - {getFilename(file().path)} -
-
-
- - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - - - + return ( + + change(expanded ? [...open(), value()] : open().filter((path) => path !== value())) + } + type={file().type} + header={ +
+
+ +
+ + {`\u202A${displayDirectory(file().path)}\u202C`} + + {getFilename(file().path)}
- } - > - - - {(view) => ( -
- + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + -
- )} -
-
- - ) - }} - - - - + + + +
+
+ } + > + + + {(view) => ( +
+ +
+ )} +
+
+ + ) + }} + + +
) },