From ada4f1bee691cb30abba1c417a46ccf7efeed7fb Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:49:09 +0800 Subject: [PATCH] feat(app): merge repeated file edits (#44107) --- .../session-timeline-collapse-state.spec.ts | 20 +- .../session-timeline-projection.spec.ts | 110 +++++- .../src/components/apply-patch-file.test.ts | 45 ++- .../src/components/apply-patch-file.ts | 57 +++- .../session-ui/src/components/session-diff.ts | 2 +- .../src/message/current-message.tsx | 15 +- .../src/timeline/projection.test.ts | 8 +- .../session-ui/src/timeline/projection.ts | 15 +- .../src/timeline/rows-current.test.ts | 42 ++- .../src/timeline/session-timeline-row.tsx | 31 +- .../session-ui/src/timeline/timeline-row.ts | 2 +- .../session-ui/src/tools/tool-renderer.tsx | 315 +++++++++--------- 12 files changed, 453 insertions(+), 209 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 3e15d9daca..ad47380c2c 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -99,7 +99,7 @@ test.describe("regression: session timeline local row state", () => { await wrapper.evaluate((element) => { ;(element as HTMLElement).dataset.regressionMarker = "before-stream" }) - await wrapper.locator('[data-slot="collapsible-trigger"]').first().click() + await wrapper.locator('[data-scope="apply-patch"] button').click() await expectExpanded(wrapper, false) events.push(...textEvents()) @@ -179,8 +179,8 @@ test.describe("regression: session timeline local row state", () => { await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first() - const diff = wrapper.locator('[data-component="edit-content"]').first() + const trigger = wrapper.locator('[data-component="sticky-accordion-header"]') + const diff = wrapper.locator('[data-component="apply-patch-file-diff"]').first() await expectAppVisible(diff) await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500) const samples = await wrapper.evaluate(async (element) => { @@ -190,8 +190,8 @@ test.describe("regression: session timeline local row state", () => { for (const offset of [0, 120, 240, 360, 480]) { root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0)) await new Promise(requestAnimationFrame) - const trigger = element.querySelector('[data-slot="collapsible-trigger"]')! - const diff = element.querySelector('[data-component="edit-content"]')! + const trigger = element.querySelector('[data-component="sticky-accordion-header"]')! + const diff = element.querySelector('[data-component="apply-patch-file-diff"]')! result.push({ offset, trigger: trigger.getBoundingClientRect().y, @@ -202,7 +202,7 @@ test.describe("regression: session timeline local row state", () => { return result }) - expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff) + expect(samples[0]!.trigger).toBeGreaterThanOrEqual(samples[0]!.diff) expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true) expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true) }) @@ -234,7 +234,9 @@ async function readToolState(page: Page) { .evaluate( (element, textPartID) => ({ expanded: (() => { - const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const trigger = + element.querySelector('[data-scope="apply-patch"] button') ?? + element.querySelector('[data-slot="collapsible-trigger"]') const aria = trigger?.getAttribute("aria-expanded") if (aria === "true") return true if (aria === "false") return false @@ -409,7 +411,9 @@ function eventValue( } function readExpanded(element: Element) { - const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const trigger = + element.querySelector('[data-scope="apply-patch"] button') ?? + element.querySelector('[data-slot="collapsible-trigger"]') const aria = trigger?.getAttribute("aria-expanded") if (aria === "true") return true if (aria === "false") return false diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 56ad586cb9..9029b555b5 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test" import { assistantMessage, + partUpdated, setupTimeline, status, toolPart, @@ -76,36 +77,119 @@ test.describe("session timeline projection", () => { await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1) await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0) await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0) + const edit = page.locator('[data-timeline-part-id="prt_edit"]') + await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible() + await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) }) - test("combines adjacent patch calls into one file group", async ({ page }) => { + test("combines adjacent patch calls and repeated files into one group", async ({ page }) => { const first = "prt_patch_first" const second = "prt_patch_second" - await setupTimeline(page, { + const timeline = await setupTimeline(page, { messages: [ userMessage(), assistantMessage([ - toolPart(first, "patch", "completed", { patchText: "Update src/first.ts" }, { - metadata: { files: [patchFile("src/first.ts", "modified")] }, - }), - toolPart(second, "patch", "completed", { patchText: "Update src/second.ts" }, { - metadata: { files: [patchFile("src/second.ts", "added")] }, - }), + toolPart( + first, + "patch", + "completed", + { patchText: "Update src/first.ts" }, + { + metadata: { files: [patchFile("src/first.ts", "modified")] }, + }, + ), ]), ], }) + const initial = page.locator(`[data-timeline-part-id="${first}"]`) + const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]') + await expect(initialFile).toBeVisible() + await initialFile.getByRole("button").click() + await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true") + await initial.evaluate((element) => { + const row = element.closest("[data-timeline-key]") + if (row) row.dataset.patchRow = "stable" + }) + + await timeline.send( + partUpdated(toolPart(second, "patch", "running", { patchText: "Update more files" }, { metadata: {} })), + ) + const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`) - await expect(group).toBeVisible() - await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1) - await expect(group.getByRole("button", { name: "Patch 2 files" })).toHaveCount(0) - await expect(group.getByRole("button")).toHaveCount(2) - await expect(group.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(2) + await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable") + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) + await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute( + "aria-expanded", + "true", + ) + + await timeline.send( + partUpdated( + toolPart( + second, + "patch", + "completed", + { patchText: "Update more files" }, + { + metadata: { + files: [patchFile("src/first.ts", "modified"), patchFile("src/second.ts", "added")], + }, + }, + ), + ), + ) + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"]) + await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute( + "aria-expanded", + "true", + ) + await expect(group.locator('[data-scope="apply-patch"] [data-type="add"] button')).toHaveAttribute( + "aria-expanded", + "false", + ) await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0) }) + test("combines adjacent edit calls and repeated files into one group", async ({ page }) => { + const first = "prt_edit_first" + const second = "prt_edit_second" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + first, + "edit", + "completed", + { path: "src/first.ts", oldString: "one", newString: "two" }, + { + metadata: { files: [patchFile("src/first.ts", "modified")] }, + }, + ), + toolPart( + second, + "edit", + "completed", + { path: "src/first.ts", oldString: "two", newString: "three" }, + { + metadata: { files: [patchFile("src/first.ts", "modified")] }, + }, + ), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + + const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`) + await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") + await expect(group.getByText("1 file", { exact: true })).toBeVisible() + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) + await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") + }) + test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { const firstUser = userMessage( [ diff --git a/packages/session-ui/src/components/apply-patch-file.test.ts b/packages/session-ui/src/components/apply-patch-file.test.ts index 5aa49fa881..c3853c4325 100644 --- a/packages/session-ui/src/components/apply-patch-file.test.ts +++ b/packages/session-ui/src/components/apply-patch-file.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { patchFile, patchFiles } from "./apply-patch-file" +import { createTwoFilesPatch } from "diff" +import { patchFile, patchFileGroups, patchFiles } from "./apply-patch-file" describe("apply patch files", () => { test("parses current file diffs", () => { @@ -28,4 +29,46 @@ describe("apply patch files", () => { { path: "src/old.ts", type: "delete" }, ]) }) + + test("composes sequential complete patches for the same file", () => { + const before = "const a = 1\nconst b = 2\n" + const middle = "const a = 2\nconst b = 2\n" + const after = "const a = 2\nconst b = 3\n" + const patch = (oldText: string, newText: string) => + createTwoFilesPatch("a/src/a.ts", "b/src/a.ts", oldText, newText).replace( + /^(?:Index: [^\n]+\n)?=+\n/, + "diff --git a/src/a.ts b/src/a.ts\n", + ) + const groups = patchFileGroups([ + { + file: "src/a.ts", + patch: patch(before, middle), + additions: 1, + deletions: 1, + status: "modified", + }, + { + file: "src/a.ts", + patch: patch(middle, after), + additions: 1, + deletions: 1, + status: "modified", + }, + ]) + + expect(groups).toHaveLength(1) + expect(groups[0]?.views).toHaveLength(1) + expect(groups[0]?.additions).toBe(2) + expect(groups[0]?.deletions).toBe(2) + }) + + test("keeps sequential partial patches under one file", () => { + const groups = patchFileGroups([ + { file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b", additions: 1, deletions: 1, status: "modified" }, + { file: "src/a.ts", patch: "@@ -2 +2 @@\n-c\n+d", additions: 1, deletions: 1, status: "modified" }, + ]) + + expect(groups).toHaveLength(1) + expect(groups[0]?.views).toHaveLength(2) + }) }) diff --git a/packages/session-ui/src/components/apply-patch-file.ts b/packages/session-ui/src/components/apply-patch-file.ts index 650cbc1c1e..2dd94e50f8 100644 --- a/packages/session-ui/src/components/apply-patch-file.ts +++ b/packages/session-ui/src/components/apply-patch-file.ts @@ -1,5 +1,6 @@ import type { FileDiffInfo } from "@opencode-ai/client/promise" -import { normalize, type ViewDiff } from "./session-diff" +import { diffLines } from "diff" +import { completePatchContents, normalize, type ViewDiff } from "./session-diff" type Kind = "add" | "update" | "delete" @@ -9,8 +10,11 @@ export type ApplyPatchFile = { additions: number deletions: number view: ViewDiff + contents?: { before: string; after: string } } +export type ApplyPatchFileGroup = Omit & { views: ViewDiff[] } + function fileDiff(value: unknown): value is FileDiffInfo { if (!value || typeof value !== "object") return false if (!("file" in value) || typeof value.file !== "string") return false @@ -29,6 +33,7 @@ export function patchFile(value: unknown): ApplyPatchFile | undefined { additions: value.additions, deletions: value.deletions, view: normalize(value), + contents: completePatchContents(value.patch), } } @@ -36,3 +41,53 @@ export function patchFiles(value: unknown) { if (!Array.isArray(value)) return [] return value.map(patchFile).filter((file): file is ApplyPatchFile => !!file) } + +export function patchFileGroups(value: unknown): ApplyPatchFileGroup[] { + const groups = patchFiles(value).reduce((result, file) => { + const files = result.get(file.path) + if (files) files.push(file) + if (!files) result.set(file.path, [file]) + return result + }, new Map()) + return [...groups].map(([path, files]) => { + const first = files[0]! + const last = files.at(-1)! + const type = last.type === "delete" ? "delete" : first.type === "add" ? "add" : "update" + const chained = files.every( + (file, index) => !!file.contents && (index === 0 || files[index - 1]?.contents?.after === file.contents.before), + ) + if (!chained) { + return { + path, + type, + additions: files.reduce((total, file) => total + file.additions, 0), + deletions: files.reduce((total, file) => total + file.deletions, 0), + views: files.map((file) => file.view), + } + } + + const before = first.contents!.before + const after = last.contents!.after + const counts = diffLines(before, after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + path, + type, + ...counts, + views: [ + normalize({ + file: path, + before, + after, + status: type === "add" ? "added" : type === "delete" ? "deleted" : "modified", + ...counts, + }), + ], + } + }) +} diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 2b799841cc..ea89ac9625 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -70,7 +70,7 @@ function fileDiffFromPatch(file: string, patch: string) { return value } -function completePatchContents(patch: string) { +export function completePatchContents(patch: string) { try { const parsed = parsePatch(patch)[0] if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index f024013d2d..53e8cda0a3 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -6,7 +6,7 @@ import type { import { Match, Switch } from "solid-js" import type { SessionUserActions, SessionUserComment } from "../actions" import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content" -import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer" +import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer" import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state" export type { SessionUserActions, SessionUserComment } from "../actions" @@ -110,9 +110,18 @@ export function SessionContextToolGroup(props: { ) } -export function SessionPatchToolGroup(props: { +export function SessionFileToolGroup(props: { tools: SessionMessageAssistantTool[] + fileOpen: (path: string) => boolean | undefined + onFileOpenChange: (path: string, open: boolean) => void onSizeChange?: () => void }) { - return + return ( + + ) } diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 5900c54ab9..0594e76a54 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -2,11 +2,7 @@ import { describe, expect, test } from "bun:test" import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise" import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection" -const context = ( - key: string, - partIDs: string[], - identity: { userMessageID?: string; messageID?: string } = {}, -) => +const context = (key: string, partIDs: string[], identity: { userMessageID?: string; messageID?: string } = {}) => new TimelineRow.AssistantPart({ userMessageID: identity.userMessageID ?? "user-1", group: { @@ -22,7 +18,7 @@ const patch = (key: string, partIDs: string[], userMessageID = "user-1") => userMessageID, group: { key, - type: "patch", + type: "file", refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })), } satisfies PartGroup, previousAssistantPart: false, diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 0ea4dc8346..2cd259cd38 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -446,20 +446,15 @@ function renderable(content: Content, showReasoning: boolean) { function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] { const groups: PartGroup[] = [] - let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined + let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined const flush = () => { const current = adjacent const first = current?.refs[0] if (!first) return - if (current.type === "patch" && current.refs.length === 1) { - groups.push({ type: "part", key: `part:${first.messageID}:${first.partID}`, ref: first }) - adjacent = undefined - return - } groups.push({ - type: current.type, + type: current.type === "context" ? "context" : "file", key: - current.type === "patch" + current.type !== "context" ? `part:${first.messageID}:${first.partID}` : `context:${first.messageID}:${first.partID}`, refs: current.refs, @@ -473,7 +468,9 @@ function groupContent(items: { messageID: string; partID: string; content: Conte ? "context" : item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error" ? "patch" - : undefined + : item.content.type === "tool" && item.content.name === "edit" && item.content.state.status !== "error" + ? "edit" + : undefined if (type) { if (adjacent?.type !== type) flush() adjacent ??= { type, refs: [] } diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 2428b00aa0..5afe0b8a71 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -433,7 +433,12 @@ describe("current session timeline rows", () => { type: "tool", id: "tool_patch_1", name: "patch", - state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "done" }], + metadata: { files: [] }, + }, time: { created: 2, completed: 3 }, }, { @@ -459,9 +464,28 @@ describe("current session timeline rows", () => { type: "tool", id: "tool_patch_3", name: "patch", - state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "done" }], + metadata: { files: [] }, + }, time: { created: 7, completed: 8 }, }, + { + type: "tool", + id: "tool_edit_1", + name: "edit", + state: { status: "running", input: {}, metadata: { files: [] } }, + time: { created: 9 }, + }, + { + type: "tool", + id: "tool_edit_2", + name: "edit", + state: { status: "running", input: {}, metadata: { files: [] } }, + time: { created: 10 }, + }, ], time: { created: 2, completed: 8 }, }, @@ -472,7 +496,7 @@ describe("current session timeline rows", () => { expect(groups).toEqual([ { - type: "patch", + type: "file", key: "part:msg_assistant:tool_patch_1", refs: [ { messageID: "msg_assistant", partID: "tool_patch_1" }, @@ -485,9 +509,17 @@ describe("current session timeline rows", () => { ref: { messageID: "msg_assistant", partID: "tool_patch_failed" }, }, { - type: "part", + type: "file", key: "part:msg_assistant:tool_patch_3", - ref: { messageID: "msg_assistant", partID: "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_edit_1" }, + { messageID: "msg_assistant", partID: "tool_edit_2" }, + ], }, ]) }) diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 32a59c319b..7b5c971462 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -15,7 +15,7 @@ import { MessageDivider, SessionAssistantContent, SessionContextToolGroup, - SessionPatchToolGroup, + SessionFileToolGroup, SessionShellMessage, SessionUserMessage, currentContentDefaultOpen, @@ -99,19 +99,36 @@ export function createSessionTimelineRowRenderer(input: { ) } - if (row().group.type === "patch") { + if (row().group.type === "file") { const tools = createMemo(() => { const group = row().group - if (group.type !== "patch") return [] + if (group.type !== "file") return [] return group.refs.flatMap((ref) => { const message = input.projection.messageByID().get(ref.messageID) const content = Timeline.resolveContent(message, ref.partID) return message?.type === "assistant" && content?.type === "tool" ? [content] : [] }) }) + const firstPath = createMemo(() => { + const tool = tools()[0] + if (!tool || !("metadata" in tool.state)) return undefined + const files = tool.state.metadata?.files + if (!Array.isArray(files)) return undefined + const file = files[0] + return file && typeof file === "object" && "file" in file && typeof file.file === "string" + ? file.file + : undefined + }) return ( - { + const open = input.disclosure.value(`${row().group.key}:file:${path}`) + if (open !== undefined) return open + if (tools()[0]?.name !== "edit" || path !== firstPath()) return false + return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen() + }} + onFileOpenChange={(path, open) => input.disclosure.set(`${row().group.key}:file:${path}`, open)} onSizeChange={onSizeChange} /> ) @@ -173,7 +190,11 @@ export function createSessionTimelineRowRenderer(input: { if (message.type === "system") { const prefix = "Instructions updated: " if (message.description?.startsWith(prefix)) { - const keys = message.description.slice(prefix.length).split(",").map((s) => s.trim()).filter(Boolean) + const keys = message.description + .slice(prefix.length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) return { label: i18n.t("ui.sessionTimeline.notice.instructionsUpdated"), items: keys, diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 8f19fa0d21..192d2b7ddd 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -18,7 +18,7 @@ export type PartGroup = } | { key: string - type: "patch" + type: "file" refs: PartRef[] } diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index f70a30e6b7..d4a42610d3 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -33,7 +33,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { AnimatedCountList } from "../components/tool-count-summary" import { ToolStatusTitle } from "../components/tool-status-title" -import { patchFiles } from "../components/apply-patch-file" +import { patchFileGroups } from "../components/apply-patch-file" import { animate } from "motion" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise" @@ -562,32 +562,55 @@ export function CurrentContextToolGroup(props: { ) } -export function CurrentPatchToolGroup(props: { +export function CurrentFileToolGroup(props: { tools: SessionMessageAssistantTool[] + fileOpen: (path: string) => boolean | undefined + onFileOpenChange: (path: string, open: boolean) => void onSizeChange?: () => void }) { - const metadata = createMemo(() => ({ - files: props.tools.flatMap((tool) => { + const files = createMemo((previous: { key: string; value: unknown }[]) => { + const next = props.tools.flatMap((tool) => { const files = currentToolMetadata(tool).files - return Array.isArray(files) ? files : [] - }), + if (!Array.isArray(files)) return [] + return files.map((value, index) => ({ key: `${tool.id}:${index}`, value })) + }) + const updates = new Map(next.map((entry) => [entry.key, entry.value])) + const existing = new Set(previous.map((entry) => entry.key)) + const result = [ + ...previous.map((entry) => { + if (!updates.has(entry.key)) return entry + const value = updates.get(entry.key) + return samePatchFile(value, entry.value) ? entry : { key: entry.key, value } + }), + ...next.filter((entry) => !existing.has(entry.key)), + ] + return result.length === previous.length && result.every((entry, index) => entry === previous[index]) + ? previous + : result + }, []) + const metadata = createMemo(() => ({ + files: files().map((entry) => entry.value), })) const pending = createMemo(() => 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")) return (
tool.id).join(",")} + data-timeline-part-id={props.tools.length === 1 ? props.tools[0]?.id : undefined} + data-timeline-part-ids={props.tools.length > 1 ? props.tools.map((tool) => tool.id).join(",") : undefined} > ) { const input = currentToolInput(tool) const metadata = currentToolMetadata(tool) @@ -643,6 +678,8 @@ export interface ToolProps { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void + fileOpen?: (path: string) => boolean | undefined + onFileOpenChange?: (path: string, open: boolean) => void deferContent?: boolean virtualizeDiff?: boolean onContentRendered?: () => void @@ -674,7 +711,12 @@ export const ToolRegistry = { render: getTool, } -function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) { +function ToolFileAccordion(props: { + path: string + actions?: JSX.Element + children: JSX.Element + defaultOpen?: boolean +}) { const value = createMemo(() => props.path || "tool-file") return ( @@ -1253,7 +1295,7 @@ ToolRegistry.register({ @@ -1495,13 +1537,23 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const fileComponent = useFileComponent() - const files = createMemo(() => patchFiles(props.metadata.files)) - const single = createMemo(() => { - const list = files() - if (list.length !== 1) return undefined - return list[0] - }) + 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"), + ) + const open = createMemo(() => { + if (!props.fileOpen) return expanded() + return files().flatMap((file) => (props.fileOpen?.(file.path) === true ? [file.path] : [])) + }) + const change = (value: string | string[]) => { + const next = Array.isArray(value) ? value : value ? [value] : [] + if (!props.onFileOpenChange) { + setExpanded(next) + return + } + files().forEach((file) => props.onFileOpenChange?.(file.path, next.includes(file.path))) + } const subtitle = createMemo(() => { const count = files().length @@ -1510,159 +1562,110 @@ ToolRegistry.register({ }) return ( - - + + 0}> + - 0}> - setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - - {(file, index) => { - const value = () => `${index()}:${file.path}` - const active = createMemo(() => expanded().includes(value())) - const [visible, setVisible] = createSignal(false) + + {(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 ( - - - -
-
- -
- - {`\u202A${displayDirectory(file.path)}\u202C`} - - {getFilename(file.path)} -
-
-
- - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - - - -
+ return ( + + + +
+
+ +
+ + {`\u202A${displayDirectory(file().path)}\u202C`} + + {getFilename(file().path)}
- - - - +
+
+ + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + + + +
+
+
+
+ + + + {(view) => (
-
-
-
- ) - }} - - - - -
- } - > -
- - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - - - } - > -
- -
-
-
-
-
+ )} + +
+ + + ) + }} + + +
+ +
) }, })