diff --git a/.changeset/defer-diff-card-render.md b/.changeset/defer-diff-card-render.md new file mode 100644 index 00000000000..7fef344ac7f --- /dev/null +++ b/.changeset/defer-diff-card-render.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Render collapsed diff tool cards without parsing and mount deferred tool bodies within a frame budget, so expanded transcripts fill in faster. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ab7312411b3..a5717bbbd33 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2774,7 +2774,11 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) - const view = createMemo(() => { + // A plain function, not `createMemo`: Solid evaluates a memo eagerly on + // render, which parsed the patch with Pierre even while the card stayed + // collapsed. This is only read when the deferred body mounts or the user + // opens the diff viewer, so collapsed cards do no parse work. + const view = () => { const diff = props.metadata?.filediff if (diff?.patch) return normalize(diff) // Pending state: tool-part metadata.filediff is written only after the @@ -2789,8 +2793,15 @@ ToolRegistry.register({ additions: diff?.additions ?? 0, deletions: diff?.deletions ?? 0, }) - }) - const canOpenDiff = () => !!data.openDiff && !!path() && !!view() + } + const canOpenDiff = () => { + if (!data.openDiff || !path()) return false + // Presence check instead of `view()` so the always-rendered trigger does + // not parse the patch while the card stays collapsed. + const diff = props.metadata?.filediff + if (diff?.patch) return true + return !!(props.input.oldString || props.input.newString) + } const openDiff = () => { const v = view() if (!canOpenDiff() || !v) return @@ -2886,12 +2897,16 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) - const view = createMemo(() => { + // Lazy like the edit card: only parsed when the deferred body mounts or the + // user opens the diff viewer, never while the card is collapsed. + const view = () => { const diff = props.metadata?.filediff if (!diff?.patch) return return normalize(diff) - }) - const canOpenDiff = () => !!data.openDiff && !!props.input.filePath && !!view() + } + // Cheap presence check instead of `view()` so a collapsed card never + // parses its patch with Pierre just to decide whether to show the button. + const canOpenDiff = () => !!data.openDiff && !!props.input.filePath && !!props.metadata?.filediff?.patch const openDiff = () => { const v = view() if (!data.openDiff || !props.input.filePath || !v) return @@ -3001,6 +3016,8 @@ interface ApplyPatchFile { movePath?: string } +const HUNK_MARKER = /^\s*@@/m + ToolRegistry.register({ name: "apply_patch", render(props) { @@ -3053,8 +3070,11 @@ ToolRegistry.register({ if (!data.openDiff || !first) return data.openDiff(diffs.length === 1 ? first : { ...first, files: diffs }) } + // Cheap `@@` marker check: keeps the trigger hidden for unparsable patches + // like the `view` guard did, without parsing every file while collapsed. + const hasHunk = (file: ApplyPatchFile) => HUNK_MARKER.test(file.patch ?? file.diff ?? "") const allDiffAction = () => ( - view(file))}> + void }> = [] let deferredFrame: number | undefined +// kilocode_change start +// Mount deferred tool bodies within a per-frame time budget. Mounting one body +// per frame kept each diff card's render off a single frame, but an expanded +// transcript with many cards then needed one frame per card before everything +// was visible. Spend a fixed budget per frame so cheap bodies mount together. +// A body whose duration exceeds the budget ends that frame, so later bodies +// wait for the next one. +const DEFERRED_MOUNT_BUDGET_MS = 12 + function flushDeferredMounts() { - while (deferredMounts.length > 0) { - // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. - // Pop from the end so heavy default-open bodies near the bottom become interactive first. - const item = deferredMounts.pop()! - if (item.active) { - deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined - item.fn() - return + const deadline = performance.now() + DEFERRED_MOUNT_BUDGET_MS + // Re-arm in `finally`: a throw from one body must not leave `deferredFrame` + // pointing at an already-fired frame, which would stall every later mount. + try { + while (deferredMounts.length > 0) { + // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. + // Pop from the end so heavy default-open bodies near the bottom become interactive first. + const item = deferredMounts.pop()! + if (item.active) item.fn() + if (performance.now() >= deadline) break } + } finally { + deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined } - deferredFrame = undefined } +// kilocode_change end function scheduleDeferredFlush() { if (deferredFrame !== undefined) return