fix(session-ui): group file tool diffs (#48932)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-14 15:37:56 +08:00
committed by GitHub
co-authored by Brendonovich
parent 0ee9f61e3d
commit f1149efee7
8 changed files with 59 additions and 42 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -34,7 +34,7 @@ const scenarios = [
title: false,
},
{
name: "grouped edit with title",
name: "grouped edit",
placement: "grouped",
tools: [
toolPart(
@@ -46,14 +46,14 @@ const scenarios = [
),
],
files: ["a"],
title: true,
title: false,
},
{
name: "running edit input fallback",
placement: "grouped",
tools: [toolPart("prt_sticky_edit", "edit", "running", { path: "src/a.ts", oldString: before, newString: after })],
files: ["a"],
title: true,
title: false,
},
{
name: "grouped write",
@@ -4,38 +4,25 @@ import { expect, story } from "../../storybook/playwright/story"
story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "workflow" } })
const first = timeline.locator(
'[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_patch"]',
'[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_write_extra,tool_family_patch"]',
)
const second = timeline.locator('[data-timeline-part-ids="tool_family_skill,tool_family_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"webfetch",
"websearch",
"subagent",
"shell",
"edit",
"write",
"patch",
"question",
"skill",
"custom",
]) {
for (const id of ["webfetch", "websearch", "subagent", "shell", "question", "skill", "custom"]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
for (const name of ["edit", "write", "patch"]) {
const tool = timeline.locator(`[data-timeline-part-id="tool_family_${name}"]`)
await expect(tool.getByText("1 file", { exact: true })).toBeVisible()
await expect(tool.getByRole("button")).toHaveCount(1)
await expect(tool.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("data-locked", "")
await expect(tool.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("line-height", "16px")
}
const files = timeline.locator(
'[data-timeline-part-ids="tool_family_edit,tool_family_write,tool_family_write_extra,tool_family_patch"]',
)
await expect(files).toBeVisible()
await expect(files.locator('[data-scope="apply-patch"]')).toHaveCount(1)
await expect(files.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "new.ts", "extra.ts"])
await expect(files.locator('[data-slot="basic-tool-tool-title"]')).toHaveCount(0)
await expect(files.locator('[data-scope="apply-patch"] button')).toHaveCount(3)
await expect(files.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(3)
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
@@ -250,6 +250,10 @@ const CompleteAgentWorkflow = {
path: "src/new.ts",
content: "export const stable = true",
}),
storyTool("tool_family_write_extra", "write", "completed", {
path: "src/extra.ts",
content: "export const extra = true",
}),
storyTool(
"tool_family_patch",
"patch",
@@ -64,19 +64,24 @@ export function createSessionTimelineRowRenderer(input: {
}) {
const i18n = useI18n()
const data = useData()
// Cached timelines retain subgroup identities alongside their disclosure choices.
// Cached timelines retain file-change subgroup identities alongside their disclosure choices.
const patchGroupKeys = input.disclosure.patchGroupKeys ?? new Map<string, string>()
const patchPartKeys = new WeakMap<SessionMessageAssistant["content"][number], string>()
const patchOwners = createMemo(() => {
const owners = new Map<string, string>()
const rows = input.projection.rows()
// Track status changes before a group is first opened: a failed patch can
// Track status changes before a group is first opened: a failed file change can
// split an existing group without changing the projection's row identities.
rows.forEach((row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
if (
content?.type !== "tool" ||
!["edit", "write", "patch"].includes(content.name) ||
content.state.status === "error"
)
return
const part = `${ref.messageID}:${ref.partID}`
const key = patchGroupKeys.get(part)
if (key && !owners.has(key)) owners.set(key, part)
+33 -12
View File
@@ -16,7 +16,7 @@ import {
type JSX,
} from "solid-js"
import stripAnsi from "strip-ansi"
import { createTwoFilesPatch } from "diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Dynamic } from "solid-js/web"
import { type SessionSummary, useData } from "../context"
import { useFileComponent } from "@opencode/ui/context/file"
@@ -566,13 +566,7 @@ export function CurrentContextToolGroup(props: {
return groups
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
) {
if (isFileChangeTool(tool) && Array.isArray(previous) && previous[0] && isFileChangeTool(previous[0])) {
previous.push(tool)
return groups
}
@@ -595,7 +589,7 @@ export function CurrentContextToolGroup(props: {
const patchKeys = createMemo(() => {
const keys = new Map<SessionMessageAssistantTool, string>()
items().forEach((item) => {
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
if (!Array.isArray(item) || !item[0] || !isFileChangeTool(item[0])) return
const key = props.patchGroupKey?.(item) ?? item[0].id
item.forEach((tool) => keys.set(tool, key))
})
@@ -715,7 +709,7 @@ export function CurrentContextToolGroup(props: {
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<Show
when={tool().name === "patch" && tool().state.status !== "error"}
when={isFileChangeTool(tool())}
fallback={
<ToolDisplay
id={tool().id}
@@ -844,9 +838,32 @@ export function CurrentFileToolGroup(props: {
const files = currentToolMetadata(tool).files
if (Array.isArray(files) && files.length > 0)
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
if (tool.name !== "write") return []
const input = currentToolInput(tool)
if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return []
if (typeof input.path !== "string") return []
if (tool.name === "edit" && typeof input.oldString === "string" && typeof input.newString === "string") {
const changes = diffLines(input.oldString, input.newString)
const additions = changes
.filter((change) => change.added)
.reduce((total, change) => total + (change.count ?? 0), 0)
const deletions = changes
.filter((change) => change.removed)
.reduce((total, change) => total + (change.count ?? 0), 0)
if (additions === 0 && deletions === 0) return []
return [
{
key: `${tool.id}:0`,
toolID: tool.id,
value: {
file: input.path,
patch: createTwoFilesPatch(input.path, input.path, input.oldString, input.newString),
additions,
deletions,
status: "modified",
},
},
]
}
if (tool.name !== "write" || typeof input.content !== "string" || !input.content) return []
return [
{
key: `${tool.id}:0`,
@@ -912,6 +929,10 @@ export function CurrentFileToolGroup(props: {
)
}
function isFileChangeTool(tool: SessionMessageAssistantTool) {
return tool.state.status !== "error" && (tool.name === "edit" || tool.name === "write" || tool.name === "patch")
}
function samePatchFile(a: unknown, b: unknown) {
if (a === b) return true
if (!record(a) || !record(b)) return false