fix(app): stabilize virtual session timeline interactions (#28422)

This commit is contained in:
Luke Parker
2026-05-25 12:05:27 +10:00
committed by GitHub
parent 9495ecd536
commit f023c63a60
14 changed files with 1062 additions and 177 deletions

View File

@@ -36,7 +36,6 @@ describe("apply patch file", () => {
])[0]
expect(file).toBeDefined()
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(file!.view, "deletions")).toBe("one\n")
expect(text(file!.view, "additions")).toBe("two\n")
})

View File

@@ -29,6 +29,8 @@ export interface BasicToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
forceOpen?: boolean
defer?: boolean
locked?: boolean
@@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) {
open: props.defaultOpen ?? false,
ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => state.open
const open = () => props.open ?? state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
@@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) {
if (props.defer && open()) scheduleReady(true)
})
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
createEffect(() => {
if (props.forceOpen) setState("open", true)
if (!props.forceOpen) return
if (open()) return
setOpen(true)
})
createEffect(
@@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) {
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setState("open", value)
setOpen(value)
}
const trigger = () => (

View File

@@ -1,5 +1,7 @@
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import {
areFilesEqual,
areOptionsEqual,
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
@@ -88,6 +90,7 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
mode: "diff"
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: DiffPreload<T>
virtualize?: boolean
}
type DiffPairProps<T> = DiffBaseProps<T> & {
@@ -123,7 +126,7 @@ const sharedKeys = [
] as const
const textKeys = ["file", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
// ---------------------------------------------------------------------------
// Shared viewer hook
@@ -482,17 +485,24 @@ function notifyRendered(opts: {
function renderViewer<I extends RenderTarget>(opts: {
viewer: Viewer
current: I | undefined
reset?: boolean
create: () => I
update?: (value: I) => void
assign: (value: I) => void
draw: (value: I) => void
onReady: () => void
}) {
clearReadyWatcher(opts.viewer.ready)
opts.current?.cleanUp()
const next = opts.create()
opts.assign(next)
const reset = opts.reset === true && opts.current !== undefined
if (reset) opts.current?.cleanUp()
const next = reset || !opts.current ? opts.create() : opts.current
if (reset || !opts.current) {
opts.viewer.container.innerHTML = ""
opts.assign(next)
} else {
opts.update?.(next)
}
opts.viewer.container.innerHTML = ""
opts.draw(next)
applyViewerScheme(opts.viewer.getHost())
@@ -566,7 +576,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
}
}
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
@@ -576,6 +586,10 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): Vi
return {
get: () => {
if (!enabled()) {
release()
return
}
if (shared) return shared.virtualizer
const container = host()
@@ -858,15 +872,14 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
const virtualizer = virtuals.get()
renderViewer({
viewer,
current: instance,
reset: instance !== undefined,
create: () =>
isVirtual && virtualizer
virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
assign: (value) => {
@@ -907,6 +920,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
function DiffViewer<T>(props: DiffFileProps<T>) {
let instance: FileDiff<T> | undefined
let instanceVirtualizer: Virtualizer | undefined
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
let instanceFileDiff: FileDiffMetadata | undefined
let instanceBefore: FileContents | undefined
let instanceAfter: FileContents | undefined
let dragSide: DiffSelectionSide | undefined
let dragEndSide: DiffSelectionSide | undefined
let viewer!: Viewer
@@ -991,7 +1010,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter,
)
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const virtuals = createSharedVirtualStrategy(
() => viewer.container,
() => local.virtualize !== false,
)
const large = createMemo(() => {
if (local.fileDiff) {
@@ -1067,31 +1089,62 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
return sampledChecksum(contents)
}
const before = local.before ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) } : undefined
const after = local.after ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) } : undefined
const targetChanged =
local.fileDiff !== undefined
? instanceFileDiff !== local.fileDiff
: instanceFileDiff !== undefined ||
before === undefined ||
after === undefined ||
instanceBefore === undefined ||
instanceAfter === undefined ||
!areFilesEqual(instanceBefore, before) ||
!areFilesEqual(instanceAfter, after)
// Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
// Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
const reset =
instance !== undefined &&
(instanceVirtualizer !== virtualizer ||
instanceWorkerPool !== workerPool ||
(virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
renderViewer({
viewer,
current: instance,
reset,
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
update: (value) => value.setOptions(opts),
assign: (value) => {
instance = value
instanceVirtualizer = virtualizer
instanceWorkerPool = workerPool
instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
instanceFileDiff = local.fileDiff
instanceBefore = before
instanceAfter = after
},
draw: (value) => {
if (local.fileDiff) {
value.render({
fileDiff: local.fileDiff,
forceRender,
lineAnnotations: [],
containerWrapper: viewer.container,
})
return
}
if (!local.before || !local.after) return
if (!before || !after) return
value.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
oldFile: before,
newFile: after,
forceRender,
lineAnnotations: [],
containerWrapper: viewer.container,
})
@@ -1111,6 +1164,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
instanceVirtualizer = undefined
instanceWorkerPool = undefined
instanceVirtualHunkSeparators = undefined
instanceFileDiff = undefined
instanceBefore = undefined
instanceAfter = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined

View File

@@ -175,7 +175,10 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
deferToolContent?: boolean
virtualizeDiff?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -290,7 +293,7 @@ function getDirectory(path: string | undefined) {
}
import type { IconProps } from "./icon"
import { normalize } from "./session-diff"
import { normalize, resolveFileDiff } from "./session-diff"
export type ToolInfo = {
icon: IconProps["name"]
@@ -930,7 +933,7 @@ export function AssistantMessageDisplay(props: {
)
}
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(
@@ -938,11 +941,15 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const handleOpenChange = (value: boolean) => {
setOpen(value)
props.onSizeChange?.()
}
return (
<Collapsible
open={open()}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
@@ -1261,7 +1268,10 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1278,7 +1288,10 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
deferContent?: boolean
virtualizeDiff?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1376,6 +1389,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
})
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
return (
<Show when={!hideQuestion()}>
@@ -1399,6 +1414,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
error={error()}
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()}
href={taskHref()}
/>
@@ -1417,7 +1434,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
deferContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff}
/>
</Match>
</Switch>
@@ -1921,15 +1941,29 @@ ToolRegistry.register({
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
const diffSource = createMemo(
() => {
const filediff = props.metadata?.filediff
if (!filediff) return
return {
file: filediff.file || props.input.filePath || "",
patch: typeof filediff.patch === "string" ? filediff.patch : undefined,
before: typeof filediff.before === "string" ? filediff.before : undefined,
after: typeof filediff.after === "string" ? filediff.after : undefined,
}
},
undefined,
{
equals: (a, b) =>
a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after,
},
)
const fileCompProps = createMemo(() => {
try {
if (props.metadata?.filediff) {
const diff = normalize({
...props.metadata?.filediff,
status: "modified",
})
const fileDiff = diff.fileDiff
const source = diffSource()
if (source) {
const fileDiff = resolveFileDiff(source)
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
}
} catch {}
@@ -1987,7 +2021,7 @@ ToolRegistry.register({
}
>
<div data-component="edit-content">
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
</div>
</ToolFileAccordion>
</Show>
@@ -2171,6 +2205,7 @@ ToolRegistry.register({
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
/>
@@ -2243,7 +2278,7 @@ ToolRegistry.register({
}
>
<div data-component="apply-patch-file-diff">
<Dynamic component={fileComponent} mode="diff" fileDiff={single()!.view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} />
</div>
</ToolFileAccordion>
</BasicTool>

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalize, text } from "./session-diff"
import { normalize, resolveFileDiff, text } from "./session-diff"
describe("session diff", () => {
test("keeps unified patch content", () => {
@@ -13,8 +13,8 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(view.fileDiff.name).toBe("a.ts")
expect(view.fileDiff.isPartial).toBe(true)
expect(text(view, "deletions")).toBe("one\ntwo\n")
expect(text(view, "additions")).toBe("one\nthree\n")
})
@@ -34,7 +34,52 @@ describe("session diff", () => {
expect(text(view, "additions")).toBe("one\nthree")
})
test("converts legacy content into a patch", () => {
test("keeps separated patch hunks partial without complete file contents", () => {
const fileDiff = resolveFileDiff({
file: "project.ts",
patch:
'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
})
expect(fileDiff.isPartial).toBe(true)
expect(fileDiff.hunks).toHaveLength(2)
expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
})
test("renders headerless persisted patches", () => {
const view = normalize({
file: "a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified" as const,
})
expect(view.fileDiff.name).toBe("a.ts")
expect(view.fileDiff.isPartial).toBe(true)
expect(text(view, "deletions")).toBe("old\n")
expect(text(view, "additions")).toBe("new\n")
})
test("does not share headerless patch metadata between files", () => {
const patch = "@@ -1 +1 @@\n-old\n+new\n"
expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
})
test("keeps capped header-only patches partial", () => {
const fileDiff = resolveFileDiff({
file: "a.ts",
patch: "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
})
expect(fileDiff.name).toBe("a.ts")
expect(fileDiff.isPartial).toBe(true)
expect(fileDiff.hunks).toEqual([])
})
test("keeps full legacy content as a complete diff", () => {
const diff = {
file: "a.ts",
before: "one\n",
@@ -45,7 +90,7 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
expect(view.fileDiff.isPartial).toBe(false)
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
@@ -61,7 +106,6 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(text(view, "deletions")).toBe("")
expect(text(view, "additions")).toBe("")
})

View File

@@ -1,5 +1,5 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { formatPatch, parsePatch, structuredPatch } from "diff"
import { parsePatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = {
@@ -14,107 +14,35 @@ type LegacyDiff = {
type SnapshotDiff = SnapshotFileDiff & { file: string }
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
export type ViewDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
fileDiff: FileDiffMetadata
}
const cache = new Map<string, FileDiffMetadata>()
const diffCacheLimit = 16
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") {
try {
const [patch] = parsePatch(diff.patch)
const beforeLines: Array<{ text: string; newline: boolean }> = []
const afterLines: Array<{ text: string; newline: boolean }> = []
let previous: "-" | "+" | " " | undefined
const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1)
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("\\")) {
if (previous === "-" || previous === " ") {
const before = beforeLines.at(-1)
if (before) before.newline = false
}
if (previous === "+" || previous === " ") {
const after = afterLines.at(-1)
if (after) after.newline = false
}
continue
}
if (line.startsWith("-")) {
beforeLines.push({ text: line.slice(1), newline: true })
previous = "-"
} else if (line.startsWith("+")) {
afterLines.push({ text: line.slice(1), newline: true })
previous = "+"
} else {
// context line (starts with ' ')
beforeLines.push({ text: line.slice(1), newline: true })
afterLines.push({ text: line.slice(1), newline: true })
previous = " "
}
}
}
return {
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
patch: diff.patch,
patchIsPartial,
}
} catch {
return { before: "", after: "", patch: diff.patch, patchIsPartial: false }
}
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
after: "after" in diff && typeof diff.after === "string" ? diff.after : "",
patch: formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
),
patchIsPartial: false,
}
}
function file(file: string, patch: string, before: string, after: string, partial = false) {
const hit = cache.get(patch)
if (hit) return hit
let value: FileDiffMetadata | undefined
if (partial) value = parsePatchFiles(patch)[0]?.files[0]
if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
cache.set(patch, value)
return value
export function resolveFileDiff(diff: DiffSource) {
if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
return fileDiffFromContent(
diff.file,
typeof diff.before === "string" ? diff.before : "",
typeof diff.after === "string" ? diff.after : "",
)
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
return {
file: diff.file,
patch: next.patch,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff,
fileDiff: resolveFileDiff(diff),
}
}
@@ -122,3 +50,40 @@ export function text(diff: ViewDiff, side: "deletions" | "additions") {
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
return diff.fileDiff.additionLines.join("")
}
function fileDiffFromPatch(file: string, patch: string) {
const key = `${file}\0${patch}`
const hit = patchFileDiffCache.get(key)
if (hit) {
patchFileDiffCache.delete(key)
patchFileDiffCache.set(key, hit)
return hit
}
const input = patchInput(file, patch)
const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file)
patchFileDiffCache.set(key, value)
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
return value
}
function patchInput(file: string, patch: string) {
try {
const parsed = parsePatch(patch)[0]
if (!parsed) return
if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
if (!parsed.hunks.length) return
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
} catch {
return
}
}
function fileDiffFromContent(file: string, before: string, after: string) {
if (!before && !after) return emptyFileDiff(file)
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
}
function emptyFileDiff(file: string) {
return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
}

View File

@@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
error: string
title?: string
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
subtitle?: string
href?: string
}
@@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
open: props.defaultOpen ?? false,
copied: false,
})
const open = () => state.open
const open = () => props.open ?? state.open
const copied = () => state.copied
const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
const [split, rest] = splitProps(props, [
"tool",
"error",
"title",
"defaultOpen",
"open",
"onOpenChange",
"subtitle",
"href",
])
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
const name = createMemo(() => {
if (split.title) return split.title
const map: Record<string, string> = {
@@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
class="tool-collapsible"
data-open={open() ? "true" : "false"}
open={open()}
onOpenChange={(value) => setState("open", value)}
onOpenChange={setOpen}
>
<Collapsible.Trigger>
<div data-component="tool-trigger">