feat(app): merge repeated file edits (#44107)
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user