Compare commits

...
12 changed files with 418 additions and 134 deletions
+11 -8
View File
@@ -92,15 +92,18 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
output: result,
content: toModelContent(result),
metadata: {
files: [fileDiff(result.resource, current?.text ?? "", formatted, current ? "modified" : "added")],
},
}
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
}),
)
.pipe(Effect.orDie)
+63 -1
View File
@@ -119,6 +119,17 @@ describe("WriteTool", () => {
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: {
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
],
},
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -158,6 +169,20 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
metadata: {
files: [
{
file: "formatted.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+FORMAT ME"),
},
],
},
})
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
@@ -180,6 +205,7 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.metadata).toEqual(fixture.assertions[0]?.metadata)
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [
{
@@ -215,7 +241,22 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
const settled = yield* executeTool(
registry,
call({ path: "preserved.txt", content: "after" }, "call-preserved"),
)
expect(settled).toMatchObject({
metadata: {
files: [
{
file: "preserved.txt",
status: "modified",
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
},
})
if (settled.status === "completed") expect(JSON.stringify(settled.metadata)).not.toContain("\uFEFF")
yield* executeTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
@@ -230,6 +271,27 @@ describe("WriteTool", () => {
}),
)
it.live("reports zero-change metadata for empty and unchanged writes", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
const created = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-empty"))
expect(created).toMatchObject({
status: "completed",
metadata: { files: [{ file: "empty.txt", status: "added", additions: 0, deletions: 0 }] },
})
const unchanged = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-unchanged"))
expect(unchanged).toMatchObject({
status: "completed",
metadata: { files: [{ file: "empty.txt", status: "modified", additions: 0, deletions: 0 }] },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "empty.txt"), "utf8"))).toBe("")
}),
)
}),
)
it.live("accepts an absolute file path inside the active Location", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
@@ -1,38 +1,49 @@
import { expect, story } from "../../storybook/playwright/story"
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups")
const group = root.locator('[data-component="collapsed-tool-group"]')
const patches = group.locator('[data-component="apply-patch-tool"]')
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(/^3 /)
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toBeVisible()
await group.screenshot({ path: info.outputPath("merged.png") })
})
for (const separator of ["shell", "error", "reasoning"]) {
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
for (const tool of ["patch", "edit", "write", "mixed"]) {
story(`merges follow-up ${tool} calls into one stack with a distinct file count`, async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { tool } })
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
const patches = group.locator('[data-component="apply-patch-tool"]')
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await expect(
patches.getByLabel(tool === "mixed" ? "Edit" : `${tool[0].toUpperCase()}${tool.slice(1)}`, { exact: true }),
).toBeVisible()
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(group).toHaveAttribute(
"data-timeline-part-ids",
tool === "patch" ? "patch_shell,patch_first,patch_next" : "patch_shell,first_0,first_1,next_0,next_1",
)
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toHaveCount(2)
await expect(patches.locator('[data-component="file"]').nth(0)).toBeVisible()
await expect(patches.locator('[data-component="file"]').nth(1)).toBeVisible()
await expect(patches.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(2)
await group.screenshot({ path: info.outputPath("merged.png") })
})
for (const separator of ["shell", "error", "reasoning"]) {
story(`does not merge ${tool} calls across an intervening ${separator}`, async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator, tool } })
await expect(root.locator("[data-file-tool]")).toHaveAttribute("data-file-tool", tool)
await expect(root.locator("[data-file-separator]")).toHaveAttribute("data-file-separator", separator)
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
})
}
}
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
@@ -46,3 +57,33 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
})
for (const placement of ["separate", "grouped"]) {
story(
`preserves mixed file disclosures through append and split in ${placement} timeline rows`,
async ({ mount, page }) => {
await page.setViewportSize({ width: placement === "grouped" ? 390 : 1280, height: 900 })
const root = await mount("current-tool-group--patch-follow-ups", {
args: { tool: "mixed", placement, separator: "reasoning" },
})
const timeline = root.locator('[data-component="session-timeline"]')
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
const stacks = timeline.locator('[data-component="apply-patch-tool"]')
const first = stacks.first().locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await expect(stacks).toHaveCount(1)
if (placement === "grouped") await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(stacks).toHaveCount(1)
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
await expect(stacks).toHaveCount(2)
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
const second = stacks.nth(1).locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await expect(second).toHaveAttribute("aria-expanded", "false")
},
)
}
@@ -92,3 +92,36 @@ story("keeps patch file disclosures independent", async ({ mount }) => {
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(deleted).toHaveAttribute("aria-expanded", "false")
})
for (const placement of ["separate", "grouped"]) {
for (const profile of [
{ tool: "edit", empty: false, noOp: false },
{ tool: "write", empty: false, noOp: false },
{ tool: "write", empty: true, noOp: false },
{ tool: "edit", empty: false, noOp: true },
{ tool: "write", empty: true, noOp: true },
]) {
story(
`keeps ${profile.empty ? "empty " : ""}${profile.tool} input fallback with ${profile.noOp ? "zero-change" : "missing"} metadata in ${placement} rows`,
async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { ...profile, timeline: true, placement },
})
const timeline = root.locator('[data-component="session-timeline"]')
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
const fallback = timeline.locator(`[data-component="${profile.tool}-tool"]`)
await expect(fallback).toHaveCount(1)
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
if (placement === "grouped") await fallback.getByRole("button", { name: /example\.ts/ }).click()
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
await root.getByRole("button", { name: "Complete file tool" }).click()
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
await expect(fallback.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
await expect(timeline.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
},
)
}
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { currentContentDefaultOpen } from "./current-tool-state"
import { currentContentDefaultOpen, currentToolCanGroupFiles } from "./current-tool-state"
function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantTool {
return {
@@ -17,6 +17,33 @@ function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantToo
}
}
describe("current file grouping eligibility", () => {
test.each(["edit", "write"])("keeps %s input fallbacks unless changed files are available", (name) => {
const unchanged = { file: "src/example.ts", patch: "", status: "modified", additions: 0, deletions: 0 }
expect(currentToolCanGroupFiles(tool(name, [unchanged]))).toBe(false)
expect(currentToolCanGroupFiles(tool(name))).toBe(false)
expect(currentToolCanGroupFiles({ ...tool(name), state: { status: "running", input: {}, metadata: {} } })).toBe(
false,
)
expect(
currentToolCanGroupFiles(
tool(name, [{ ...unchanged, patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1 }]),
),
).toBe(true)
expect(
currentToolCanGroupFiles({
...tool(name),
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "failed" },
metadata: { files: [{ ...unchanged, additions: 1 }] },
},
}),
).toBe(false)
})
})
describe("current content default open", () => {
test("uses the shell disclosure preference", () => {
expect(currentContentDefaultOpen(tool("shell"), true, false)).toBe(true)
@@ -14,6 +14,23 @@ export function currentToolMetadata(tool: SessionMessageAssistantTool): Record<s
return tool.state.metadata ?? empty
}
export function currentToolCanGroupFiles(tool: SessionMessageAssistantTool) {
if (tool.state.status === "error") return false
if (tool.name === "patch") return true
// Keep the input-based renderer when older edit/write results have no file diffs.
if (tool.name !== "edit" && tool.name !== "write") return false
const files = currentToolMetadata(tool).files
if (!Array.isArray(files)) return false
// Empty and unchanged results still need their filename and content preview.
return files.some(
(file) =>
!!file &&
typeof file === "object" &&
(("additions" in file && typeof file.additions === "number" && file.additions > 0) ||
("deletions" in file && typeof file.deletions === "number" && file.deletions > 0)),
)
}
export function currentToolOutput(tool: SessionMessageAssistantTool) {
if (tool.state.status === "running") {
const output = tool.state.metadata.output
@@ -264,44 +264,98 @@ export const CreatedANewFile = {
}
export const FileToolFallbacks = {
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
args: {
tool: "edit",
empty: false,
forceOpen: false,
controlled: true,
timeline: false,
placement: "separate",
noOp: false,
},
argTypes: {
tool: { control: "select", options: ["edit", "write"] },
empty: { control: "boolean" },
forceOpen: { control: "boolean" },
controlled: { control: "boolean" },
timeline: { control: "boolean" },
noOp: { control: "boolean" },
placement: { control: "select", options: ["separate", "grouped"] },
},
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
render: (args: {
tool: string
empty: boolean
forceOpen: boolean
controlled: boolean
timeline: boolean
noOp: boolean
placement: "separate" | "grouped"
}) => {
const [state, setState] = createStore({ completed: false, open: false })
const document = createMemo(() =>
storyDocument([
storyTool(
"tool_file_fallback",
args.tool,
state.completed ? "completed" : "running",
{
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:
state.completed && args.noOp
? {
files: [
{
file: "src/example.ts",
patch: createTwoFilesPatch("src/example.ts", "src/example.ts", "", ""),
status: "modified",
additions: 0,
deletions: 0,
},
],
}
: undefined,
},
),
]),
)
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 document={document()}>
{args.timeline ? (
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
) : (
<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>
)
@@ -8,7 +8,12 @@ import type {
} from "@opencode-ai/client/promise"
import { Option, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
import {
currentContentDefaultOpen,
currentToolCanGroupFiles,
currentToolFailed,
currentToolHasLoadedFiles,
} from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
@@ -597,7 +602,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 +670,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 (currentToolCanGroupFiles(content)) return "file"
return undefined
}
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
@@ -684,8 +688,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 (currentToolCanGroupFiles(content)) return "file"
return undefined
}
@@ -4,7 +4,7 @@ import type {
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -625,7 +625,7 @@ describe("current session timeline rows", () => {
])
})
test("groups adjacent successful patches and leaves failed patches separate", () => {
test("groups adjacent patch, edit, and write calls and leaves failed calls separate", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
{
@@ -681,16 +681,23 @@ describe("current session timeline rows", () => {
type: "tool",
id: "tool_edit_1",
name: "edit",
state: { status: "running", input: {}, metadata: { files: [] } },
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
time: { created: 9 },
},
{
type: "tool",
id: "tool_edit_2",
name: "edit",
state: { status: "running", input: {}, metadata: { files: [] } },
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
time: { created: 10 },
},
{
type: "tool",
id: "tool_write",
name: "write",
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/written.ts")] } },
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" },
],
},
])
@@ -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", "part", "part", "file", "context"] },
{ shell: true, edit: true, types: ["part", "part", "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 } },
@@ -23,7 +23,7 @@ import type { ContextGroupPart } from "../tools/tool-renderer"
import { SessionRetry } from "../components/session-retry"
import { SessionError } from "../components/session-error"
import { timelineCategory, type TimelineDetail } from "./detail"
import { currentToolFailed } from "../message/current-tool-state"
import { currentToolCanGroupFiles, currentToolFailed } from "../message/current-tool-state"
import {
createReactiveTimelineProjection,
Timeline,
@@ -72,7 +72,7 @@ export function createSessionTimelineRowRenderer(input: {
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" || !currentToolCanGroupFiles(content)) return
const part = `${ref.messageID}:${ref.partID}`
const key = patchGroupKeys.get(part)
if (key && !owners.has(key)) owners.set(key, part)
@@ -223,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
if (open !== undefined) return open
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
if (!["edit", "write"].includes(tools()[0]?.name ?? "") || 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)}
@@ -4,6 +4,7 @@ import { createTwoFilesPatch } from "diff"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
import { SessionTimeline } from "../timeline/session-timeline"
export default {
title: "OpenCode/Work/Tool group",
@@ -78,36 +79,59 @@ export const MixedReasoning = {
}
export const PatchFollowUps = {
args: { separator: "none" },
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
render: (args: { separator: string }) => {
args: { separator: "none", tool: "patch", placement: "used" },
argTypes: {
separator: { control: "select", options: ["none", "shell", "error", "reasoning"] },
tool: { control: "select", options: ["patch", "edit", "write", "mixed"] },
placement: { control: "select", options: ["used", "separate", "grouped"] },
},
render: (args: { separator: string; tool: string; placement: "used" | "separate" | "grouped" }) => {
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
const source = (value: number) => `export const value = ${value}\n`
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(
path,
path,
`export const value = ${before}\n`,
`export const value = ${after}\n`,
"",
"",
{ context: Infinity },
),
patch: createTwoFilesPatch(path, path, source(before), source(after)),
})
const changes = (next: boolean) => {
const files = next
? [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)]
: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)]
const status = next && state.phase === "running" ? "running" : "completed"
if (args.tool === "patch")
return [
storyTool(
next ? "patch_next" : "patch_first",
"patch",
status,
{},
{ metadata: status === "running" ? {} : { files } },
),
]
return files.map((file, index) => {
const name = args.tool === "mixed" ? (next ? ["patch", "write"] : ["edit", "write"])[index]! : args.tool
return storyTool(
`${next ? "next" : "first"}_${index}`,
name,
status,
name === "patch"
? { patchText: `Update ${file.file}` }
: name === "write"
? { path: file.file, content: source(next && index === 0 ? 2 : 1) }
: {
path: file.file,
oldString: source(next && index === 0 ? 1 : 0),
newString: source(next && index === 0 ? 2 : 1),
},
{ metadata: status === "running" ? {} : { files: [file] } },
)
})
}
const parts = createMemo<ContextGroupPart[]>(() => [
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
storyTool(
"patch_first",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
},
),
...changes(false),
...(state.phase === "initial"
? []
: [
@@ -115,7 +139,15 @@ export const PatchFollowUps = {
? [storyTool("patch_separator", "shell", "completed", { command: "printf checked" })]
: []),
...(args.separator === "error"
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
? [
storyTool(
"patch_error",
args.tool === "mixed" ? "write" : args.tool,
"error",
{},
{ error: "File change failed" },
),
]
: []),
...(args.separator === "reasoning" && state.reasoning
? [
@@ -126,19 +158,16 @@ export const PatchFollowUps = {
},
]
: []),
storyTool(
"patch_next",
"patch",
state.phase === "running" ? "running" : "completed",
{},
{
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
},
),
...changes(true),
]),
])
const document = createMemo(() => storyDocument(parts()))
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<section
class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6"
data-file-tool={args.tool}
data-file-separator={args.separator}
>
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start follow-up patch
@@ -152,13 +181,20 @@ export const PatchFollowUps = {
</button>
</Show>
</div>
<CurrentSessionProviders document={storyDocument(parts())}>
<CurrentContextToolGroup
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
<CurrentSessionProviders document={document()}>
<Show
when={args.placement !== "used"}
fallback={
<CurrentContextToolGroup
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
}
>
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
</Show>
</CurrentSessionProviders>
</section>
)
@@ -45,6 +45,7 @@ import type {
import {
currentToolError,
currentToolHasLoadedFiles,
currentToolCanGroupFiles,
currentToolInput,
currentToolMetadata,
currentToolOutput,
@@ -547,11 +548,10 @@ export function CurrentContextToolGroup(props: {
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
currentToolCanGroupFiles(tool) &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
previous[0] &&
currentToolCanGroupFiles(previous[0])
) {
previous.push(tool)
return groups
@@ -577,7 +577,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] || !currentToolCanGroupFiles(item[0])) return
const key = props.patchGroupKey?.(item) ?? item[0].id
item.forEach((tool) => keys.set(tool, key))
})
@@ -682,7 +682,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={currentToolCanGroupFiles(tool())}
fallback={
<ToolDisplay
id={tool().id}
@@ -836,7 +836,7 @@ 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(() => props.tools[0]?.name ?? "patch")
return (
<div
@@ -1879,7 +1879,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()