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
6 changes: 6 additions & 0 deletions .changeset/quick-diffs-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---

Improve Changes diff responsiveness when switching worktrees by deferring offscreen diff rendering and ignoring stale diff updates.
135 changes: 124 additions & 11 deletions packages/kilo-ui/src/components/diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,94 @@ import { getWorkerPool } from "@opencode-ai/ui/pierre/worker"

type SelectionSide = "additions" | "deletions"

const OBSERVER_MARGIN = "2000px 0px"
// Placeholder sizing only: keeps scroll position stable until Pierre renders.
// This does not affect which files are expanded or collapsed.
const ESTIMATED_LINE_HEIGHT = 20
const MIN_PLACEHOLDER_HEIGHT = 160
const MAX_PLACEHOLDER_HEIGHT = 1200
type Job = { run: () => void; cancelled: boolean }

// A review can contain many expanded diff components. Creating one
// IntersectionObserver per diff showed up in profiles, so all deferred diffs
// share a single observer and only register their element + render callback.
const watchers = new Map<Element, Job>()
const queue: Job[] = []
let shared: IntersectionObserver | undefined
let frame: number | undefined

function lines(text: string): number {
if (!text) return 0
let count = 1
const cap = MAX_PLACEHOLDER_HEIGHT / ESTIMATED_LINE_HEIGHT
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) !== 10) continue
count++
if (count >= cap) return cap
}
return count
}

function release(node: Element) {
if (!shared) return
shared.unobserve(node)
if (watchers.size > 0) return
shared.disconnect()
shared = undefined
}

function enqueue(job: Job) {
queue.push(job)
schedule()
}

// When a large batch of diffs becomes near-visible at once, render one diff per
// animation frame. This keeps the UI responsive while preserving expanded state.
function schedule() {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const job = queue.shift()
if (job && !job.cancelled) job.run()
if (queue.length > 0) schedule()
})
}

// Defer Pierre's expensive DOM render until the diff is close to the viewport.
// The caller still mounts an expanded diff container immediately, but the body
// render is queued here so offscreen expanded diffs do not block worktree
// switches or message handling.
function observe(node: Element, cb: () => void): () => void {
if (typeof IntersectionObserver === "undefined") {
cb()
return () => {}
}

const job: Job = { run: cb, cancelled: false }

shared ??= new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const item = watchers.get(entry.target)
if (!item) continue
watchers.delete(entry.target)
release(entry.target)
enqueue(item)
}
},
{ rootMargin: OBSERVER_MARGIN },
)

watchers.set(node, job)
shared.observe(node)
return () => {
job.cancelled = true
if (!watchers.delete(node)) return
release(node)
}
}

function findElement(node: Node | null): HTMLElement | undefined {
if (!node) return
if (node instanceof HTMLElement) return node
Expand Down Expand Up @@ -78,16 +166,25 @@ export function Diff<T>(props: DiffProps<T>) {
])

const mobile = createMediaQuery("(max-width: 640px)")
const [visible, setVisible] = createSignal(false)

const before = createMemo(() => {
if (local.fileDiff) return local.fileDiff.deletionLines.join("")
return typeof local.before?.contents === "string" ? local.before.contents : ""
})
const after = createMemo(() => {
if (local.fileDiff) return local.fileDiff.additionLines.join("")
return typeof local.after?.contents === "string" ? local.after.contents : ""
})

const estimate = createMemo(() => {
const value = Math.max(lines(before()), lines(after())) * ESTIMATED_LINE_HEIGHT
if (value === 0) return MIN_PLACEHOLDER_HEIGHT
return Math.max(MIN_PLACEHOLDER_HEIGHT, Math.min(value, MAX_PLACEHOLDER_HEIGHT))
})

const large = createMemo(() => {
if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("")
const after = local.fileDiff.additionLines.join("")
return Math.max(before.length, after.length) > 500_000
}
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
return Math.max(before().length, after().length) > 500_000
})

