diff --git a/.changeset/quick-diffs-switch.md b/.changeset/quick-diffs-switch.md new file mode 100644 index 00000000000..8a64e6d28da --- /dev/null +++ b/.changeset/quick-diffs-switch.md @@ -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. diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index 05391907849..43abaaf2459 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -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() +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 @@ -78,16 +166,25 @@ export function Diff(props: DiffProps) { ]) 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 = { @@ -125,6 +222,17 @@ export function Diff(props: DiffProps) { return result.virtualizer } + createEffect(() => { + if (visible()) return + container.style.minHeight = `${estimate()}px` + }) + + createEffect(() => { + if (visible()) return + const cleanup = observe(container, () => setVisible(true)) + onCleanup(cleanup) + }) + const getRoot = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -361,7 +469,10 @@ export function Diff(props: DiffProps) { const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() - if (!active) return + if (!active) { + lastSelection = range + return + } const fixed = fixSelection(range) if (fixed === undefined) { @@ -569,6 +680,8 @@ export function Diff(props: DiffProps) { } createEffect(() => { + if (!visible()) return + const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const virtualizer = getVirtualizer() @@ -596,8 +709,8 @@ export function Diff(props: DiffProps) { 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) diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 4939d703ace..fdafce3f5ae 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -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) {} @@ -148,16 +150,22 @@ export class WorktreeDiffController { } public async request(sessionId: string): Promise { + 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 @@ -165,23 +173,30 @@ export class WorktreeDiffController { } 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 { 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 }) } @@ -196,10 +211,11 @@ 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) @@ -207,6 +223,7 @@ export class WorktreeDiffController { } public stop(): void { + this.epoch++ if (this.interval) { clearInterval(this.interval) this.interval = undefined @@ -217,11 +234,13 @@ export class WorktreeDiffController { } private async poll(sessionId: string): Promise { + 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 @@ -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() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index c363e7d0154..1d92e2b47d4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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" }) }) @@ -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}`