Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/defer-diff-card-render.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 27 additions & 7 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3001,6 +3016,8 @@ interface ApplyPatchFile {
movePath?: string
}

const HUNK_MARKER = /^\s*@@/m

ToolRegistry.register({
name: "apply_patch",
render(props) {
Expand Down Expand Up @@ -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 = () => (
<Show when={data.openDiff && files().some((file) => view(file))}>
<Show when={data.openDiff && files().some(hasHunk)}>
<span data-slot="tool-trigger-actions">
<Tooltip value={i18n.t("ui.messagePart.openInDiffViewer")} placement="top" gutter={4}>
<IconButton
Expand Down
31 changes: 22 additions & 9 deletions packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,32 @@ const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
const deferredMounts: Array<{ active: boolean; fn: () => 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
Expand Down
Loading