const largeOptions = {
Expand Down Expand Up @@ -125,6 +222,17 @@ export function Diff<T>(props: DiffProps<T>) {
return result.virtualizer
}

createEffect(() => {
if (visible()) return
container.style.minHeight = `${estimate()}px`
})

createEffect(() => {
if (visible()) return
Comment thread
marius-kilocode marked this conversation as resolved.
const cleanup = observe(container, () => setVisible(true))
onCleanup(cleanup)
})

const getRoot = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
Expand Down Expand Up @@ -361,7 +469,10 @@ export function Diff<T>(props: DiffProps<T>) {

const setSelectedLines = (range: SelectedLineRange | null) => {
const active = current()
if (!active) return
if (!active) {
lastSelection = range
return
}

const fixed = fixSelection(range)
if (fixed === undefined) {
Expand Down Expand Up @@ -569,6 +680,8 @@ export function Diff<T>(props: DiffProps<T>) {
}

createEffect(() => {
if (!visible()) return

const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
Expand Down Expand Up @@ -596,8 +709,8 @@ export function Diff<T>(props: DiffProps<T>) {
containerWrapper: container,
})
} else {
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const beforeContents = before()
const afterContents = after()

const cacheKey = (contents: string) => {
if (!large()) return sampledChecksum(contents, contents.length)
Expand Down
27 changes: 25 additions & 2 deletions packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export class WorktreeDiffController {
private session: string | undefined
private hash: string | undefined
private target: Target | undefined
/** Monotonic token for the active diff watch. Async work drops results when this changes. */
private epoch = 0
private applying: string | undefined

constructor(private readonly ctx: WorktreeDiffControllerContext) {}
Expand Down Expand Up @@ -148,40 +150,53 @@ export class WorktreeDiffController {
}

public async request(sessionId: string): Promise<void> {
const epoch = this.session === sessionId ? this.epoch : ++this.epoch
this.session = sessionId
this.target = undefined
await this.ready("stateReady rejected, continuing diff resolve:")
if (!this.current(epoch, sessionId)) return

const target = await this.resolve(sessionId)
if (!target) return
if (!this.current(epoch, sessionId)) return

this.target = { sessionId, ...target }
this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true })

try {
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
if (!this.current(epoch, sessionId)) return
this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`)
this.hash = hashFileDiffs(files)
this.session = sessionId
this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files })
} catch (error) {
this.ctx.log("Failed to fetch worktree diff:", error)
} finally {
this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false })
if (this.current(epoch, sessionId)) {
this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false })
}
}
}

public async requestFile(sessionId: string, file: string): Promise<void> {
if (!file) return
const epoch = this.epoch
await this.ready("stateReady rejected, continuing diff detail resolve:")
if (!this.current(epoch, sessionId)) return

const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId)
if (!target) return
if (!this.current(epoch, sessionId)) return

this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch }

try {
const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file)
if (!this.current(epoch, sessionId)) return
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data })
} catch (error) {
if (!this.current(epoch, sessionId)) return
this.ctx.log("Failed to fetch worktree diff file:", error)
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null })
}
Expand All @@ -196,17 +211,19 @@ export class WorktreeDiffController {
this.stop()
this.session = sessionId
this.hash = undefined
const epoch = this.epoch
this.ctx.log(`Starting diff polling for session ${sessionId}`)

void this.request(sessionId).then(() => {
if (this.session !== sessionId) return
if (!this.current(epoch, sessionId)) return
this.interval = setInterval(() => {
void this.poll(sessionId)
}, DIFF_POLL_INTERVAL_MS)
})
}

public stop(): void {
this.epoch++
if (this.interval) {
clearInterval(this.interval)
this.interval = undefined
Expand All @@ -217,11 +234,13 @@ export class WorktreeDiffController {
}

private async poll(sessionId: string): Promise<void> {
const epoch = this.epoch
const target = this.target?.sessionId === sessionId ? this.target : undefined
if (!target) return

try {
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
if (!this.current(epoch, sessionId)) return
const hash = hashFileDiffs(files)
if (hash === this.hash && this.session === sessionId) return
this.hash = hash
Expand All @@ -232,6 +251,10 @@ export class WorktreeDiffController {
}
}

private current(epoch: number, sessionId: string): boolean {
return this.epoch === epoch && this.session === sessionId
}

private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal()
const state = this.ctx.getState()
Expand Down
67 changes: 24 additions & 43 deletions packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1470,44 +1470,39 @@ const AgentManagerContent: Component = () => {
}
})

const selectedDiffSessionId = () => {
const sel = selection()
if (sel === LOCAL) return LOCAL
if (!sel) return undefined

const current = session.currentSessionID()
if (current) {
const item = managedSessions().find((entry) => entry.id === current)
if (item?.worktreeId === sel) return current
}

return managedSessions().find((entry) => entry.worktreeId === sel)?.id
}

const currentDiffSessionId = createMemo(selectedDiffSessionId)

// Start/stop diff watch when panel opens/closes, review tab opens, or session changes
createEffect(() => {
const panel = diffOpen()
const review = reviewActive()
const sel = selection()
const id = session.currentSessionID()
if (panel) {
if (sel === LOCAL) {
// For local tab, diff against unpushed changes using LOCAL sentinel
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL })
return
} else if (id) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
return
}
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
return
}
if (review) {
// Review tab is open but no specific session — use local sentinel for local,
// or any session in the selected worktree.
if (sel === LOCAL) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL })

if (panel || review) {
const id = currentDiffSessionId()
if (id) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
return
}
if (sel) {
const managed = managedSessions().find((ms) => ms.worktreeId === sel)
if (managed) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: managed.id })
return
}
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
setDiffLoading(false)
return
}

setDiffLoading(false)
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
})

Expand Down Expand Up @@ -1563,20 +1558,6 @@ const AgentManagerContent: Component = () => {
return []
})

const currentDiffSessionId = createMemo(() => {
const sel = selection()
if (sel === LOCAL) return LOCAL

const current = session.currentSessionID()
if (current) {
const item = managedSessions().find((entry) => entry.id === current)
if (sel && item?.worktreeId === sel) return current
}

if (!sel) return undefined
return managedSessions().find((entry) => entry.worktreeId === sel)?.id
})

const diffSessionKey = createMemo(() => {
const sel = selection()
if (sel === LOCAL) return `local:${LOCAL}`
Expand Down
Loading