From 6258c1943af26d7491ec9611aa21e459339d4676 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:51:53 +0800 Subject: [PATCH] feat(app): group adjacent patch calls (#43901) --- .../session-timeline-file-projection.spec.ts | 7 +- .../session-timeline-file-state.spec.ts | 16 ++-- .../session-timeline-projection.spec.ts | 40 +++++++- .../src/session/timeline/message-timeline.tsx | 2 +- .../session-ui/src/components/basic-tool.tsx | 27 ++++-- .../src/components/message-part.css | 3 +- .../src/message/current-message.test.ts | 6 +- .../src/message/current-message.tsx | 9 +- .../src/message/current-tool-state.ts | 3 +- .../src/timeline/projection.test.ts | 18 ++++ .../session-ui/src/timeline/projection.ts | 59 ++++++++---- .../src/timeline/rows-current.test.ts | 72 ++++++++++++++ .../src/timeline/session-timeline-row.tsx | 19 ++++ .../session-ui/src/timeline/timeline-row.ts | 5 + .../session-ui/src/tools/tool-renderer.tsx | 94 ++++++++++--------- 15 files changed, 290 insertions(+), 90 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index 94da7242de..ffe877a82f 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -47,5 +47,10 @@ test("renders a completed single-file patch", async ({ page }) => { settings: { editToolPartsExpanded: true }, }) - await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible() + const wrapper = page.locator(`[data-timeline-part-id="${id}"]`) + const file = wrapper.locator('[data-scope="apply-patch"]') + await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false") + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0) + await file.getByRole("button").click() + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() }) diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index f36bd7628a..8318a685a6 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" import { createTwoFilesPatch } from "diff" -test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => { +test("keeps patch file disclosures independent", async ({ page }) => { const patchID = "prt_nested_patch" const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")] await setupTimeline(page, { @@ -21,15 +21,17 @@ test("preserves nested patch file state through outer collapse and reopen", asyn settings: { editToolPartsExpanded: true }, }) const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`) - const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]') const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]') + await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3) await deleted.getByRole("button").click() await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") - await outer.click() - await expect(outer).toHaveAttribute("aria-expanded", "false") - await outer.click() - await expect(outer).toHaveAttribute("aria-expanded", "true") - await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") + await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false") + await modified.getByRole("button").click() + await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true") + await deleted.getByRole("button").click() + await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false") + await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true") }) function patchFile(file: string, status: "added" | "modified" | "deleted") { diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 500a169dbb..56ad586cb9 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -69,9 +69,43 @@ test.describe("session timeline projection", () => { ]) { await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() } + const patch = page.locator('[data-timeline-part-id="prt_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[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) await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) }) + test("combines adjacent patch calls into one file group", async ({ page }) => { + const first = "prt_patch_first" + const second = "prt_patch_second" + 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")] }, + }), + ]), + ], + }) + + 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('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"]) + await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0) + }) + test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { const firstUser = userMessage( [ @@ -196,11 +230,7 @@ function patchPart(id: string) { { patchText: "Update the projected files" }, { metadata: { - files: [ - patchFile("src/a.ts", "modified"), - patchFile("src/b.ts", "added"), - patchFile("src/old.ts", "deleted"), - ], + files: [patchFile("src/a.ts", "modified")], }, }, ) diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 238f3b312c..92196e8674 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -469,7 +469,7 @@ function MessageTimelineView( deferred={(row) => { if (row._tag !== "AssistantPart" || row.group.type !== "part") return false const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID) - return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name) + return content?.type === "tool" && ["edit", "write"].includes(content.name) }} renderRow={(row, onSizeChange) => ( <> diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index 74ba359060..ac8302823d 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -258,19 +258,32 @@ export function BasicTool(props: BasicToolProps) { return ( + {trigger()} + + } > - {trigger()} - + + {trigger()} + + } > [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] { + > [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"], + > [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] { position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; diff --git a/packages/session-ui/src/message/current-message.test.ts b/packages/session-ui/src/message/current-message.test.ts index 8e1f00d13a..a9653ac513 100644 --- a/packages/session-ui/src/message/current-message.test.ts +++ b/packages/session-ui/src/message/current-message.test.ts @@ -26,10 +26,10 @@ describe("current content default open", () => { test("uses the file-change disclosure preference", () => { expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true) expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false) - expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true) + expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true) }) - test("keeps deletion-only changes collapsed", () => { + test("opens deletion-only patches", () => { expect( currentContentDefaultOpen( tool("patch", [ @@ -39,6 +39,6 @@ describe("current content default open", () => { false, true, ), - ).toBe(false) + ).toBe(true) }) }) diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index ebe7352571..f024013d2d 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -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, ToolDisplay } from "../tools/tool-renderer" +import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer" import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state" export type { SessionUserActions, SessionUserComment } from "../actions" @@ -109,3 +109,10 @@ export function SessionContextToolGroup(props: { /> ) } + +export function SessionPatchToolGroup(props: { + tools: SessionMessageAssistantTool[] + onSizeChange?: () => void +}) { + return +} diff --git a/packages/session-ui/src/message/current-tool-state.ts b/packages/session-ui/src/message/current-tool-state.ts index a1c46f751d..ae1bc47dd5 100644 --- a/packages/session-ui/src/message/current-tool-state.ts +++ b/packages/session-ui/src/message/current-tool-state.ts @@ -36,7 +36,8 @@ export function currentContentDefaultOpen( ) { if (content.type !== "tool") return undefined if (content.name === "shell" || content.name === "execute") return shellExpanded - if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined + if (content.name === "patch") return true + if (content.name !== "edit" && content.name !== "write") return undefined if (!editExpanded) return false const files = currentToolMetadata(content).files if (!Array.isArray(files) || files.length === 0) return true diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 4de84dd0cc..5900c54ab9 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -17,6 +17,17 @@ const context = ( previousAssistantPart: false, }) +const patch = (key: string, partIDs: string[], userMessageID = "user-1") => + new TimelineRow.AssistantPart({ + userMessageID, + group: { + key, + type: "patch", + refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })), + } satisfies PartGroup, + previousAssistantPart: false, + }) + const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID }) const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key) @@ -36,6 +47,13 @@ describe("reuseTimelineRows", () => { expected: ["assistant-part:user-1:context:a"], reused: [], }, + { + name: "preserves a patch group key when a member is appended", + previous: [patch("patch:a", ["a"])], + rows: [patch("patch:a", ["a", "b"])], + expected: ["assistant-part:user-1:patch:a"], + reused: [], + }, { name: "preserves the group key when the first member is removed", previous: [context("context:a", ["a", "b"])], diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 7d70d324ea..8dbf2edfa7 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -15,8 +15,8 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } type Notice = Exclude type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice } type Content = SessionMessageAssistant["content"][number] -type ContextRow = Extract -type PriorContext = { index: number; row: ContextRow } +type GroupRow = Extract +type PriorGroup = { index: number; row: GroupRow } const contextTools = new Set(["read", "glob", "grep", "list"]) const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) @@ -309,20 +309,20 @@ export namespace Timeline { export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) - const contextByPart = new Map() + const groupByPart = new Map() previous.forEach((row, index) => { - if (row._tag !== "AssistantPart" || row.group.type !== "context") return - row.group.refs.forEach((ref) => contextByPart.set(contextPartKey(row.userMessageID, ref), { index, row })) + if (row._tag !== "AssistantPart" || row.group.type === "part") return + row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row })) }) const reserved = new Map() rows.forEach((row, index) => { - if (row._tag !== "AssistantPart" || row.group.type !== "context") return + if (row._tag !== "AssistantPart" || row.group.type === "part") return const key = TimelineRow.key(row) if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index) }) const claimed = new Set() const next = rows.map((input, index) => { - const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed) + const row = stabilizeGroupKey(groupByPart, reserved, input, index, claimed) const existing = byKey.get(TimelineRow.key(row)) if (!existing) return row return TimelineRow.equals(existing, row) ? existing : row @@ -398,16 +398,16 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) { return result } -function stabilizeContextKey( - contextByPart: Map, +function stabilizeGroupKey( + groupByPart: Map, reserved: Map, row: TimelineRow.TimelineRow, rowIndex: number, claimed: Set, ) { - if (row._tag !== "AssistantPart" || row.group.type !== "context") return row - const existing = row.group.refs.reduce((result, ref) => { - const candidate = contextByPart.get(contextPartKey(row.userMessageID, ref)) + if (row._tag !== "AssistantPart" || row.group.type === "part") return row + const existing = row.group.refs.reduce((result, ref) => { + const candidate = groupByPart.get(groupPartKey(row.userMessageID, ref)) if (!candidate) return result const key = TimelineRow.key(candidate.row) if (claimed.has(key)) return result @@ -426,7 +426,7 @@ function stabilizeContextKey( }) } -function contextPartKey(userMessageID: string, ref: PartRef) { +function groupPartKey(userMessageID: string, ref: PartRef) { return `${userMessageID}:${ref.messageID}:${ref.partID}` } @@ -440,17 +440,38 @@ function renderable(content: Content, showReasoning: boolean) { function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] { const groups: PartGroup[] = [] - let context: PartRef[] = [] + let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined const flush = () => { - const first = context[0] + const current = adjacent + const first = current?.refs[0] if (!first) return - groups.push({ type: "context", key: `context:${first.messageID}:${first.partID}`, refs: context }) - context = [] + 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, + key: + current.type === "patch" + ? `part:${first.messageID}:${first.partID}` + : `context:${first.messageID}:${first.partID}`, + refs: current.refs, + }) + adjacent = undefined } items.forEach((item) => { - if (item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)) { - context.push({ messageID: item.messageID, partID: item.partID }) + const type = + item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content) + ? "context" + : item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error" + ? "patch" + : undefined + if (type) { + if (adjacent?.type !== type) flush() + adjacent ??= { type, refs: [] } + adjacent.refs.push({ messageID: item.messageID, partID: item.partID }) return } flush() diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 3c50364806..a40e603453 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -359,6 +359,78 @@ describe("current session timeline rows", () => { ]) }) + test("groups adjacent successful patches and leaves failed patches separate", () => { + const source = [ + { id: "msg_user", type: "user", text: "edit", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "tool_patch_1", + name: "patch", + state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } }, + time: { created: 2, completed: 3 }, + }, + { + type: "tool", + id: "tool_patch_2", + name: "patch", + state: { status: "running", input: {}, metadata: { files: [] } }, + time: { created: 4 }, + }, + { + type: "tool", + id: "tool_patch_failed", + name: "patch", + state: { + status: "error", + input: {}, + error: { type: "ToolError", message: "failed" }, + metadata: { files: [] }, + }, + time: { created: 5, completed: 6 }, + }, + { + type: "tool", + id: "tool_patch_3", + name: "patch", + state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } }, + time: { created: 7, completed: 8 }, + }, + ], + time: { created: 2, completed: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" }) + const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : [])) + + expect(groups).toEqual([ + { + type: "patch", + key: "part:msg_assistant:tool_patch_1", + refs: [ + { messageID: "msg_assistant", partID: "tool_patch_1" }, + { messageID: "msg_assistant", partID: "tool_patch_2" }, + ], + }, + { + type: "part", + key: "part:msg_assistant:tool_patch_failed", + ref: { messageID: "msg_assistant", partID: "tool_patch_failed" }, + }, + { + type: "part", + key: "part:msg_assistant:tool_patch_3", + ref: { messageID: "msg_assistant", partID: "tool_patch_3" }, + }, + ]) + }) + test("places a divider after interrupted output unless the turn compacts", () => { const messages = [ { id: "msg_user", type: "user", text: "continue", time: { created: 1 } }, diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 28bfc82446..1e33851a99 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -14,6 +14,7 @@ import { MessageDivider, SessionAssistantContent, SessionContextToolGroup, + SessionPatchToolGroup, SessionShellMessage, SessionUserMessage, currentContentDefaultOpen, @@ -97,6 +98,24 @@ export function createSessionTimelineRowRenderer(input: { ) } + if (row().group.type === "patch") { + const tools = createMemo(() => { + const group = row().group + if (group.type !== "patch") 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] : [] + }) + }) + return ( + + ) + } + const ref = createMemo(() => { const group = row().group return group.type === "part" ? group.ref : undefined diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index cb9a1669d2..8f19fa0d21 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -16,6 +16,11 @@ export type PartGroup = type: "context" refs: PartRef[] } + | { + key: string + type: "patch" + refs: PartRef[] + } export namespace TimelineRow { export class TurnGap extends Data.TaggedClass("TurnGap")<{ diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 84db2530a4..a0dc5a6f21 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -562,6 +562,40 @@ export function CurrentContextToolGroup(props: { ) } +export function CurrentPatchToolGroup(props: { + tools: SessionMessageAssistantTool[] + onSizeChange?: () => void +}) { + const metadata = createMemo(() => ({ + files: props.tools.flatMap((tool) => { + const files = currentToolMetadata(tool).files + return Array.isArray(files) ? files : [] + }), + })) + const pending = createMemo(() => + props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"), + ) + const render = ToolRegistry.render("patch") ?? GenericTool + + return ( +
tool.id).join(",")} + > + +
+ ) +} + function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: ReturnType) { const input = currentToolInput(tool) const metadata = currentToolMetadata(tool) @@ -640,7 +674,7 @@ export const ToolRegistry = { render: getTool, } -function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { +function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) { const value = createMemo(() => props.path || "tool-file") return ( @@ -648,7 +682,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre multiple data-scope="apply-patch" style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }} - defaultValue={[value()]} + defaultValue={props.defaultOpen === false ? [] : [value()]} > @@ -1433,22 +1467,12 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => patchFiles(props.metadata.files)) - const pending = createMemo(() => props.status === "streaming" || props.status === "running") const single = createMemo(() => { const list = files() if (list.length !== 1) return undefined return list[0] }) const [expanded, setExpanded] = createSignal([]) - let seeded = false - - createEffect(() => { - const list = files() - if (list.length === 0) return - if (seeded) return - seeded = true - setExpanded(list.filter((file) => file.type !== "delete").map((file) => file.path)) - }) const subtitle = createMemo(() => { const count = files().length @@ -1463,9 +1487,12 @@ ToolRegistry.register({
setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > - {(file) => { - const active = createMemo(() => expanded().includes(file.path)) + {(file, index) => { + const value = () => `${index()}:${file.path}` + const active = createMemo(() => expanded().includes(value())) const [visible, setVisible] = createSignal(false) createEffect(() => { @@ -1497,7 +1525,7 @@ ToolRegistry.register({ }) return ( - +
@@ -1561,39 +1589,17 @@ ToolRegistry.register({
-
-
- - - - - {getFilename(single()!.path)} - -
- -
- {displayDirectory(single()!.path)} -
-
-
-
- - - -
-
- } >