From 6a0335704689e1b8aa4957b3bab93f9b8432f221 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:25:36 +0200 Subject: [PATCH] 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)