fix(app): align file tool disclosures with Patch (#47269)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-09-04 19:47:57 +08:00 committed by GitHub
parent 632b65854c
commit b52b79d28f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 203 additions and 193 deletions

View file

@ -0,0 +1,76 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`file tools share Patch's upfront file list in ${theme}`, async ({ mount }, info) => {
const root = await mount("current-session-research-agents--agent-research", {
args: { scenario: "workflow" },
globals: { theme },
})
const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" })
const disclosure = group.getByRole("button", { name: /^Used .*Edit.*Write.*Patch$/ })
await disclosure.click()
for (const name of ["edit", "write", "patch"]) {
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
const file = tool.locator('[data-slot="accordion-trigger"]')
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator('[data-component="file"]')).toHaveCount(0)
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-component="file"]')).toBeVisible()
await expect(file).toBeFocused()
await file.press("Space")
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.press("Enter")
await expect(file).toHaveAttribute("aria-expanded", "true")
}
await disclosure.click()
await disclosure.click()
for (const name of ["edit", "write", "patch"]) {
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
await expect(tool.locator('[data-slot="accordion-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-component="file"]')).toBeVisible()
}
await root.screenshot({ path: info.outputPath(`file-tools-${theme}.png`) })
})
}
for (const tool of ["edit", "write"]) {
for (const controlled of [false, true]) {
story(`${tool} supports forceOpen with ${controlled ? "controlled" : "local"} disclosure`, async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { tool, controlled, forceOpen: true },
})
await expect(root.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
await expect(root.locator('[data-component="file"]')).toBeVisible()
})
}
story(`${tool} preserves input fallback and disclosure through completion`, async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", { args: { tool } })
const file = root.getByRole("button", { name: /example\.ts/ })
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(root.getByText("1 file", { exact: true })).toBeVisible()
await file.click()
await expect(root.locator('[data-component="file"]')).toContainText(tool === "edit" ? "after" : "written")
await root.getByRole("button", { name: "Complete file tool" }).click()
await expect(root.getByText("Example diagnostic")).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "true")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(root.getByText("Example diagnostic")).toBeVisible()
})
}
story("empty writes still show a file row", async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { tool: "write", empty: true },
})
await root.getByRole("button", { name: "Complete file tool" }).click()
const file = root.getByRole("button", { name: /example\.ts/ })
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await expect(root.locator('[data-component="file"]')).toBeAttached()
await expect(file).toBeVisible()
})

View file

@ -25,18 +25,17 @@ story("renders every admitted tool family and hides timeline-only exclusions", a
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit).toContainText("Edit")
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("font-size", "13px")
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("line-height", "16px")
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")
}
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})

View file

@ -466,102 +466,6 @@
--tool-content-gap: 8px;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
[data-slot="message-part-title-area"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
[data-slot="message-part-title"] {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-family: var(--font-family-sans);
font-size: 13px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-compact);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-muted);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--v2-text-text-base);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-directory"] {
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--v2-text-text-base);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
}
[data-component="edit-content"] {
border-radius: inherit;
border-top: 0.5px solid var(--v2-border-border-muted);

View file

@ -11,6 +11,7 @@ import {
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
import { ToolDisplay } from "../tools/tool-renderer"
export default {
title: "OpenCode/Work/File changes",
@ -261,3 +262,48 @@ export const CreatedANewFile = {
/>
),
}
export const FileToolFallbacks = {
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
argTypes: {
tool: { control: "select", options: ["edit", "write"] },
empty: { control: "boolean" },
forceOpen: { control: "boolean" },
controlled: { control: "boolean" },
},
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
const [state, setState] = createStore({ completed: false, open: false })
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("completed", true)}>
Complete file tool
</button>
<CurrentSessionProviders document={storyDocument([])}>
<ToolDisplay
id="tool_file_fallback"
tool={args.tool}
status={state.completed ? "completed" : "running"}
input={{
path: "src/example.ts",
oldString: "export const before = true\n",
newString: "export const after = true\n",
content: args.empty ? "" : "export const written = true\n",
}}
metadata={{
diagnostics: state.completed
? {
"src/example.ts": [
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
],
}
: {},
}}
open={args.controlled ? state.open : undefined}
onOpenChange={(open) => setState("open", open)}
forceOpen={args.forceOpen}
/>
</CurrentSessionProviders>
</section>
)
},
}

View file

@ -951,20 +951,66 @@ export const ToolRegistry = {
render: getTool,
}
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
const i18n = useI18n()
return (
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: props.title,
subtitle: props.count > 0 ? `${props.count} ${i18n.plural("ui.common.file", props.count)}` : "",
}}
>
{props.children}
</BasicTool>
)
}
function ToolFileAccordion(props: {
path: string
actions?: JSX.Element
children: JSX.Element
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
forceOpen?: boolean
defer?: boolean
}) {
const value = createMemo(() => props.path || "tool-file")
const [expanded, setExpanded] = createSignal(props.defaultOpen ?? false)
const [visible, setVisible] = createSignal(false)
const open = () => props.open ?? expanded()
const change = (value: boolean) => {
if (props.open === undefined) setExpanded(value)
props.onOpenChange?.(value)
}
createEffect(() => {
if (props.forceOpen && !open()) change(true)
})
createEffect(() => {
if (!open()) {
setVisible(false)
return
}
const frame = requestAnimationFrame(() => setVisible(true))
onCleanup(() => cancelAnimationFrame(frame))
})
return (
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
defaultValue={props.defaultOpen === false ? [] : [value()]}
value={open() ? [value()] : []}
onChange={(next) => change(next.includes(value()))}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@ -986,7 +1032,9 @@ function ToolFileAccordion(props: {
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>{props.children}</Accordion.Content>
<Accordion.Content>
<Show when={!props.defer || visible()}>{props.children}</Show>
</Accordion.Content>
</Accordion.Item>
</Accordion>
)
@ -1709,7 +1757,6 @@ ToolRegistry.register({
const value = diff()
return typeof value?.file === "string" ? value.file : inputPath()
})
const filename = () => getFilename(inputPath())
const pending = () => props.status === "streaming" || props.status === "running"
const diffSource = createMemo(
() => {
@ -1749,39 +1796,15 @@ ToolRegistry.register({
return (
<div data-component="edit-tool">
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && inputPath().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(inputPath())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending() ? diff() : undefined}>
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
</Show>
</div>
</div>
}
>
<FileTool {...props} title={i18n.t("ui.messagePart.title.edit")} 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}
actions={
<Show when={!pending() ? diff() : undefined}>
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
@ -1800,7 +1823,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</FileTool>
</div>
)
},
@ -1814,38 +1837,18 @@ ToolRegistry.register({
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()))
const filename = () => getFilename(path())
const pending = () => props.status === "streaming" || props.status === "running"
return (
<div data-component="write-tool">
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && path().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(path())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
</div>
}
>
<Show when={content() && path()}>
<ToolFileAccordion path={path()}>
<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}
@ -1862,7 +1865,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</FileTool>
</div>
)
},
@ -1891,27 +1894,9 @@ ToolRegistry.register({
files().forEach((file) => props.onFileOpenChange?.(file.path, next.includes(file.path)))
}
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
return `${count} ${i18n.plural("ui.common.file", count)}`
})
return (
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: title(),
subtitle: subtitle(),
}}
>
<FileTool {...props} title={title()} count={files().length}>
<Show when={files().length > 0}>
<Accordion
multiple
@ -2000,7 +1985,7 @@ ToolRegistry.register({
</Index>
</Accordion>
</Show>
</BasicTool>
</FileTool>
</div>
)
},