perf(agent-manager): optimize worktree diff loading

This commit is contained in:
marius-kilocode
2026-08-25 12:17:33 +02:00
parent 9d262793a8
commit ea74bda399
21 changed files with 519 additions and 105 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees.
@@ -212,7 +212,7 @@ export class AgentManagerProvider implements Disposable {
log: (msg) => this.log(msg),
})
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
this.diffCatalog = new DiffSourceCatalog(this.connectionService)
this.diffCatalog = new DiffSourceCatalog(this.connectionService, local)
this.diffs = new WorktreeDiffController({
getState: () => this.getStateManager(),
getRoot: () => this.getRoot(),
@@ -44,6 +44,7 @@ interface ExecOptions {
env?: NodeJS.ProcessEnv
stdin?: string
timeout?: number
signal?: AbortSignal
}
export interface ExecResult {
@@ -594,12 +595,12 @@ export class GitOps {
* suitable for callers that need to tolerate legitimate failures (e.g.
* `merge-base` on an orphan branch, `ls-files --error-unmatch`).
*/
execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise<ExecResult> {
execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise<ExecResult> {
return this.exec(args, cwd, options)
}
execGitBuffer(args: string[], cwd: string): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd)
execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd, options)
}
private async exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
@@ -616,7 +617,7 @@ export class GitOps {
return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }
}
const invoke = () => this.invoke(cmd, args, cwd, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
return this.semaphore ? this.semaphore.run(invoke, options?.signal) : invoke()
}
private executable(): Promise<string> {
@@ -642,7 +643,7 @@ export class GitOps {
}
private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
if (this.controller.signal.aborted) {
if (this.controller.signal.aborted || options?.signal?.aborted) {
return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" })
}
@@ -664,6 +665,7 @@ export class GitOps {
: undefined
this.controller.signal.addEventListener("abort", abort, { once: true })
options?.signal?.addEventListener("abort", abort, { once: true })
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
@@ -673,6 +675,7 @@ export class GitOps {
child.on("close", (code) => {
if (timeout) clearTimeout(timeout)
this.controller.signal.removeEventListener("abort", abort)
options?.signal?.removeEventListener("abort", abort)
resolve({
code: code ?? 1,
stdout: Buffer.concat(out),
@@ -166,16 +166,16 @@ function statusFromCode(code: string): Status {
}
async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<Meta[]> {
const nameStatus = await git.execGit(
["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc],
dir,
)
const [nameStatus, counts, untracked] = await Promise.all([
git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], dir),
numstat(git, dir, anc),
git.execGit(["ls-files", "--others", "--exclude-standard"], dir),
])
if (nameStatus.code !== 0) {
log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() })
return []
}
const counts = await numstat(git, dir, anc)
const result: Meta[] = []
const seen = new Set<string>()
@@ -201,7 +201,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
})
}
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir)
if (untracked.code !== 0) {
log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() })
return result
@@ -265,28 +264,74 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?:
export function createLocalDiff(git: GitOps, log?: Log) {
const states = new Map<string, { anc: string; metas: Map<string, Meta> }>()
const generations = new Map<string, number>()
const details = new Map<string, { value: WorktreeDiffEntry; bytes: number }>()
const pending = new Map<string, { signal?: AbortSignal; work: Promise<WorktreeDiffEntry> }>()
let bytes = 0
const remember = (id: string, value: WorktreeDiffEntry) => {
const size =
(value.before?.length ?? 0) +
(value.after?.length ?? 0) +
(value.patch?.length ?? 0) +
(value.image?.before?.data?.length ?? 0) +
(value.image?.after?.data?.length ?? 0)
const current = details.get(id)
if (current) bytes -= current.bytes
details.delete(id)
details.set(id, { value, bytes: size })
bytes += size
while (details.size > 128 || bytes > 64 * 1024 * 1024) {
const key = details.keys().next().value!
bytes -= details.get(key)!.bytes
details.delete(key)
}
}
return {
summary: async (dir: string, base: string): Promise<WorktreeDiffEntry[]> => {
const id = `${dir}\0${base}`
const generation = (generations.get(id) ?? 0) + 1
generations.set(id, generation)
const anc = await ancestor(git, dir, base, log)
if (!anc) {
states.delete(id)
if (generations.get(id) === generation) states.delete(id)
return []
}
const items = await list(git, dir, anc, log)
if (generations.get(id) !== generation) return items.map(summarize)
states.delete(id)
states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) })
if (states.size > 8) states.delete(states.keys().next().value!)
return items.map(summarize)
},
file: async (dir: string, base: string, file: string): Promise<WorktreeDiffEntry | null> => {
file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise<WorktreeDiffEntry | null> => {
const state = states.get(`${dir}\0${base}`)
if (!state) return diffFile(git, dir, base, file, log)
const meta = state.metas.get(file)
if (!meta) return null
return materialize(git, dir, state.anc, meta, log)
const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.stamp}`
const cached = details.get(id)
if (cached) {
remember(id, cached.value)
return cached.value
}
const current = pending.get(id)
if (current && !current.signal?.aborted) return current.work
const work = materialize(git, dir, state.anc, meta, log, signal)
pending.set(id, { signal, work })
work.then(
(value) => {
if (pending.get(id)?.work !== work) return
pending.delete(id)
remember(id, value)
},
() => {
if (pending.get(id)?.work === work) pending.delete(id)
},
)
return work
},
}
}
@@ -343,8 +388,8 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
}
}
async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise<number> {
const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir)
async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise<number> {
const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal })
if (result.code !== 0) return 0
return parseInt(result.stdout.trim(), 10) || 0
}
@@ -356,8 +401,14 @@ async function fileSize(dir: string, file: string): Promise<number> {
return stat?.size ?? 0
}
async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise<Buffer | undefined> {
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir)
async function readBlob(
git: GitOps,
dir: string,
ref: string,
file: string,
signal?: AbortSignal,
): Promise<Buffer | undefined> {
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir, { signal })
return result.code === 0 ? result.stdout : undefined
}
@@ -369,9 +420,16 @@ async function readFile(dir: string, file: string): Promise<Buffer | undefined>
return readImageFile(full)
}
async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
async function readBefore(
git: GitOps,
dir: string,
anc: string,
file: string,
status: Status,
signal?: AbortSignal,
): Promise<string> {
if (status === "added") return ""
const result = await git.execGit(["show", `${anc}:${file}`], dir)
const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal })
return result.code === 0 ? result.stdout : ""
}
@@ -386,10 +444,17 @@ async function readAfter(dir: string, file: string, status: Status): Promise<str
return fs.readFile(full, "utf-8").catch(() => "")
}
async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise<string> {
async function unifiedPatch(
git: GitOps,
dir: string,
anc: string,
file: string,
signal?: AbortSignal,
): Promise<string> {
const result = await git.execGit(
["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file],
dir,
{ signal },
)
return result.code === 0 ? result.stdout : ""
}
@@ -418,15 +483,26 @@ export async function diffFile(
return materialize(git, dir, anc, meta, log)
}
async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise<WorktreeDiffEntry> {
async function materialize(
git: GitOps,
dir: string,
anc: string,
meta: Meta,
log?: Log,
signal?: AbortSignal,
): Promise<WorktreeDiffEntry> {
const mime = imageMime(meta.file)
if (meta.binary && !mime) return summarize(meta)
const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file)
const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file)
const [beforeBytes, afterBytes] = await Promise.all([
meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal),
meta.status === "deleted" ? 0 : fileSize(dir, meta.file),
])
if (mime) {
const image = await loadImage(
meta.file,
meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) },
meta.status === "added"
? undefined
: { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) },
meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) },
)
return { ...summarize(meta), summarized: false, image }
@@ -444,9 +520,12 @@ async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, lo
return summarize(meta)
}
const before = await readBefore(git, dir, anc, meta.file, meta.status)
const after = await readAfter(dir, meta.file, meta.status)
const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after)
const [before, after, tracked] = await Promise.all([
readBefore(git, dir, anc, meta.file, meta.status, signal),
readAfter(dir, meta.file, meta.status),
meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""),
])
const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after)
const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions
return {
file: meta.file,
@@ -7,12 +7,12 @@
*/
export class Semaphore {
private running = 0
private readonly pending: (() => void)[] = []
private readonly pending: { resolve: () => void; abort?: () => void }[] = []
constructor(private readonly limit: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
async run<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
await this.acquire(signal)
try {
return await fn()
} finally {
@@ -20,22 +20,33 @@ export class Semaphore {
}
}
private acquire(): Promise<void> {
private acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(signal.reason)
if (this.running < this.limit) {
this.running++
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pending.push(() => {
this.running++
resolve()
})
return new Promise<void>((resolve, reject) => {
const item = {
resolve: () => {
if (item.abort) signal?.removeEventListener("abort", item.abort)
this.running++
resolve()
},
abort: undefined as (() => void) | undefined,
}
item.abort = () => {
const index = this.pending.indexOf(item)
if (index !== -1) this.pending.splice(index, 1)
reject(signal?.reason)
}
signal?.addEventListener("abort", item.abort, { once: true })
this.pending.push(item)
})
}
private release(): void {
this.running--
const next = this.pending.shift()
if (next) next()
this.pending.shift()?.resolve()
}
}
@@ -180,6 +180,13 @@ export class SourceController {
this.send(this.messages.diffFile(source, file, null))
return
}
// Yield once so a worktree switch can advance the epoch before queued
// detail work enters the shared Git semaphore.
await new Promise<void>((resolve) => setTimeout(resolve, 0))
if (this.epoch !== epoch || this.active !== source) {
this.send(this.messages.diffFile(source, file, null))
return
}
const diff = await source.fetchFile(file).catch(() => null)
// Discard stale content after disposal/swap, but still complete the request
// so consumers can clear per-file loading state.
@@ -239,10 +246,15 @@ export class SourceController {
private startPolling(source: DiffSource, epoch: number): void {
this.stopPolling()
let busy = false
this.interval = setInterval(async () => {
if (busy) return
busy = true
// Self-cancel when the tick reports the source is done
const keep = await this.runFetch(source, epoch, false)
if (!keep) this.stopPolling()
const keep = await this.runFetch(source, epoch, false).finally(() => {
busy = false
})
if (!keep && this.epoch === epoch && this.active === source) this.stopPolling()
}, DIFF_POLL_INTERVAL_MS)
}
@@ -18,6 +18,12 @@ import {
import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn"
import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged"
import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged"
import type { WorktreeDiffEntry } from "../../agent-manager/types"
export interface LocalDiffSource {
summary: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
export interface WorkspaceBranchesResult {
branches: BranchListItem[]
@@ -68,7 +74,10 @@ export class DiffSourceCatalog implements vscode.Disposable {
private branchGit: GitOps | undefined
private branchOutput: vscode.OutputChannel | undefined
constructor(private readonly connection: KiloConnectionService) {}
constructor(
private readonly connection: KiloConnectionService,
private readonly local?: LocalDiffSource,
) {}
listAvailable(ctx: PanelContext): DiffSourceDescriptor[] {
if (ctx.hidePicker) return []
@@ -96,6 +105,8 @@ export class DiffSourceCatalog implements vscode.Disposable {
...opts,
baseBranchOverride: ctx.baseBranchOverride,
baseBranch: ctx.baseBranch,
summary: this.local?.summary,
file: this.local?.file,
})
}
@@ -45,6 +45,8 @@ export interface WorktreeDiffSourceOptions {
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
summary?: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
/**
@@ -57,6 +59,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const controller = new AbortController()
const root = (): string | undefined => {
const dir = opts.dir?.()
@@ -101,7 +104,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
}
const status: StatusResolver = async (current, file) => {
const entry = await diffFile(git, current.directory, current.baseBranch, file, log)
const entry = opts.file
? await opts.file(current.directory, current.baseBranch, file)
: await diffFile(git, current.directory, current.baseBranch, file, log)
return entry?.status
}
@@ -112,7 +117,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const current = await resolveTarget()
if (!current) return { diffs: [] }
const entries = await diffSummary(git, current.directory, current.baseBranch, log)
const entries = opts.summary
? await opts.summary(current.directory, current.baseBranch)
: await diffSummary(git, current.directory, current.baseBranch, log)
const diffs = entries.map(toDiffFile)
log(`Diff: ${diffs.length} file(s)`)
return { diffs }
@@ -124,7 +131,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
if (!current) return null
try {
const entry = await diffFile(git, current.directory, current.baseBranch, file, log)
const entry = opts.file
? await opts.file(current.directory, current.baseBranch, file, controller.signal)
: await diffFile(git, current.directory, current.baseBranch, file, log)
if (!entry) return null
return toDiffFile(entry)
} catch (err) {
@@ -152,6 +161,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
controller.abort()
target = undefined
},
}
@@ -29,6 +29,8 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"),
path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"),
path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"),
@@ -65,11 +65,22 @@ describe("createWorktreeDiffs", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true })
expect(diffs.diffLoading()).toBe(true)
expect(diffs.diffLoadingFor(() => "s1")).toBe(true)
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [] })
expect(diffs.diffLoadingFor(() => "s1")).toBe(false)
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false })
expect(diffs.diffLoading()).toBe(false)
})
})
it("keeps loading isolated to its composite diff id", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1#branch", loading: true })
expect(diffs.diffLoadingFor(() => "s1#branch")).toBe(true)
expect(diffs.diffLoadingFor(() => "s2#branch")).toBe(false)
})
})
it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => {
withDiffs((diffs, sent) => {
diffs.requestDiffFile("s1", "a.ts")
@@ -352,6 +352,36 @@ describe("diffFile", () => {
})
})
it("reuses cached detail while the summary stamp is unchanged", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
const local = createLocalDiff(git())
await local.summary(dir, base)
const first = await local.file(dir, base, "seed.txt")
const second = await local.file(dir, base, "seed.txt")
expect(second).toBe(first)
})
})
it("invalidates cached detail after the summary stamp changes", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n")
const local = createLocalDiff(git())
await local.summary(dir, base)
const first = await local.file(dir, base, "seed.txt")
await new Promise((resolve) => setTimeout(resolve, 5))
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nsecond value\n")
await local.summary(dir, base)
const second = await local.file(dir, base, "seed.txt")
expect(second).not.toBe(first)
expect(second?.after).toBe("seed\nsecond value\n")
})
})
it("does not materialize binary detail from a cached summary", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03]))
@@ -69,6 +69,22 @@ describe("Semaphore", () => {
expect(order).toEqual([1, 2, 3])
})
it("removes an aborted task from the pending queue", async () => {
const sem = new Semaphore(1)
let release: () => void = () => {}
const first = sem.run(() => new Promise<void>((resolve) => (release = resolve)))
const controller = new AbortController()
const aborted = sem.run(async () => "aborted", controller.signal)
const next = sem.run(async () => "next")
controller.abort(new Error("cancelled"))
await expect(aborted).rejects.toThrow("cancelled")
release()
expect(await next).toBe("next")
await first
})
it("allows full concurrency when limit exceeds task count", async () => {
const sem = new Semaphore(10)
let running = 0
@@ -346,6 +346,36 @@ describe("SourceController.requestFile", () => {
controller.stop()
})
it("does not start queued detail work after the source changes", async () => {
let details = 0
const workspace: DiffSource = {
descriptor: WORKSPACE_DESC,
async fetch() {
return { diffs: [] }
},
async fetchFile() {
details++
return null
},
}
const session: DiffSource = {
descriptor: SESSION_DESC,
async fetch() {
return { diffs: [] }
},
}
const { controller } = make({ workspace, "session:s1": session })
controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" })
await controller.activate("workspace")
const request = controller.requestFile("foo.ts")
await controller.activate("session:s1")
await request
expect(details).toBe(0)
controller.stop()
})
it("posts null when a pending fetchFile result is invalidated by stop", async () => {
let release: () => void = () => {}
const workspace: DiffSource = {
@@ -162,14 +162,14 @@ import {
import { createEmbeddedTerminalReader } from "./terminal/output"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
import { DiffPanel } from "./DiffPanel"
import { DiffPanelCache } from "./DiffPanelCache"
import { PRPanelHost } from "./pr/PRPanelHost"
import { createRevertFile } from "./revert-file"
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
import { createApplyToLocal } from "./apply-to-local"
import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs"
import type { ReviewComment } from "../diff-viewer/review-comments"
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
import { createReviewComposers } from "./review-composers"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
import { randomColor } from "./section-colors"
@@ -309,6 +309,7 @@ const AgentManagerContent: Component = () => {
let pendingSidebarWidth: number | undefined
const [history, setHistory] = createSignal(false)
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
const [diffMounted, setDiffMounted] = createSignal(false)
const diffOpen = () => sidePanel() === SidePanel.Diff
const prOpen = () => sidePanel() === SidePanel.PR
const activePR = createMemo(() => {
@@ -319,6 +320,7 @@ const AgentManagerContent: Component = () => {
return { pr, selected, wt: worktrees().find((w) => w.id === selected) }
})
const diffs = createWorktreeDiffs(vscode, activeProjectId)
createEffect(on(activeProjectId, diffs.reset, { defer: true }))
const diffDatas = diffs.diffDatas
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
@@ -330,7 +332,7 @@ const AgentManagerContent: Component = () => {
setReviewActive(false)
setSidePanel(SidePanel.Terminal)
}
const reviewComposer = createReviewComposer()
const composers = createReviewComposers(currentProjectId)
const reviewState = createReviewState()
const reviewOpenByContext = reviewState.open
const setReviewOpenByContext = reviewState.setOpen
@@ -524,7 +526,6 @@ const AgentManagerContent: Component = () => {
setPendingDelete(null)
}
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
createEffect(
on(
selection,
@@ -1752,9 +1753,12 @@ const AgentManagerContent: Component = () => {
}
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId))
const diffLoadingForCurrent = createMemo(() => diffs.diffLoadingFor(diffScopeId))
const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId)
createEffect(() => diffOpen() && setDiffMounted(true))
const handleShowKeyboardShortcuts = () => {
const categories = buildShortcutCategories(kb(), t)
dialog.show(() => (
@@ -2603,7 +2607,9 @@ const AgentManagerContent: Component = () => {
mounted while a side terminal is alive — hidden via
.am-side-host-hidden (absolute + opacity), never
unmounted, so xterm render loops keep streaming. */}
<Show when={sidePanel() !== null || terms.sides().length > 0 || subagents.tabs().length > 0}>
<Show
when={sidePanel() !== null || diffMounted() || terms.sides().length > 0 || subagents.tabs().length > 0}
>
<div
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
style={{ width: `${panelWidth()}px` }}
@@ -2620,42 +2626,49 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<div class="am-diff-panel-wrapper">
<Show when={sidePanel() === SidePanel.Diff}>
<DiffPanel
diffs={reviewDiffs()}
loading={diffLoading()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={activeDiffSession()}
sessionKey={diffSessionKey()}
notice={diffNotice()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onSendClick={() => metrics.track("send_review_comments", "side_review")}
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
onExpand={
selection() !== null
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
: undefined
}
onRequestDiff={requestDiffFile}
onOpenFile={(file, line) => {
const id = diffCtx()
if (id)
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
}}
onOpenDocument={documentInspector.open}
onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
/>
</Show>
<DiffPanelCache
current={diffScopeId}
context={diffCtx}
project={activeProjectId}
active={() => diffOpen() && !history() && !reviewActive()}
data={diffDatas}
loading={(key) => diffs.diffLoadingFor(() => key)}
loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)}
notice={(key) => diffNotices()[key]}
comments={(ctx) =>
readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx)
}
setComments={(ctx, comments) =>
setReviewCommentsByContext((prev) =>
setReviewComments(prev, currentProjectId() ?? "single", ctx, comments),
)
}
composer={composers.get}
lead={() => diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
onSendClick={() => metrics.track("send_review_comments", "side_review")}
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
onExpand={
selection() !== null
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
: undefined
}
onRequestDiff={diffs.requestDiffFile}
onOpenFile={(ctx, file, line) =>
vscode.postMessage({ type: "agentManager.openFile", sessionId: ctx, filePath: file, line })
}
onOpenDocument={documentInspector.open}
onRevertFile={(key, ctx, file) => {
metrics.track("revert_file", "side_review")
revertCtl.revertFor(key, ctx, review.scope(), file)
}}
revertingFiles={revertCtl.revertingFor}
activeTerminalId={terms.activeId()}
/>
<Show when={sidePanel() === SidePanel.PR && activePR()}>
<PRPanelHost
pr={activePR()!.pr}
@@ -2726,7 +2739,7 @@ const AgentManagerContent: Component = () => {
<div class="am-review-host" style={{ display: reviewActive() && !terms.activeId() ? undefined : "none" }}>
<FullScreenDiffView
diffs={reviewDiffs()}
loading={diffLoading()}
loading={diffLoadingForCurrent()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={activeDiffSession()}
sessionKey={diffSessionKey()}
@@ -2736,7 +2749,7 @@ const AgentManagerContent: Component = () => {
canComment={scopeCapabilities(review.scope()).comments}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
composer={composers.get(`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`)}
onSendAll={closeReviewTab}
onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")}
diffStyle={diffStyle.style()}
@@ -8,7 +8,6 @@ import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
@@ -74,6 +73,7 @@ const DIFF_NOTICE_KEYS: Record<string, string> = {
interface DiffPanelProps {
diffs: WorktreeFileDiff[]
loading: boolean
active?: boolean
loadingFiles?: Set<string>
sessionId?: string
sessionKey?: string
@@ -264,7 +264,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
diffs: () => props.diffs,
open,
loading: () => props.loadingFiles,
send: () => props.onRequestDiff,
send: () => (props.active === false ? undefined : props.onRequestDiff),
})
// --- CRUD ---
@@ -327,6 +327,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
on(
() => [props.diffs, comments()] as const,
([diffs, current]) => {
if (props.active === false) return
const valid = sanitizeReviewComments(current, diffs)
if (valid.length !== current.length) {
setComments(valid)
@@ -553,7 +554,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
<Spinner />
<span>{t("session.review.loadingChanges")}</span>
</div>
</Show>
@@ -714,10 +714,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
fallback={
<div class="am-diff-summary-state">
<Show when={isLoadingDetail()} fallback={<span>Diff preview loads on demand.</span>}>
<>
<Spinner />
<span>Loading diff...</span>
</>
<span>Loading diff...</span>
</Show>
</div>
}
@@ -0,0 +1,110 @@
import { For, createEffect, createMemo, createSignal, type Accessor, type Component, type JSX } from "solid-js"
import type { WorktreeFileDiff } from "../src/types/messages"
import type { ReviewComment } from "../diff-viewer/review-comments"
import type { ReviewComposer } from "../diff-viewer/review-annotations"
import { DiffPanel } from "./DiffPanel"
const CACHE_SIZE = 4
interface Entry {
key: string
cacheKey: string
ctx: string
used: number
}
interface Props {
current: Accessor<string | undefined>
context: Accessor<string | undefined>
project: Accessor<string | undefined>
active: Accessor<boolean>
data: Accessor<Record<string, WorktreeFileDiff[]>>
loading: (key: string) => boolean
loadingFiles: (key: string) => Set<string>
notice: (key: string) => string | undefined
comments: (ctx: string) => ReviewComment[]
setComments: (ctx: string, comments: ReviewComment[]) => void
composer: (key: string) => ReviewComposer
lead: () => JSX.Element
canRevert: boolean
diffStyle: "unified" | "split"
onDiffStyleChange: (style: "unified" | "split") => void
markdownRender: boolean
onMarkdownRenderChange: (render: boolean) => void
onSendClick: () => void
onClose: () => void
onExpand?: () => void
onRequestDiff: (key: string, file: string) => void
onOpenFile: (ctx: string, file: string, line?: number) => void
onOpenDocument: (file: string) => void
onRevertFile: (key: string, ctx: string, file: string) => void
revertingFiles: (key: string) => Set<string>
activeTerminalId?: string
}
export const DiffPanelCache: Component<Props> = (props) => {
const [entries, setEntries] = createSignal<Entry[]>([])
let used = 0
createEffect(() => {
if (!props.active()) return
const key = props.current()
const ctx = props.context()
const project = props.project() ?? "single"
if (!key || !ctx) return
const cacheKey = `${project}\0${key}`
setEntries((prev) => {
const prefix = `${project}\0`
const scoped = prev.filter((item) => item.cacheKey.startsWith(prefix))
const current = scoped.find((item) => item.cacheKey === cacheKey)
if (current) {
current.used = ++used
return scoped
}
const next = [...scoped, { key, cacheKey, ctx, used: ++used }]
if (next.length <= CACHE_SIZE) return next
const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry))
return next.filter((item) => item !== oldest)
})
})
return (
<For each={entries()}>
{(entry) => {
const active = createMemo(
() => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey,
)
return (
<div class="am-diff-panel-cache" classList={{ "am-diff-panel-cache-active": active() }} inert={!active()}>
<DiffPanel
diffs={props.data()[entry.key] ?? []}
loading={props.loading(entry.key)}
active={active()}
loadingFiles={props.loadingFiles(entry.key)}
sessionKey={entry.key}
notice={props.notice(entry.key)}
lead={active() ? props.lead() : undefined}
canRevert={props.canRevert}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
markdownRender={props.markdownRender}
onMarkdownRenderChange={props.onMarkdownRenderChange}
comments={props.comments(entry.ctx)}
onCommentsChange={(comments) => props.setComments(entry.ctx, comments)}
composer={props.composer(entry.cacheKey)}
onSendClick={props.onSendClick}
onClose={props.onClose}
onExpand={props.onExpand}
onRequestDiff={(file) => props.onRequestDiff(entry.key, file)}
onOpenFile={(file, line) => props.onOpenFile(entry.ctx, file, line)}
onOpenDocument={props.onOpenDocument}
onRevertFile={(file) => props.onRevertFile(entry.key, entry.ctx, file)}
revertingFiles={props.revertingFiles(entry.key)}
activeTerminalId={props.activeTerminalId}
/>
</div>
)
}}
</For>
)
}
@@ -1939,6 +1939,24 @@ body.am-wt-dragging-active * {
overflow: hidden;
}
.am-diff-panel-cache {
position: absolute;
inset: 0;
display: flex;
min-width: 0;
min-height: 0;
opacity: 0;
pointer-events: none;
z-index: 0;
background: var(--surface-base);
}
.am-diff-panel-cache-active {
opacity: 1;
pointer-events: auto;
z-index: 2;
}
.am-diff-panel-wrapper > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
@@ -28,9 +28,9 @@ export function createRevertFile(
return files()[id] ?? new Set<string>()
})
function revert(file: string) {
const id = diffScopeId()
const context = ctx()
const revertingFor = (id: string) => files()[id] ?? new Set<string>()
function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) {
if (!id || !context) return
setFiles((prev) => {
const set = new Set(prev[id] ?? [])
@@ -42,10 +42,14 @@ export function createRevertFile(
projectId: projectId?.(),
sessionId: context,
file,
scope: scope(),
scope: source,
})
}
function revert(file: string) {
revertFor(diffScopeId(), ctx(), scope(), file)
}
function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) {
setFiles((prev) => {
const set = new Set(prev[ev.sessionId] ?? [])
@@ -62,5 +66,5 @@ export function createRevertFile(
}
}
return { reverting, revert, onResult }
return { reverting, revertingFor, revert, revertFor, onResult }
}
@@ -0,0 +1,24 @@
import type { Accessor } from "solid-js"
import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations"
export function createReviewComposers(project: Accessor<string | undefined>) {
const values = new Map<string, ReviewComposer>()
const get = (key: string) => {
const current = values.get(key)
if (current) return current
const next = createReviewComposer()
values.set(key, next)
return next
}
const clear = (ctx: string | null) => {
if (!ctx) return
const prefix = `${project() ?? "single"}\0${ctx}`
for (const key of values.keys()) {
if (key === prefix || key.startsWith(`${prefix}#`)) values.delete(key)
}
}
return { get, clear }
}
@@ -34,10 +34,18 @@ export function createWorktreeDiffs(
project: () => string | undefined = () => undefined,
) {
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
const [diffLoading, setDiffLoading] = createSignal(false)
const [diffLoadings, setDiffLoadings] = createSignal<Record<string, true>>({})
const diffLoading = () => Object.keys(diffLoadings()).length > 0
const [diffNotices, setDiffNotices] = createSignal<Record<string, string | undefined>>({})
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
const reset = () => {
setDiffDatas({})
setDiffLoadings({})
setDiffNotices({})
setDiffFileLoading({})
}
const setDiffFilePending = (sessionId: string, file: string, value: boolean) => {
setDiffFileLoading((prev) => {
const session = prev[sessionId] ?? {}
@@ -93,6 +101,13 @@ export function createWorktreeDiffs(
return new Set(Object.keys(diffFileLoading()[id] ?? {}))
}
/** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */
const diffLoadingFor = (sessionId: Accessor<string | undefined>) => {
const id = sessionId()
if (!id) return false
return diffLoadings()[id] === true && !(id in diffDatas())
}
// Backend messages.
const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => {
@@ -122,7 +137,18 @@ export function createWorktreeDiffs(
}
const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => {
setDiffLoading(ev.loading)
// One source is active per project. Replacing the map on start also clears
// an interrupted source whose stale completion is intentionally discarded.
if (ev.loading) {
setDiffLoadings({ [ev.sessionId]: true })
return
}
setDiffLoadings((prev) => {
if (!prev[ev.sessionId]) return prev
const next = { ...prev }
delete next[ev.sessionId]
return next
})
}
const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => {
@@ -132,11 +158,13 @@ export function createWorktreeDiffs(
return {
diffDatas,
diffLoading,
setDiffLoading,
setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}),
diffNotices,
requestDiffFile,
refreshStaleDiffs,
diffFileLoadingFor,
diffLoadingFor,
reset,
onWorktreeDiff,
onWorktreeDiffFile,
onWorktreeDiffLoading,
@@ -36,7 +36,7 @@ export function createDiffRequests(opts: DiffRequestOptions) {
createEffect(
on(
() => [opts.open(), opts.diffs(), opts.loading()] as const,
() => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const,
([open, diffs]) => {
const files = new Set(open)
for (const file of requested.keys()) {