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 (