From ea74bda3996d55ace921a80b96e3ddb65636652b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 12:17:33 +0200 Subject: [PATCH 01/11] perf(agent-manager): optimize worktree diff loading --- .changeset/fast-agent-manager-diffs.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 2 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 13 +- .../src/agent-manager/local-diff.ts | 125 ++++++++++++++---- .../src/agent-manager/semaphore.ts | 33 +++-- .../kilo-vscode/src/diff/SourceController.ts | 16 ++- .../kilo-vscode/src/diff/sources/catalog.ts | 13 +- .../kilo-vscode/src/diff/sources/worktree.ts | 16 ++- .../tests/unit/agent-manager-arch.test.ts | 2 + .../unit/agent-manager-worktree-diffs.test.ts | 11 ++ .../kilo-vscode/tests/unit/local-diff.test.ts | 30 +++++ .../kilo-vscode/tests/unit/semaphore.test.ts | 16 +++ .../tests/unit/source-controller.test.ts | 30 +++++ .../agent-manager/AgentManagerApp.tsx | 99 ++++++++------ .../webview-ui/agent-manager/DiffPanel.tsx | 11 +- .../agent-manager/DiffPanelCache.tsx | 110 +++++++++++++++ .../agent-manager/agent-manager.css | 18 +++ .../webview-ui/agent-manager/revert-file.ts | 14 +- .../agent-manager/review-composers.ts | 24 ++++ .../agent-manager/worktree-diffs.ts | 34 ++++- .../webview-ui/diff-viewer/diff-requests.ts | 2 +- 21 files changed, 519 insertions(+), 105 deletions(-) create mode 100644 .changeset/fast-agent-manager-diffs.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts diff --git a/.changeset/fast-agent-manager-diffs.md b/.changeset/fast-agent-manager-diffs.md new file mode 100644 index 0000000000..8a51a2f6c9 --- /dev/null +++ b/.changeset/fast-agent-manager-diffs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 99eab29e3e..68e24959d1 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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(), diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 8b14d97277..e31ebc00c7 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -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 { + execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise { return this.exec(args, cwd, options) } - execGitBuffer(args: string[], cwd: string): Promise { - return this.execBuffer(args, cwd) + execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise { + return this.execBuffer(args, cwd, options) } private async exec(args: string[], cwd: string, options?: ExecOptions): Promise { @@ -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 { @@ -642,7 +643,7 @@ export class GitOps { } private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { - 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), diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index e643e86627..83eff8b4f7 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -166,16 +166,16 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { - 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() @@ -201,7 +201,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise }>() + const generations = new Map() + const details = new Map() + const pending = new Map }>() + 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 => { 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 => { + file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise => { 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 { - 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 { + 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 { return stat?.size ?? 0 } -async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise { - const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) +async function readBlob( + git: GitOps, + dir: string, + ref: string, + file: string, + signal?: AbortSignal, +): Promise { + 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 return readImageFile(full) } -async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { +async function readBefore( + git: GitOps, + dir: string, + anc: string, + file: string, + status: Status, + signal?: AbortSignal, +): Promise { 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 "") } -async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { +async function unifiedPatch( + git: GitOps, + dir: string, + anc: string, + file: string, + signal?: AbortSignal, +): Promise { 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 { +async function materialize( + git: GitOps, + dir: string, + anc: string, + meta: Meta, + log?: Log, + signal?: AbortSignal, +): Promise { 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, diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index 37f3ab9cdd..a5e52d3eaf 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -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(fn: () => Promise): Promise { - await this.acquire() + async run(fn: () => Promise, signal?: AbortSignal): Promise { + await this.acquire(signal) try { return await fn() } finally { @@ -20,22 +20,33 @@ export class Semaphore { } } - private acquire(): Promise { + private acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason) if (this.running < this.limit) { this.running++ return Promise.resolve() } - return new Promise((resolve) => { - this.pending.push(() => { - this.running++ - resolve() - }) + return new Promise((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() } } diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 11a51ed344..1d454fdf8d 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -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((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) } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 8eb2d0ffdf..1cfb414a8c 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -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 + file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise +} 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, }) } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 89e750a1c8..4aa8b690f1 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -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 + file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise } /** @@ -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 }, } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index fff186c189..39751504d6 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 5d2e5bc3e1..16bd99d63f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -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") diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 68f8e8f89b..1e947e34ae 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.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])) diff --git a/packages/kilo-vscode/tests/unit/semaphore.test.ts b/packages/kilo-vscode/tests/unit/semaphore.test.ts index 697ba146b2..dc64b8b0e5 100644 --- a/packages/kilo-vscode/tests/unit/semaphore.test.ts +++ b/packages/kilo-vscode/tests/unit/semaphore.test.ts @@ -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((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 diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 3c14910e70..c08673ced3 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -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 = { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ff426db114..a0a2ffeb9d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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(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. */} - 0 || subagents.tabs().length > 0}> + 0 || subagents.tabs().length > 0} + >
{ />
- - 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()} - /> - + 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()} + /> {
{ 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()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 9513aacae6..803cbd45ea 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -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 = { interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean + active?: boolean loadingFiles?: Set sessionId?: string sessionKey?: string @@ -264,7 +264,7 @@ export const DiffPanel: Component = (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 = (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 = (props) => {
- {t("session.review.loadingChanges")}
@@ -714,10 +714,7 @@ export const DiffPanel: Component = (props) => { fallback={
Diff preview loads on demand.}> - <> - - Loading diff... - + Loading diff...
} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx new file mode 100644 index 0000000000..e6890125d8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -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 + context: Accessor + project: Accessor + active: Accessor + data: Accessor> + loading: (key: string) => boolean + loadingFiles: (key: string) => Set + 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 + activeTerminalId?: string +} + +export const DiffPanelCache: Component = (props) => { + const [entries, setEntries] = createSignal([]) + 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 ( + + {(entry) => { + const active = createMemo( + () => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey, + ) + return ( +
+ 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} + /> +
+ ) + }} +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index ab10cd505d..0c8f0570d3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -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); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index 3f2a528518..fa2d340fda 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -28,9 +28,9 @@ export function createRevertFile( return files()[id] ?? new Set() }) - function revert(file: string) { - const id = diffScopeId() - const context = ctx() + const revertingFor = (id: string) => files()[id] ?? new Set() + + 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 } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts new file mode 100644 index 0000000000..2901c323ba --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -0,0 +1,24 @@ +import type { Accessor } from "solid-js" +import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations" + +export function createReviewComposers(project: Accessor) { + const values = new Map() + + 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 } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 60ba7cb599..1de9f56219 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -34,10 +34,18 @@ export function createWorktreeDiffs( project: () => string | undefined = () => undefined, ) { const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) + const [diffLoadings, setDiffLoadings] = createSignal>({}) + const diffLoading = () => Object.keys(diffLoadings()).length > 0 const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + 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) => { + 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, diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index 904ceca13f..d76728ebe5 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -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()) { From 6a0335704689e1b8aa4957b3bab93f9b8432f221 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:25:36 +0200 Subject: [PATCH 02/11] fix(agent-manager): close diff cache review gaps --- .../src/agent-manager/local-diff.ts | 109 +++++++++++++----- .../kilo-vscode/src/diff/SourceController.ts | 26 ++++- .../unit/agent-manager-worktree-diffs.test.ts | 4 +- .../agent-manager/AgentManagerApp.tsx | 48 ++++---- .../webview-ui/agent-manager/DiffPanel.tsx | 22 +++- .../agent-manager/DiffPanelCache.tsx | 29 ++++- .../agent-manager/apply-to-local.tsx | 3 +- .../agent-manager/project/review-state.ts | 5 +- .../webview-ui/agent-manager/revert-file.ts | 17 +-- .../agent-manager/review-composers.ts | 20 +++- .../webview-ui/diff-viewer/diff-requests.ts | 10 ++ 11 files changed, 217 insertions(+), 76 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 83eff8b4f7..d45bf1210b 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,4 +1,5 @@ import * as fs from "fs/promises" +import { createHash } from "crypto" import { binaryFile } from "../diff/shared/binary" import { imageMime, loadImage, readImageFile } from "../diff/shared/image" import { resolveInside } from "../diff/shared/path" @@ -144,7 +145,46 @@ async function statStamp(dir: string, file: string): Promise { if (!full) return `missing:${file}` const stat = await fs.lstat(full).catch(() => undefined) if (!stat) return `missing:${file}` - return `${stat.size}:${stat.mtimeMs}` + return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` +} + +async function contentStamp(dir: string, file: string, status: Status): Promise { + if (status === "deleted") return "deleted" + const full = resolveInside(dir, file) + if (!full) return `missing:${file}` + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) return `missing:${file}` + const value = stat.isSymbolicLink() + ? Buffer.from(await fs.readlink(full)) + : stat.isFile() + ? await fs.readFile(full).catch(() => undefined) + : undefined + if (!value) return `unreadable:${file}` + return createHash("sha256").update(value).digest("hex") +} + +function detailStamp(value: WorktreeDiffEntry, meta: Meta): string { + if (meta.status === "deleted") return "deleted" + const data = value.image?.after?.data + if (data) return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex") + return createHash("sha256") + .update(value.after ?? "") + .digest("hex") +} + +async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return 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(""), + ]) +} + +async function sizes(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), + meta.status === "deleted" ? 0 : fileSize(dir, meta.file), + ]) } async function lineCount(file: string): Promise { @@ -265,21 +305,26 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?: export function createLocalDiff(git: GitOps, log?: Log) { const states = new Map }>() const generations = new Map() - const details = new Map() + const details = new Map() const pending = new Map }>() 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 forget = (id: string) => { + const value = details.get(id) + if (!value) return + bytes -= value.bytes + details.delete(id) + } + + const remember = (id: string, value: WorktreeDiffEntry, stamp: string) => { + const size = [value.before, value.after, value.patch, value.image?.before?.data, value.image?.after?.data].reduce( + (sum, value) => sum + Buffer.byteLength(value ?? ""), + 0, + ) const current = details.get(id) if (current) bytes -= current.bytes details.delete(id) - details.set(id, { value, bytes: size }) + details.set(id, { value, bytes: size, stamp }) bytes += size while (details.size > 128 || bytes > 64 * 1024 * 1024) { const key = details.keys().next().value! @@ -311,11 +356,14 @@ export function createLocalDiff(git: GitOps, log?: Log) { if (!state) return diffFile(git, dir, base, file, log) const meta = state.metas.get(file) if (!meta) return null - const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.stamp}` + const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` const cached = details.get(id) if (cached) { - remember(id, cached.value) - return cached.value + if (cached.stamp === (await contentStamp(dir, file, meta.status))) { + remember(id, cached.value, cached.stamp) + return cached.value + } + forget(id) } const current = pending.get(id) if (current && !current.signal?.aborted) return current.work @@ -325,7 +373,8 @@ export function createLocalDiff(git: GitOps, log?: Log) { (value) => { if (pending.get(id)?.work !== work) return pending.delete(id) - remember(id, value) + if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return + remember(id, value, detailStamp(value, meta)) }, () => { if (pending.get(id)?.work === work) pending.delete(id) @@ -390,7 +439,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise { const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal }) - if (result.code !== 0) return 0 + if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`) return parseInt(result.stdout.trim(), 10) || 0 } @@ -430,18 +479,21 @@ async function readBefore( ): Promise { if (status === "added") return "" const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal }) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not read base file for ${file}`) + return result.stdout } async function readAfter(dir: string, file: string, status: Status): Promise { if (status === "deleted") return "" const full = resolveInside(dir, file) - if (!full) return "" + if (!full) throw new Error(`Could not resolve working file for ${file}`) const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return "" + if (!stat) throw new Error(`Could not read working file for ${file}`) if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "") - if (!stat.isFile()) return "" - return fs.readFile(full, "utf-8").catch(() => "") + if (!stat.isFile()) throw new Error(`Working path is not a file: ${file}`) + return fs.readFile(full, "utf-8").catch(() => { + throw new Error(`Could not read working file for ${file}`) + }) } async function unifiedPatch( @@ -456,7 +508,8 @@ async function unifiedPatch( dir, { signal }, ) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not create diff for ${file}`) + return result.stdout } function linesOf(text: string): number { @@ -493,10 +546,8 @@ async function materialize( ): Promise { const mime = imageMime(meta.file) if (meta.binary && !mime) return summarize(meta) - 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), - ]) + const [beforeBytes, afterBytes] = await sizes(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") if (mime) { const image = await loadImage( meta.file, @@ -505,6 +556,7 @@ async function materialize( : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) }, meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, ) + if (signal?.aborted) throw new Error("Diff detail aborted") return { ...summarize(meta), summarized: false, image } } // Cheap size probe before materializing content — protects the extension @@ -520,11 +572,8 @@ async function materialize( return summarize(meta) } - 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 [before, after, tracked] = await detailReads(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions return { diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 1d454fdf8d..c05a826a18 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -67,6 +67,7 @@ export class SourceController { private interval: ReturnType | undefined private lastHash: string | undefined private epoch = 0 + private readonly fetches = new Map>() constructor( private readonly build: (id: string, ctx: PanelContext) => DiffSource, @@ -91,6 +92,7 @@ export class SourceController { stop(): void { this.epoch++ this.stopPolling() + this.fetches.clear() this.active?.dispose?.() this.active = undefined this.activeId = undefined @@ -117,7 +119,7 @@ export class SourceController { if (opts.fetch === false) return - const keepPolling = await this.runFetch(source, epoch, true) + const keepPolling = await this.fetch(source, epoch, true) // Prevents the polling interval from starting after teardown or swap. if (this.epoch !== epoch || this.activeId !== id) return if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) @@ -151,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.runFetch(source, epoch, false) + await this.fetch(source, epoch, false) } } @@ -160,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.runFetch(source, epoch, true) + await this.fetch(source, epoch, true) } /** @@ -251,7 +253,7 @@ export class SourceController { if (busy) return busy = true // Self-cancel when the tick reports the source is done - const keep = await this.runFetch(source, epoch, false).finally(() => { + const keep = await this.fetch(source, epoch, false).finally(() => { busy = false }) if (!keep && this.epoch === epoch && this.active === source) this.stopPolling() @@ -264,4 +266,20 @@ export class SourceController { this.interval = undefined } } + + private fetch(source: DiffSource, epoch: number, initial: boolean): Promise { + const current = this.fetches.get(source) + if (current) return current + const work = this.runFetch(source, epoch, initial) + this.fetches.set(source, work) + work.then( + () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + }, + () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + }, + ) + return work + } } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 16bd99d63f..4965ca1bf1 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -34,7 +34,7 @@ describe("createWorktreeDiffs", () => { it("stores full diffs per session", () => { withDiffs((diffs) => { diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) - expect(diffs.diffDatas()["s1"]).toHaveLength(1) + expect(diffs.diffDatas()["single\0s1"]).toHaveLength(1) }) }) @@ -56,7 +56,7 @@ describe("createWorktreeDiffs", () => { file: "a.ts", diff: diff("a.ts", 9), }) - expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffDatas()["single\0s1"]![0]!.additions).toBe(9) expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a0a2ffeb9d..f076530635 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -167,7 +167,7 @@ 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 { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -333,6 +333,7 @@ const AgentManagerContent: Component = () => { setSidePanel(SidePanel.Terminal) } const composers = createReviewComposers(currentProjectId) + createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true })) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -549,16 +550,6 @@ const AgentManagerContent: Component = () => { if (sel === null) return setReviewOpenForContext(sel, open) } - const reviewComments = createMemo(() => { - const sel = selection() - if (sel === null) return [] as ReviewComment[] - return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel) - }) - const setReviewCommentsForSelection = (comments: ReviewComment[]) => { - const sel = selection() - if (sel === null) return - setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments)) - } const apply = createApplyToLocal({ vscode, dialog, @@ -719,6 +710,8 @@ const AgentManagerContent: Component = () => { }) createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) + composers.prune(ids) + composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -1661,6 +1654,17 @@ const AgentManagerContent: Component = () => { // The composite id (ctx#scope) the extension keys diff data by. const diffScopeId = review.id + const reviewComments = createMemo(() => { + const key = diffScopeId() + if (!key) return [] as ReviewComment[] + return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + }) + const setReviewCommentsForSelection = (comments: ReviewComment[]) => { + const key = diffScopeId() + if (!key) return + setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", key, comments)) + } + const diffScopeControls = (compact: boolean) => ( { tabFocus.restore() } - // Data for the review tab / side panel: keyed by the composite diff id - // (ctx#scope) the extension pushes, so each scope keeps its own file set and - // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() const key = diffScopeId() if (!key) return [] - return data[key] ?? [] + return data[diffDataKey(activeProjectId(), key)] ?? [] }) const diffSessionKey = createMemo(() => diffScopeId() ?? "") - // Source-level notice for the active composite id (e.g. snapshots disabled - // for the Session scope), shown as a banner instead of the empty state. const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined @@ -2634,13 +2633,13 @@ const AgentManagerContent: Component = () => { data={diffDatas} loading={(key) => diffs.diffLoadingFor(() => key)} loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)} - notice={(key) => diffNotices()[key]} - comments={(ctx) => - readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx) + notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]} + comments={(key) => + readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) } - setComments={(ctx, comments) => + setComments={(key, comments) => setReviewCommentsByContext((prev) => - setReviewComments(prev, currentProjectId() ?? "single", ctx, comments), + setReviewComments(prev, currentProjectId() ?? "single", key, comments), ) } composer={composers.get} @@ -2668,6 +2667,11 @@ const AgentManagerContent: Component = () => { }} revertingFiles={revertCtl.revertingFor} activeTerminalId={terms.activeId()} + contexts={() => new Set(worktrees().map((wt) => wt.id))} + onEvict={(key) => { + composers.drop(key) + diffs.drop(key) + }} /> = (props) => { }, ), ) + + createEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -249,6 +264,7 @@ export const DiffPanel: Component = (props) => { on( () => props.sessionKey, () => { + if (props.active === false) return setDraft(null) draftMeta = null setEditing(null) @@ -393,8 +409,10 @@ export const DiffPanel: Component = (props) => { const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta editMeta = result.editMeta - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null + if (props.active !== false) { + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null + } return result.annotations } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx index e6890125d8..0bf696f83c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -3,6 +3,7 @@ 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" +import { diffDataKey } from "./worktree-diffs" const CACHE_SIZE = 4 @@ -18,6 +19,8 @@ interface Props { context: Accessor project: Accessor active: Accessor + onEvict?: (key: string) => void + contexts: Accessor> data: Accessor> loading: (key: string) => boolean loadingFiles: (key: string) => Set @@ -46,6 +49,15 @@ export const DiffPanelCache: Component = (props) => { const [entries, setEntries] = createSignal([]) let used = 0 + createEffect(() => { + const contexts = props.contexts() + setEntries((prev) => { + const next = prev.filter((entry) => entry.ctx === "local" || contexts.has(entry.ctx)) + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + }) + }) + createEffect(() => { if (!props.active()) return const key = props.current() @@ -59,12 +71,19 @@ export const DiffPanelCache: Component = (props) => { const current = scoped.find((item) => item.cacheKey === cacheKey) if (current) { current.used = ++used + for (const item of prev) if (!scoped.includes(item)) props.onEvict?.(item.cacheKey) return scoped } const next = [...scoped, { key, cacheKey, ctx, used: ++used }] - if (next.length <= CACHE_SIZE) return next + if (next.length <= CACHE_SIZE) { + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + } const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry)) - return next.filter((item) => item !== oldest) + const result = next.filter((item) => item !== oldest) + props.onEvict?.(oldest.cacheKey) + for (const item of prev) if (!result.includes(item)) props.onEvict?.(item.cacheKey) + return result }) }) @@ -77,7 +96,7 @@ export const DiffPanelCache: Component = (props) => { return (
= (props) => { onDiffStyleChange={props.onDiffStyleChange} markdownRender={props.markdownRender} onMarkdownRenderChange={props.onMarkdownRenderChange} - comments={props.comments(entry.ctx)} - onCommentsChange={(comments) => props.setComments(entry.ctx, comments)} + comments={props.comments(entry.key)} + onCommentsChange={(comments) => props.setComments(entry.key, comments)} composer={props.composer(entry.cacheKey)} onSendClick={props.onSendClick} onClose={props.onClose} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx index d57c3a39c5..5fee2b5b3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -15,6 +15,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { groupApplyConflicts } from "./apply-conflicts" import { ApplyDialog } from "./ApplyDialog" import { composeDiffId } from "./diff-scope-state" +import { diffDataKey } from "./worktree-diffs" import type { tracker } from "./telemetry" import type { useDialog } from "@kilocode/kilo-ui/context/dialog" import type { useLanguage } from "../src/context/language" @@ -74,7 +75,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { const applyDiffs = createMemo(() => { const key = applyDiffKey() if (!key) return [] as WorktreeFileDiff[] - return diffDatas()[key] ?? ([] as WorktreeFileDiff[]) + return diffDatas()[diffDataKey(opts.projectId?.(), key)] ?? ([] as WorktreeFileDiff[]) }) const applyStateForTarget = createMemo(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts index ce0aa9e51c..158b91cb54 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts @@ -50,8 +50,9 @@ export function pruneReviewState( ): Record { return Object.fromEntries( Object.entries(values).filter(([key]) => { - const [owner, context] = key.split(":") - return owner !== project || context === "local" || contexts.has(context) + const [owner, value] = key.split(":") + const context = value?.split("#", 1)[0] + return owner !== project || context === "local" || (context !== undefined && contexts.has(context)) }), ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index fa2d340fda..1b3605fae4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -21,21 +21,23 @@ export function createRevertFile( projectId?: Accessor, ) { const [files, setFiles] = createSignal>>({}) + const key = (project: string | undefined, scope: string) => `${project ?? "single"}\0${scope}` const reverting = createMemo(() => { const id = diffScopeId() if (!id) return new Set() - return files()[id] ?? new Set() + return files()[key(projectId?.(), id)] ?? new Set() }) - const revertingFor = (id: string) => files()[id] ?? new Set() + const revertingFor = (id: string) => files()[key(projectId?.(), id)] ?? new Set() function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) { if (!id || !context) return + const data = key(projectId?.(), id) setFiles((prev) => { - const set = new Set(prev[id] ?? []) + const set = new Set(prev[data] ?? []) set.add(file) - return { ...prev, [id]: set } + return { ...prev, [data]: set } }) vscode.postMessage({ type: "agentManager.revertWorktreeFile", @@ -51,12 +53,13 @@ export function createRevertFile( } function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { + const data = key(ev.projectId, ev.sessionId) setFiles((prev) => { - const set = new Set(prev[ev.sessionId] ?? []) + const set = new Set(prev[data] ?? []) set.delete(ev.file) const next = { ...prev } - if (set.size === 0) delete next[ev.sessionId] - else next[ev.sessionId] = set + if (set.size === 0) delete next[data] + else next[data] = set return next }) if (ev.status === "success") { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts index 2901c323ba..a82f0781be 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -20,5 +20,23 @@ export function createReviewComposers(project: Accessor) { } } - return { get, clear } + const drop = (key: string) => values.delete(key) + + const clearProject = (id: string) => { + const prefix = `${id}\0` + for (const key of values.keys()) { + if (key.startsWith(prefix)) values.delete(key) + } + } + + const prune = (contexts: Set) => { + const prefix = `${project() ?? "single"}\0` + for (const key of values.keys()) { + if (!key.startsWith(prefix)) continue + const ctx = key.slice(prefix.length).split("#", 1)[0] + if (ctx !== "local" && !contexts.has(ctx)) values.delete(key) + } + } + + return { get, clear, drop, clearProject, prune } } diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index d76728ebe5..8d51779566 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -13,6 +13,7 @@ interface DiffRequestOptions { export function createDiffRequests(opts: DiffRequestOptions) { const requested = new Map() + let active = false createEffect( on( @@ -38,6 +39,15 @@ export function createDiffRequests(opts: DiffRequestOptions) { on( () => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const, ([open, diffs]) => { + if (!opts.send()) { + requested.clear() + active = false + return + } + if (!active) { + requested.clear() + active = true + } const files = new Set(open) for (const file of requested.keys()) { if (!files.has(file)) requested.delete(file) From 44b438363aae82aa6161e8cbe5c6acdd74bcbda1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:29:03 +0200 Subject: [PATCH 03/11] fix(agent-manager): namespace diff data by project --- .../agent-manager/worktree-diffs.ts | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 1de9f56219..2b7604fd79 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -29,6 +29,14 @@ export function wireDiffId(id: string) { return { sessionId: ctx, scope, diffSessionId: sessionId } } +export function diffDataKey(project: string | undefined, id: string): string { + return `${project ?? "single"}\0${id}` +} + +function readData(data: Record, project: string | undefined, id: string) { + return data[diffDataKey(project, id)] +} + export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, @@ -39,6 +47,8 @@ export function createWorktreeDiffs( const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const key = (id: string) => diffDataKey(project(), id) + const reset = () => { setDiffDatas({}) setDiffLoadings({}) @@ -46,6 +56,34 @@ export function createWorktreeDiffs( setDiffFileLoading({}) } + const drop = (id: string) => { + const data = id.includes("\0") ? id : key(id) + setDiffDatas((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffLoadings((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffNotices((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffFileLoading((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + } + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { setDiffFileLoading((prev) => { const session = prev[sessionId] ?? {} @@ -74,17 +112,19 @@ export function createWorktreeDiffs( /** Lazily load a single file's full diff for the given composite diff id. */ const requestDiffFile = (id: string, file: string) => { - if (diffFileLoading()[id]?.[file]) return - setDiffFilePending(id, file, true) + const data = key(id) + if (diffFileLoading()[data]?.[file]) return + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) }) } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ const refreshStaleDiffs = (id: string, files: Set) => { - const loading = diffFileLoading()[id] ?? {} + const data = key(id) + const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue - setDiffFilePending(id, file, true) + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), @@ -98,61 +138,65 @@ export function createWorktreeDiffs( const diffFileLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return new Set() - return new Set(Object.keys(diffFileLoading()[id] ?? {})) + return new Set(Object.keys(diffFileLoading()[key(id)] ?? {})) } /** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */ const diffLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return false - return diffLoadings()[id] === true && !(id in diffDatas()) + const data = key(id) + return diffLoadings()[data] === true && !(data in diffDatas()) } // Backend messages. const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = prev[ev.sessionId] + const existing = readData(prev, project(), ev.sessionId) const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] + const existing = readData(prev, project(), ev.sessionId) ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) + setDiffFilePending(data, ev.diff.file, false) return } - setDiffFilePending(ev.sessionId, ev.file, false) + setDiffFilePending(data, ev.file, false) } const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) // 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 }) + setDiffLoadings({ [data]: true }) return } setDiffLoadings((prev) => { - if (!prev[ev.sessionId]) return prev + if (!prev[data]) return prev const next = { ...prev } - delete next[ev.sessionId] + delete next[data] return next }) } const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { - setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice })) + setDiffNotices((prev) => ({ ...prev, [diffDataKey(ev.projectId, ev.sessionId)]: ev.notice })) } return { @@ -164,6 +208,8 @@ export function createWorktreeDiffs( refreshStaleDiffs, diffFileLoadingFor, diffLoadingFor, + diffDataKey, + drop, reset, onWorktreeDiff, onWorktreeDiffFile, From befab09aa4f50ff574f40fdac447fc6447562922 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:35:04 +0200 Subject: [PATCH 04/11] test(agent-manager): cover diff cancellation races --- .../kilo-vscode/tests/unit/git-ops.test.ts | 14 +++++++++++ .../kilo-vscode/tests/unit/local-diff.test.ts | 16 ++++++++++++ .../tests/unit/source-controller.test.ts | 25 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 1 - 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 05c768c93a..c90aa3a7c0 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -667,6 +667,20 @@ describe("GitOps", () => { }) }) + it("kills an in-flight exec when its request signal aborts", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined, binary: async () => process.execPath }) + const ctl = new AbortController() + const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal }) + await sleep(25) + ctl.abort() + + const result = await pending + expect(result.code).not.toBe(0) + git.dispose() + }) + }) + it("is safe to call multiple times", () => { const git = ops(async () => "ok") git.dispose() diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 1e947e34ae..595d0bf349 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -365,6 +365,22 @@ describe("diffFile", () => { }) }) + it("does not cache detail that is aborted before Git completes", 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 ctl = new AbortController() + const pending = local.file(dir, base, "seed.txt", ctl.signal) + ctl.abort() + await expect(pending).rejects.toThrow() + + const result = await local.file(dir, base, "seed.txt") + expect(result?.after).toBe("seed\ncached\n") + }) + }) + 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") diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index c08673ced3..915cdc13fb 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -489,4 +489,29 @@ describe("SourceController.refresh", () => { controller.stop() }) + + it("shares an in-flight fetch between refresh and polling callers", async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + await gate + return { diffs: [] } + }, + } + const { controller } = make({ "session:s1": source }) + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1", { poll: false }) + const refresh = controller.refresh() + release() + await Promise.all([activation, refresh]) + + expect(fetches).toBe(1) + controller.stop() + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index f076530635..aa879913b5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -711,7 +711,6 @@ const AgentManagerContent: Component = () => { createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) composers.prune(ids) - composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev From 651dab1a12cc4837039a049075f21cd1d1af9a97 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:42:51 +0200 Subject: [PATCH 05/11] fix(agent-manager): address diff review feedback --- .../src/agent-manager/local-diff.ts | 31 ++------------- .../kilo-vscode/src/diff/SourceController.ts | 14 +++++-- .../tests/unit/source-controller.test.ts | 4 +- .../webview-ui/agent-manager/DiffPanel.tsx | 39 ++++++++++++------- .../agent-manager/worktree-diffs.ts | 15 +++---- 5 files changed, 44 insertions(+), 59 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index d45bf1210b..0594d299fc 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,5 +1,4 @@ import * as fs from "fs/promises" -import { createHash } from "crypto" import { binaryFile } from "../diff/shared/binary" import { imageMime, loadImage, readImageFile } from "../diff/shared/image" import { resolveInside } from "../diff/shared/path" @@ -148,30 +147,6 @@ async function statStamp(dir: string, file: string): Promise { return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` } -async function contentStamp(dir: string, file: string, status: Status): Promise { - if (status === "deleted") return "deleted" - const full = resolveInside(dir, file) - if (!full) return `missing:${file}` - const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return `missing:${file}` - const value = stat.isSymbolicLink() - ? Buffer.from(await fs.readlink(full)) - : stat.isFile() - ? await fs.readFile(full).catch(() => undefined) - : undefined - if (!value) return `unreadable:${file}` - return createHash("sha256").update(value).digest("hex") -} - -function detailStamp(value: WorktreeDiffEntry, meta: Meta): string { - if (meta.status === "deleted") return "deleted" - const data = value.image?.after?.data - if (data) return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex") - return createHash("sha256") - .update(value.after ?? "") - .digest("hex") -} - async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { return Promise.all([ readBefore(git, dir, anc, meta.file, meta.status, signal), @@ -359,8 +334,8 @@ export function createLocalDiff(git: GitOps, log?: Log) { const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` const cached = details.get(id) if (cached) { - if (cached.stamp === (await contentStamp(dir, file, meta.status))) { - remember(id, cached.value, cached.stamp) + if (cached.stamp === meta.stamp) { + remember(id, cached.value, meta.stamp) return cached.value } forget(id) @@ -374,7 +349,7 @@ export function createLocalDiff(git: GitOps, log?: Log) { if (pending.get(id)?.work !== work) return pending.delete(id) if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return - remember(id, value, detailStamp(value, meta)) + remember(id, value, meta.stamp) }, () => { if (pending.get(id)?.work === work) pending.delete(id) diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index c05a826a18..09eb6fb775 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -153,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.fetch(source, epoch, false) + await this.fetch(source, epoch, true, true) } } @@ -162,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.fetch(source, epoch, true) + await this.fetch(source, epoch, true, true) } /** @@ -267,9 +267,15 @@ export class SourceController { } } - private fetch(source: DiffSource, epoch: number, initial: boolean): Promise { + private fetch(source: DiffSource, epoch: number, initial: boolean, force = false): Promise { const current = this.fetches.get(source) - if (current) return current + if (current && !force) return current + if (current) { + return current.then(() => { + if (this.epoch !== epoch || this.active !== source) return false + return this.fetch(source, epoch, initial) + }) + } const work = this.runFetch(source, epoch, initial) this.fetches.set(source, work) work.then( diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 915cdc13fb..c7517fde75 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -490,7 +490,7 @@ describe("SourceController.refresh", () => { controller.stop() }) - it("shares an in-flight fetch between refresh and polling callers", async () => { + it("runs a forced refresh after an in-flight fetch", async () => { let release!: () => void const gate = new Promise((resolve) => { release = resolve @@ -511,7 +511,7 @@ describe("SourceController.refresh", () => { release() await Promise.all([activation, refresh]) - expect(fetches).toBe(1) + expect(fetches).toBe(2) controller.stop() }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 7a088d7ab4..4dbb6611ac 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,13 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" +import { + type Component, + createSignal, + createMemo, + Show, + createEffect, + createRenderEffect, + on, + type JSXElement, +} from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -160,20 +169,6 @@ export const DiffPanel: Component = (props) => { ), ) - createEffect( - on( - () => props.active, - (active) => { - if (!active) return - const value = reviewComposerDraft(composer()) - const edit = reviewComposerEdit(composer()) - setDraft(value) - setEditing(edit) - draftMeta = composer().draft - editMeta = composer().edit - }, - ), - ) const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -212,6 +207,20 @@ export const DiffPanel: Component = (props) => { // so pierre's annotation cache doesn't invalidate and destroy the textarea. let draftMeta: AnnotationMeta | null = composer().draft let editMeta: AnnotationMeta | null = composer().edit + createRenderEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 2b7604fd79..4ca050eff4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -33,10 +33,6 @@ export function diffDataKey(project: string | undefined, id: string): string { return `${project ?? "single"}\0${id}` } -function readData(data: Record, project: string | undefined, id: string) { - return data[diffDataKey(project, id)] -} - export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, @@ -119,15 +115,14 @@ export function createWorktreeDiffs( } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ - const refreshStaleDiffs = (id: string, files: Set) => { - const data = key(id) + const refreshStaleDiffs = (id: string, files: Set, data = key(id), owner = project()) => { const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", - projectId: project(), + projectId: owner, file, ...wireDiffId(id), }) @@ -155,21 +150,21 @@ export function createWorktreeDiffs( const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = readData(prev, project(), ev.sessionId) + const existing = prev[data] const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev return { ...prev, [data]: next } }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles, data, ev.projectId) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = readData(prev, project(), ev.sessionId) ?? [] + const existing = prev[data] ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) return { ...prev, [data]: next } }) From 5fea23bbc9749b966c055c21acb143493d1768ac Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:47:07 +0200 Subject: [PATCH 06/11] chore(agent-manager): stay under app line cap --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index aa879913b5..ffe685a6a7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -2387,9 +2387,6 @@ const AgentManagerContent: Component = () => {
- {/* Tab bar — full version with tabs renders when a section is selected - and has tabs; otherwise a minimal version still renders so the - sidebar toggle button stays at a fixed position. */} { /> - {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
- {/* Chat/terminal + side diff panel. Keep it mounted under the - review tab so live xterm canvases never leave the paint tree. */}
- {/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms, onFocusPrompt: focusCtl.focus, onFocusChange: focusCtl.report, })} - {/* Session-less context (e.g. a worktree mid-provisioning): the - empty state lives in the main pane so the side terminal - panel can render next to it. */}
{
- {/* One inspector host for all right-side modes. It stays - mounted while a side terminal is alive — hidden via - .am-side-host-hidden (absolute + opacity), never - unmounted, so xterm render loops keep streaming. */} 0 || subagents.tabs().length > 0} > From 13023c9dae635a1e08b59121f6e0625839f6bb65 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:04:27 +0200 Subject: [PATCH 07/11] refactor(agent-manager): simplify cancellation cleanup --- .../kilo-vscode/src/agent-manager/semaphore.ts | 15 +++++++-------- packages/kilo-vscode/src/diff/SourceController.ts | 12 ++++-------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index a5e52d3eaf..9006c7bf67 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -27,18 +27,17 @@ export class Semaphore { return Promise.resolve() } return new Promise((resolve, reject) => { - const item = { + const item: { resolve: () => void; abort: () => void } = { resolve: () => { - if (item.abort) signal?.removeEventListener("abort", 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) + 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) diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 09eb6fb775..8b7aa57d21 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -278,14 +278,10 @@ export class SourceController { } const work = this.runFetch(source, epoch, initial) this.fetches.set(source, work) - work.then( - () => { - if (this.fetches.get(source) === work) this.fetches.delete(source) - }, - () => { - if (this.fetches.get(source) === work) this.fetches.delete(source) - }, - ) + const clear = () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + } + void work.finally(clear).catch(() => undefined) return work } } From 71211f14082e140756ef89d76916e4707310c9c2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:05:52 +0200 Subject: [PATCH 08/11] refactor(agent-manager): share diff cleanup callback --- .../agent-manager/worktree-diffs.ts | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 4ca050eff4..350ba0ea5c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -54,30 +54,16 @@ export function createWorktreeDiffs( const drop = (id: string) => { const data = id.includes("\0") ? id : key(id) - setDiffDatas((prev) => { + const remove = >(prev: T): T => { if (!(data in prev)) return prev const next = { ...prev } delete next[data] return next - }) - setDiffLoadings((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) - setDiffNotices((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) - setDiffFileLoading((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) + } + setDiffDatas(remove) + setDiffLoadings(remove) + setDiffNotices(remove) + setDiffFileLoading(remove) } const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { From 7230c71b21a678b693e81d725b73ce6e97963e2a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:32:39 +0200 Subject: [PATCH 09/11] fix(agent-manager): scope full-screen diff notices --- .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b2b31fb70f..1599819baf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1748,7 +1748,7 @@ const AgentManagerContent: Component = () => { const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined - return diffNotices()[key] + return diffNotices()[diffDataKey(activeProjectId(), key)] }) const requestDiffFile = (file: string) => { From bf07f65711b925cd41088df9c4dbe03765c038ec Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:14:57 +0200 Subject: [PATCH 10/11] fix(agent-manager): keep merged app below lint limit --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 1599819baf..8327050b86 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1498,18 +1498,11 @@ const AgentManagerContent: Component = () => { } } - // Set per-session model selection without clearing busy state. - // Used during Phase 1 of multi-version creation so the UI selector - // reflects the correct model as soon as the worktree appears. if ((msg as { type: string }).type === "agentManager.setSessionModel") { const ev = msg as { type: string; sessionId: string; providerID: string; modelID: string } session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } - // Handle initial message send for multi-version sessions. - // The extension creates the worktrees/sessions, then asks the webview - // to send the prompt through the normal KiloProvider sendMessage path. - // Once the message is sent, clear the loading state for that worktree. if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { const ev = msg as unknown as AgentManagerSendInitialMessage From 7427eedfd7c75c808ae051625507352c07d1e9b2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:27:31 +0200 Subject: [PATCH 11/11] perf(agent-manager): combine worktree diff summary scans --- .../src/agent-manager/local-diff.ts | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 0594d299fc..c142bef0ee 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -117,26 +117,29 @@ async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Prom return result.stdout.trim() } -async function numstat(git: GitOps, dir: string, base: string, file?: string) { - const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] - if (file) args.push("--", file) - const result = await git.execGit(args, dir) - const map = new Map() - if (result.code !== 0) return map - for (const line of result.stdout.trim().split("\n")) { - if (!line) continue +function counts(value: string) { + const result = new Map() + for (const line of value.trim().split("\n")) { + if (!line || line.startsWith(":")) continue const parts = line.split("\t") const add = parts[0] const del = parts[1] - const name = parts.slice(2).join("\t") - if (!name) continue - map.set(name, { + const file = parts.slice(2).join("\t") + if (!file) continue + result.set(file, { additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, binary: add === "-" || del === "-", }) } - return map + return result +} + +async function numstat(git: GitOps, dir: string, base: string, file?: string) { + const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] + if (file) args.push("--", file) + const result = await git.execGit(args, dir) + return counts(result.code === 0 ? result.stdout : "") } async function statStamp(dir: string, file: string): Promise { @@ -181,28 +184,28 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { - const [nameStatus, counts, untracked] = await Promise.all([ - git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], dir), - numstat(git, dir, anc), + const [tracked, untracked] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir), 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() }) + if (tracked.code !== 0) { + log?.("git diff --raw --numstat failed", { code: tracked.code, stderr: tracked.stderr.trim() }) return [] } const result: Meta[] = [] const seen = new Set() + const stats = counts(tracked.stdout) - for (const line of nameStatus.stdout.trim().split("\n")) { - if (!line) continue + for (const line of tracked.stdout.trim().split("\n")) { + if (!line.startsWith(":")) continue const parts = line.split("\t") - const code = parts[0] + const code = parts[0]?.split(" ").at(-1) const file = parts.slice(1).join("\t") if (!file || !code) continue seen.add(file) const status = statusFromCode(code) - const stat = counts.get(file) ?? { additions: 0, deletions: 0, binary: false } + const stat = stats.get(file) ?? { additions: 0, deletions: 0, binary: false } result.push({ file, additions: stat.additions,