mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): merge adjacent file-changing tools (#44977)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
c2348f8f69
commit
010cd6131e
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ function groupContent(
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
@@ -665,8 +665,7 @@ function toolGroupType(
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
@@ -684,8 +683,7 @@ function toolGroupType(
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -691,6 +691,13 @@ describe("current session timeline rows", () => {
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_write_1",
|
||||
name: "write",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -716,14 +723,11 @@ describe("current session timeline rows", () => {
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
{ messageID: "msg_assistant", partID: "tool_write_1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -790,8 +794,8 @@ describe("current session timeline rows", () => {
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { type SessionSummary, useData } from "../context"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Collapsible } from "@opencode/ui/collapsible"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon, type IconProps } from "@opencode/ui/icon"
|
||||
import { ToolErrorCard } from "../components/tool-error-card"
|
||||
@@ -837,8 +837,24 @@ export function CurrentFileToolGroup(props: {
|
||||
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
if (Array.isArray(files) && files.length > 0)
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
if (tool.name !== "write") return []
|
||||
const input = currentToolInput(tool)
|
||||
if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return []
|
||||
return [
|
||||
{
|
||||
key: `${tool.id}:0`,
|
||||
toolID: tool.id,
|
||||
value: {
|
||||
file: input.path,
|
||||
patch: createTwoFilesPatch(input.path, input.path, "", input.content),
|
||||
additions: input.content.split("\n").length - Number(input.content.endsWith("\n")),
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
@@ -864,7 +880,10 @@ export function CurrentFileToolGroup(props: {
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
const tool = createMemo(() => {
|
||||
const name = props.tools[0]?.name
|
||||
return name === "edit" || name === "write" ? name : "patch"
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -979,7 +998,7 @@ export const ToolRegistry = {
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children?: JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<BasicTool
|
||||
@@ -1920,40 +1939,37 @@ ToolRegistry.register({
|
||||
ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
|
||||
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
|
||||
return (
|
||||
<div data-component="write-tool">
|
||||
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</FileTool>
|
||||
<Show when={content() && path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1967,7 +1983,11 @@ ToolRegistry.register({
|
||||
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"),
|
||||
props.tool === "edit"
|
||||
? i18n.t("ui.messagePart.title.edit")
|
||||
: props.tool === "write"
|
||||
? i18n.t("ui.messagePart.title.write")
|
||||
: i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
@@ -1984,92 +2004,90 @@ ToolRegistry.register({
|
||||
|
||||
return (
|
||||
<div data-component="apply-patch-tool">
|
||||
<FileTool {...props} title={title()} count={files().length}>
|
||||
<Show when={files().length > 0}>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<Show when={files().length > 0} fallback={<FileTool {...props} title={title()} count={0} />}>
|
||||
<FileAccordionGroup>
|
||||
<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 (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<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" />
|
||||
return (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
<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 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</FileTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user