feat(app): merge repeated file edits (#44107)

This commit is contained in:
Brendan Allan
2026-08-22 21:49:09 +08:00
committed by GitHub
parent 75efc9d833
commit ada4f1bee6
12 changed files with 453 additions and 209 deletions
@@ -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<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
const trigger = element.querySelector<HTMLElement>('[data-component="sticky-accordion-header"]')!
const diff = element.querySelector<HTMLElement>('[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<Type extends OpenCodeEvent["type"]>(
}
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
@@ -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<HTMLElement>("[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(
[
@@ -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)
})
})
@@ -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<ApplyPatchFile, "view" | "contents"> & { 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<string, ApplyPatchFile[]>())
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,
}),
],
}
})
}
@@ -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
@@ -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 <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
return (
<CurrentFileToolGroup
tools={props.tools}
fileOpen={props.fileOpen}
onFileOpenChange={props.onFileOpenChange}
onSizeChange={props.onSizeChange}
/>
)
}
@@ -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,
@@ -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: [] }
@@ -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" },
],
},
])
})
@@ -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 (
<SessionPatchToolGroup
<SessionFileToolGroup
tools={tools()}
fileOpen={(path) => {
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,
@@ -18,7 +18,7 @@ export type PartGroup =
}
| {
key: string
type: "patch"
type: "file"
refs: PartRef[]
}
+159 -156
View File
@@ -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 (
<div
data-component="tool-part-wrapper"
data-timeline-part-ids={props.tools.map((tool) => 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}
>
<Dynamic
component={render}
tool="patch"
tool={tool()}
input={{}}
metadata={metadata()}
status={pending() ? "running" : "completed"}
fileOpen={props.fileOpen}
onFileOpenChange={props.onFileOpenChange}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
@@ -596,6 +619,18 @@ export function CurrentPatchToolGroup(props: {
)
}
function samePatchFile(a: unknown, b: unknown) {
if (a === b) return true
if (!record(a) || !record(b)) return false
return (
a.file === b.file &&
a.patch === b.patch &&
a.additions === b.additions &&
a.deletions === b.deletions &&
a.status === b.status
)
}
function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: ReturnType<typeof useI18n>) {
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({
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t("ui.tool.shell")}
active={streaming() || props.status === "running" || props.metadata.status === "running"}
active={streaming()}
/>
</span>
<Show when={!open()}>
@@ -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<string[]>([])
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 (
<Show
when={single()}
fallback={
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: title(),
subtitle: subtitle(),
}}
>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
value={open()}
onChange={change}
>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file, index) => {
const value = () => `${index()}:${file.path}`
const active = createMemo(() => expanded().includes(value()))
const [visible, setVisible] = createSignal(false)
<Index each={files()}>
{(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 (
<Accordion.Item value={value()} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file.path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file.path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.path)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file.additions, deletions: file.deletions }}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
return (
<Accordion.Item value={value()} data-type={file().type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file().path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file().path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={props.deferContent === false || visible()}>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file().type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file().type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file().additions, deletions: file().deletions }}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={props.deferContent === false || visible()}>
<For each={file().views}>
{(view) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
fileDiff={view.fileDiff}
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</BasicTool>
</div>
}
>
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
rail={false}
>
<ToolFileAccordion
path={single()!.path}
defaultOpen={false}
actions={
<Switch>
<Match when={single()!.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={single()!.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: single()!.additions, deletions: single()!.deletions }}
/>
</Match>
</Switch>
}
>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={single()!.view.fileDiff}
onRendered={props.onContentRendered}
/>
</div>
</ToolFileAccordion>
</BasicTool>
</div>
</Show>
)}
</For>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</Index>
</Accordion>
</Show>
</BasicTool>
</div>
)
},
})