From 50c11aed8386b69e6ea1b60bd5ebdf82c44dc7ff Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Wed, 12 Aug 2026 16:40:38 +0200 Subject: [PATCH] fix(vscode): preserve changes in multi-repo sessions --- .changeset/multirepo-changes.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 99 +++++++++++--- .../src/diff/DiffViewerProvider.ts | 26 +++- packages/kilo-vscode/src/extension.ts | 3 +- .../src/kilo-provider/session-edits.ts | 47 +++++++ packages/kilo-vscode/src/review-utils.ts | 9 +- .../tests/unit/diff-viewer-provider.test.ts | 22 +++ .../tests/unit/kilo-provider-followup.test.ts | 37 +++-- .../unit/kilo-provider-load-messages.test.ts | 34 +++++ .../kilo-provider-route-integration.test.ts | 129 +++++++++++++++++- .../tests/unit/review-utils.test.ts | 16 ++- .../tests/unit/session-edits.test.ts | 38 ++++++ .../tests/unit/source-controller.test.ts | 8 +- 13 files changed, 424 insertions(+), 49 deletions(-) create mode 100644 .changeset/multirepo-changes.md create mode 100644 packages/kilo-vscode/src/kilo-provider/session-edits.ts create mode 100644 packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-edits.test.ts diff --git a/.changeset/multirepo-changes.md b/.changeset/multirepo-changes.md new file mode 100644 index 000000000000..5059084b44f2 --- /dev/null +++ b/.changeset/multirepo-changes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 918b0a0afc32..6483d455eea7 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -72,6 +72,7 @@ import { interceptMessage } from "./kilo-provider/git-changes-request" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" import { clearCommandsCache, loadCommands } from "./kilo-provider/commands" import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page" +import { editPaths } from "./kilo-provider/session-edits" import { dismissNotification, fetchAndSendNotifications as fetchNotifications, @@ -385,6 +386,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly anacondaDesktop = new AnacondaDesktopBridge() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. + private sessionGitDirectories = new Map() // Stable Git root resolved for each session. + private sessionGitRecoveries = new Set() // Sessions whose older history was scanned for a Git root. private readonly aborts = new SessionAbort() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. @@ -911,6 +914,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.currentSession?.id ?? undefined } + /** Return the Git root used by the Changes panel for a session. */ + public getSessionGitDirectory(sessionId: string): string | undefined { + return this.sessionGitDirectories.get(sessionId) + } + /** * Re-fetch and send the full session list to the webview. * Called by AgentManagerProvider after worktree recovery completes. @@ -1055,7 +1063,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper openAgentManager: () => vscode.commands.executeCommand("kilo-code.new.agentManagerOpen"), openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"), openChanges: (sessionId?: string, turnId?: string) => - vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }), + vscode.commands.executeCommand("kilo-code.new.showChanges", { + sessionId, + turnId, + directory: sessionId ? this.sessionGitDirectories.get(sessionId) : undefined, + }), openProfile: () => vscode.commands.executeCommand("kilo-code.new.profileButtonClicked"), currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { @@ -1903,7 +1915,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return - void this.refreshGitStatus(dir) + void this.refreshGitStatus(this.sessionGitDirectories.get(sessionID) ?? dir, sessionID) const revision = this.revisions.get(sessionID) const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 this.refreshes.set(sessionID, refresh) @@ -2006,6 +2018,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper parts: this.slimParts(m.parts), createdAt: new Date(m.info.time.created).toISOString(), })) + if (mode === "replace" || mode === "reconcile") { + void this.recoverSessionGitStatus( + page.items.flatMap((message) => message.parts), + sessionID, + page.cursor, + ) + } for (const message of messages) { this.connectionService.recordMessageSessionId(message.id, message.sessionID) } @@ -2054,6 +2073,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (dir) { this.sessionDirectories.set(sessionID, dir) } + const git = this.sessionGitDirectories.get(parentSessionID) + if (git) this.sessionGitDirectories.set(sessionID, git) } try { @@ -2228,6 +2249,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) + this.sessionGitDirectories.delete(sessionID) + this.sessionGitRecoveries.delete(sessionID) this.aborts.delete(sessionID) this.lastReconciledAt.delete(sessionID) this.checkpoints.delete(sessionID) @@ -4783,21 +4806,54 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper event: Extract, sessionID?: string, ) { - const part = event.properties.part as { - type?: string - metadata?: Record - state?: { status?: string; input?: Record; metadata?: Record } - } - if (part.type !== "tool" || part.state?.status !== "completed") return - const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] - const file = values.find((value): value is string => typeof value === "string" && value.length > 0) - if (!file) return + void this.refreshGitStatusFromParts([event.properties.part], sessionID) + } + + private async refreshGitStatusFromParts(parts: unknown[], sessionID?: string, recover = false): Promise { const base = this.getWorkspaceDirectory(sessionID) - const value = file.split(",")[0].trim() - const pathName = path.isAbsolute(value) ? value : path.resolve(base, value) - const directory = path.dirname(pathName) - if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return - void this.refreshGitStatus(directory) + const edits = editPaths(parts, base) + if (!recover && edits.length === 0) return false + + const cached = sessionID ? this.sessionGitDirectories.get(sessionID) : undefined + if (cached) { + await this.refreshGitStatus(cached, sessionID) + return true + } + + const root = await this.resolveGitRoot(base) + if (root) { + await this.refreshGitStatus(root, sessionID) + return true + } + + const file = edits.find((item) => this.isCurrentProjectGitDirectory(item, sessionID)) + if (!file) return false + await this.refreshGitStatus(path.dirname(file), sessionID) + return sessionID ? this.sessionGitDirectories.has(sessionID) : true + } + + private async recoverSessionGitStatus(parts: unknown[], sessionID: string, cursor?: string): Promise { + if (await this.refreshGitStatusFromParts(parts, sessionID, true)) return + if (!cursor || !this.client || !this.trackedSessionIds.has(sessionID)) return + if (this.sessionGitRecoveries.has(sessionID)) return + this.sessionGitRecoveries.add(sessionID) + + const directory = this.getWorkspaceDirectory(sessionID) + const history = await retry(() => + this.client!.session.messages({ sessionID, directory, limit: 0 }, { throwOnError: true }), + ).catch((error: unknown) => { + console.warn("[Kilo New] KiloProvider: Failed to recover session Git directory:", error) + return undefined + }) + if (!history) { + this.sessionGitRecoveries.delete(sessionID) + return + } + if (!this.trackedSessionIds.has(sessionID)) return + await this.refreshGitStatusFromParts( + history.data.flatMap((message) => message.parts), + sessionID, + ) } private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean { @@ -4810,15 +4866,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } - public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise { + public async refreshGitStatus(directory = this.getWorkspaceDirectory(), sessionID?: string): Promise { const client = this.client if (!client) return - const revision = ++this.gitStatusRevision + const active = !sessionID || sessionID === this.contextSessionID + const revision = active ? ++this.gitStatusRevision : undefined const repo = await hasGit(client, directory) const root = await this.resolveGitRoot(directory) - if (revision !== this.gitStatusRevision) return const found = repo || root !== undefined const target = root ?? directory + if (found && sessionID && !this.sessionGitDirectories.has(sessionID)) { + this.sessionGitDirectories.set(sessionID, target) + } + if (sessionID && sessionID !== this.contextSessionID) return + if (revision === undefined || revision !== this.gitStatusRevision) return const changed = !this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target) if (changed) { this.cachedStats = null diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts index 7f773cafcfa9..c17b5a34f438 100644 --- a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../services/cli-backend" -import { appendOutput, getWorkspaceRoot, openWorkspaceRelativeFile } from "../review-utils" +import { appendOutput, getWorkspaceRoot, openRelativeFile } from "../review-utils" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { buildWebviewHtml, getWebviewFontSize } from "../utils" import { watchFontSizeConfig } from "../kilo-provider/font-size" @@ -13,6 +13,7 @@ type CommentHandler = (comments: unknown[], autoSend: boolean) => void export interface DiffViewerProviderOptions { sessionIdProvider?: () => string | undefined + sessionDirectoryProvider?: (sessionId: string) => string | undefined } /** @@ -31,6 +32,7 @@ export class DiffViewerProvider implements vscode.Disposable { private fontConfigDisposable: vscode.Disposable | undefined private baseBranchOverride: string | undefined private readonly sessionIdProvider: () => string | undefined + private readonly sessionDirectoryProvider: (sessionId: string) => string | undefined private readonly output: vscode.OutputChannel constructor( @@ -40,6 +42,7 @@ export class DiffViewerProvider implements vscode.Disposable { opts: DiffViewerProviderOptions = {}, ) { this.sessionIdProvider = opts.sessionIdProvider ?? (() => undefined) + this.sessionDirectoryProvider = opts.sessionDirectoryProvider ?? (() => undefined) this.output = vscode.window.createOutputChannel("Kilo Diff Panel") } @@ -54,7 +57,11 @@ export class DiffViewerProvider implements vscode.Disposable { this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One) this.controller.setContext(this.ctx) const nextId = this.catalog.defaultSourceId(this.ctx) - if (nextId && nextId !== this.controller.currentId) this.swap(nextId) + if (nextId && nextId !== this.controller.currentId) { + this.swap(nextId) + return + } + void this.controller.reactivate() return } @@ -70,12 +77,15 @@ export class DiffViewerProvider implements vscode.Disposable { * the source picker hidden — the view becomes a static "diff of this turn" * rather than the switchable workspace/session viewer. */ - openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }): void { + openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string; directory?: string }): void { const sessionId = arg?.sessionId ?? this.sessionIdProvider() + const explicit = !!arg && "directory" in arg + const dir = explicit ? arg.directory : sessionId ? this.sessionDirectoryProvider(sessionId) : undefined const turnInitialSourceId = arg?.turnId && sessionId ? turnSourceId(sessionId, arg.turnId) : undefined this.openPanel({ workspaceRoot: getWorkspaceRoot(), sessionId, + dir, initialSourceId: turnInitialSourceId ?? arg?.initialSourceId, hidePicker: !!turnInitialSourceId, }) @@ -182,14 +192,18 @@ export class DiffViewerProvider implements vscode.Disposable { }, openFile: (msg) => { if (typeof msg.filePath !== "string") return - openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) + openRelativeFile( + this.ctx?.dir ?? this.ctx?.workspaceRoot, + msg.filePath, + typeof msg.line === "number" ? msg.line : undefined, + ) }, } private async sendBranches(): Promise { if (!this.panel) return try { - const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride) + const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride, this.ctx?.dir) if (!result || !this.panel) return void this.panel.webview.postMessage({ type: "diffViewer.branches", @@ -212,7 +226,7 @@ export class DiffViewerProvider implements vscode.Disposable { vscodeLanguage: vscode.env.language, languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), fontSize: getWebviewFontSize(), - workspaceDirectory: getWorkspaceRoot(), + workspaceDirectory: this.ctx?.dir ?? getWorkspaceRoot(), }) void this.panel.webview.postMessage({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) const initial = this.ctx ? this.catalog.defaultSourceId(this.ctx) : undefined diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index e5b4e92c8f4c..88e498254cc7 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -273,6 +273,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(diffSourceCatalog) const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, { sessionIdProvider: () => provider.getCurrentSessionId(), + sessionDirectoryProvider: (sessionId) => provider.getSessionGitDirectory(sessionId), }) diffViewerProvider.setCommentHandler((comments, autoSend) => { void provider.appendReviewComments(comments, autoSend) @@ -468,7 +469,7 @@ export function activate(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand( "kilo-code.new.showChanges", - (arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }) => { + (arg?: { sessionId?: string; turnId?: string; initialSourceId?: string; directory?: string }) => { diffViewerProvider.openFromCommand(arg) }, ), diff --git a/packages/kilo-vscode/src/kilo-provider/session-edits.ts b/packages/kilo-vscode/src/kilo-provider/session-edits.ts new file mode 100644 index 000000000000..7cd4ca13f2b2 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/session-edits.ts @@ -0,0 +1,47 @@ +import * as path from "path" + +const tools = new Set(["apply_patch", "edit", "generate_image", "multiedit", "write"]) + +function record(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return + return value as Record +} + +function value(input: unknown): string | undefined { + return typeof input === "string" && input.length > 0 ? input : undefined +} + +function files(part: Record): string[] { + const state = record(part.state) + if (part.type !== "tool" || state?.status !== "completed") return [] + if (typeof part.tool !== "string" || !tools.has(part.tool)) return [] + + const meta = record(state.metadata) + if (part.tool === "apply_patch" && Array.isArray(meta?.files)) { + return meta.files.flatMap((item) => { + const file = record(item) + return value(file?.movePath) ?? value(file?.filePath) ?? value(file?.relativePath) ?? [] + }) + } + + if (part.tool === "multiedit" && Array.isArray(meta?.results)) { + return meta.results.flatMap((item) => { + const result = record(item) + const diff = record(result?.filediff) + return value(diff?.file) ?? [] + }) + } + + const diff = record(meta?.filediff) + const input = record(state.input) + return [value(diff?.file) ?? value(meta?.filepath) ?? value(input?.filePath)].filter((file): file is string => !!file) +} + +/** Absolute paths written by completed file-mutating tool parts. */ +export function editPaths(parts: unknown[], base: string): string[] { + return parts.flatMap((part) => { + const item = record(part) + if (!item) return [] + return files(item).map((file) => (path.isAbsolute(file) ? path.normalize(file) : path.resolve(base, file))) + }) +} diff --git a/packages/kilo-vscode/src/review-utils.ts b/packages/kilo-vscode/src/review-utils.ts index c0d7eccbeda7..22e9752a546f 100644 --- a/packages/kilo-vscode/src/review-utils.ts +++ b/packages/kilo-vscode/src/review-utils.ts @@ -1,5 +1,5 @@ -import * as path from "path" import * as vscode from "vscode" +import { resolveInside } from "./diff/shared/path" import { inspect } from "util" export function appendOutput(channel: vscode.OutputChannel, prefix: string, ...args: unknown[]): void { @@ -36,10 +36,9 @@ export function openFileInEditor( .then(undefined, (err) => console.error(`[Kilo New] ${prefix}: Failed to open file:`, uri.fsPath, err)) } -export function openWorkspaceRelativeFile(relativePath: string, line?: number, column?: number): void { - const root = getWorkspaceRoot() +export function openRelativeFile(root: string | undefined, relativePath: string, line?: number, column?: number): void { if (!root) return - const resolved = path.resolve(root, relativePath) - if (!resolved.startsWith(root + path.sep) && resolved !== root) return + const resolved = resolveInside(root, relativePath) + if (!resolved) return openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffPanel") } diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts new file mode 100644 index 000000000000..6f6a20f64a70 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { DiffViewerProvider } from "../../src/diff/DiffViewerProvider" +import type { PanelContext } from "../../src/diff/types" + +describe("DiffViewerProvider.openFromCommand", () => { + it("uses the invoking provider directory even when it is explicitly unavailable", () => { + const provider = new DiffViewerProvider({} as vscode.Uri, {} as never, {} as never, { + sessionIdProvider: () => "sidebar", + sessionDirectoryProvider: () => "/sidebar/repo", + }) + const contexts: PanelContext[] = [] + provider.openPanel = (ctx) => contexts.push(ctx) + + provider.openFromCommand({ sessionId: "agent-manager", directory: "/agent/repo" }) + provider.openFromCommand({ sessionId: "editor-tab", directory: undefined }) + provider.openFromCommand() + + expect(contexts.map((ctx) => ctx.dir)).toEqual(["/agent/repo", undefined, "/sidebar/repo"]) + provider.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 29be0ad5b209..0b1ce8a9e50c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -14,6 +14,7 @@ type Internals = { handleLoadMessages: (sessionID: string) => Promise handleEvent: (event: Event, directory?: string) => void refreshGitStatus: (directory?: string) => Promise + refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -159,7 +160,7 @@ describe("KiloProvider follow-up sessions", () => { }) }) - it("refreshes Git from the file path in a completed edit tool part", () => { + it("refreshes Git from the file path in a completed edit tool part", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never, undefined, { rootDirectory: () => "/workspace", @@ -167,11 +168,13 @@ describe("KiloProvider follow-up sessions", () => { }) const internal = provider as unknown as Internals const dirs: string[] = [] + const refreshed = Promise.withResolvers() const sessionID = "ses-edit" internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) internal.trackedSessionIds.add(sessionID) internal.refreshGitStatus = async (directory) => { if (directory) dirs.push(directory) + refreshed.resolve() } internal.handleEvent( @@ -181,18 +184,22 @@ describe("KiloProvider follow-up sessions", () => { sessionID, part: { type: "tool", - state: { status: "completed" }, - metadata: { filepath: "/workspace/frontend/src/app.ts" }, + tool: "edit", + state: { + status: "completed", + metadata: { filediff: { file: "/workspace/frontend/src/app.ts" } }, + }, }, }, } as Event, "/workspace", ) + await refreshed.promise expect(dirs).toEqual(["/workspace/frontend/src"]) }) - it("ignores completed tool paths outside the active project", () => { + it("ignores completed tool paths outside the active project", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never, undefined, { rootDirectory: () => "/workspace", @@ -207,21 +214,21 @@ describe("KiloProvider follow-up sessions", () => { if (directory) dirs.push(directory) } - internal.handleEvent( - { - type: "message.part.updated", - properties: { - sessionID, - part: { - type: "tool", - state: { status: "completed" }, - metadata: { filepath: "/other-repo/src/app.ts" }, + const found = await internal.refreshGitStatusFromParts( + [ + { + type: "tool", + tool: "edit", + state: { + status: "completed", + metadata: { filediff: { file: "/other-repo/src/app.ts" } }, }, }, - } as Event, - "/workspace", + ], + sessionID, ) + expect(found).toBe(false) expect(dirs).toEqual([]) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index b70eed705756..58de510b24d0 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -242,6 +242,7 @@ type ProviderInternals = { fetchAndSendSandboxDefault: (directory?: string, requestID?: string) => Promise handleSetSandboxDefault: (enabled: boolean, requestID: string, directory?: string) => Promise handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise + refreshGitStatus: (directory?: string, sessionID?: string) => Promise handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -811,6 +812,39 @@ describe("KiloProvider revert ordering", () => { }) describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { + it("recovers the session Git directory from loaded tool history", async () => { + const client = createClient({ + messagesData: [ + { + ...mkMessage("m1", "assistant", 1), + parts: [ + { + type: "tool", + tool: "edit", + state: { + status: "completed", + input: { filePath: "/repo/frontend/src/app.ts" }, + metadata: { filediff: { file: "/repo/frontend/src/app.ts" } }, + }, + }, + ], + }, + ], + }) + const { internal } = makeProvider(client) + const calls: Array<{ directory?: string; sessionID?: string }> = [] + const recovered = defer() + internal.refreshGitStatus = async (directory, sessionID) => { + calls.push({ directory, sessionID }) + if (directory === "/repo/frontend/src") recovered.resolve() + } + + await internal.handleLoadMessages("s1") + await recovered.promise + + expect(calls).toContainEqual({ directory: "/repo/frontend/src", sessionID: "s1" }) + }) + it("stops background processes for the previous session when switching sessions", async () => { const client = createClient({ sessionData: { id: "s2", directory: "/repo/worktree", time: { created: 1, updated: 1 } }, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts index 475607e5fddc..3c3d70b7d8b8 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -35,6 +35,7 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise, vcs } }, list: async () => ({ data: [] }), + status: async () => ({ data: {} }), }, project: { current: async (p: { directory: string }) => { @@ -108,7 +109,10 @@ type ProviderInternals = { isWebviewReady: boolean webview: { postMessage: (message: unknown) => Promise } | null startStatsPolling: () => void - refreshGitStatus: (directory?: string) => Promise + contextSessionID: string | undefined + refreshGitStatus: (directory?: string, sessionID?: string) => Promise + refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise + refreshSessionDetails: (sessionID: string, dir: string) => void handleSendCommand: ( command: string, args: string, @@ -161,6 +165,129 @@ describe("KiloProvider route integration", () => { }) }) + it("keeps a session's discovered Git root across focus refreshes", async () => { + await withNestedRepo(async (root) => { + const source = path.join(root, "src") + await fs.mkdir(source) + const parent = path.dirname(root) + const { connection } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => parent, + }) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() + internal.isWebviewReady = true + internal.startStatsPolling = () => {} + internal.webview = { postMessage: async () => true } + + await internal.refreshGitStatus(source, "s1") + const resolved = await fs.realpath(root) + expect(provider.getSessionGitDirectory("s1")).toBe(resolved) + + const calls: Array<{ directory?: string; sessionID?: string }> = [] + internal.refreshGitStatus = async (directory, sessionID) => { + calls.push({ directory, sessionID }) + } + internal.contextSessionID = "s1" + internal.refreshSessionDetails("s1", parent) + + expect(calls).toEqual([{ directory: resolved, sessionID: "s1" }]) + }) + }) + + it("keeps the session on its owning repo after tools touch a nested repo", async () => { + await withNestedRepo(async (root) => { + const nested = path.join(root, "vendor", "lib") + await fs.mkdir(nested, { recursive: true }) + const result = Bun.spawnSync({ cmd: ["git", "init"], cwd: nested, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + + const { connection } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => root, + }) + const internal = provider as unknown as ProviderInternals + internal.contextSessionID = "s1" + internal.startStatsPolling = () => {} + + expect( + await internal.refreshGitStatusFromParts( + [ + { + type: "tool", + tool: "read", + state: { status: "completed", input: { filePath: path.join(nested, "readme.md") } }, + }, + ], + "s1", + ), + ).toBe(false) + expect(provider.getSessionGitDirectory("s1")).toBeUndefined() + + await internal.refreshGitStatusFromParts( + [ + { + type: "tool", + tool: "edit", + state: { + status: "completed", + metadata: { filediff: { file: path.join(nested, "src.ts") } }, + }, + }, + ], + "s1", + ) + + expect(provider.getSessionGitDirectory("s1")).toBe(await fs.realpath(root)) + }) + }) + + it("caches an inactive child repo without changing the visible Git status", async () => { + await withNestedRepo(async (root) => { + const { connection } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => path.dirname(root), + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.contextSessionID = "parent" + internal.isWebviewReady = true + internal.webview = { postMessage: async (message) => sent.push(message) } + + await internal.refreshGitStatus(root, "child") + + expect(provider.getSessionGitDirectory("child")).toBe(await fs.realpath(root)) + expect(sent).not.toContainEqual({ type: "gitStatus", repo: true }) + }) + }) + + it("does no Git work for non-mutating part updates", async () => { + await withNestedRepo(async (root) => { + const { connection, projectCalls } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => root, + }) + const internal = provider as unknown as ProviderInternals + const parts = [ + { type: "text", text: "chunk" }, + { type: "reasoning", text: "thought" }, + { type: "step-start" }, + { type: "step-finish" }, + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "README.md" } } }, + { type: "tool", tool: "bash", state: { status: "running" } }, + { type: "tool", tool: "grep", state: { status: "completed" } }, + ] + + for (const part of parts) { + expect(await internal.refreshGitStatusFromParts([part], "s1")).toBe(false) + } + + expect(projectCalls).toEqual([]) + expect(provider.getSessionGitDirectory("s1")).toBeUndefined() + }) + }) + it("checks Git capability in the active project directory", async () => { const { connection, projectCalls } = mockConnection() const provider = new KiloProvider({} as never, connection, undefined, { diff --git a/packages/kilo-vscode/tests/unit/review-utils.test.ts b/packages/kilo-vscode/tests/unit/review-utils.test.ts index 677934730108..f6573d561163 100644 --- a/packages/kilo-vscode/tests/unit/review-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/review-utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test" import * as vscode from "vscode" -import { openFileInEditor } from "../../src/review-utils" +import { openFileInEditor, openRelativeFile } from "../../src/review-utils" const execute = spyOn(vscode.commands, "executeCommand") @@ -27,3 +27,17 @@ describe("openFileInEditor", () => { expect(options.selection?.start.character).toBe(2) }) }) + +describe("openRelativeFile", () => { + it("resolves diff paths from the selected repository", () => { + openRelativeFile("/repo/app_alpha", "README.md") + + expect((execute.mock.calls[0]?.[1] as vscode.Uri).fsPath).toBe("/repo/app_alpha/README.md") + }) + + it("rejects paths outside the selected repository", () => { + openRelativeFile("/repo/app_alpha", "../app_beta/README.md") + + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-edits.test.ts b/packages/kilo-vscode/tests/unit/session-edits.test.ts new file mode 100644 index 000000000000..ebf49aa7cf8c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-edits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { editPaths } from "../../src/kilo-provider/session-edits" + +describe("session edit paths", () => { + it("ignores read tools and returns every file from mutating tools", () => { + const parts = [ + { + type: "tool", + tool: "read", + state: { status: "completed", input: { filePath: "/workspace/app/vendor/readme.md" } }, + }, + { + type: "tool", + tool: "edit", + state: { status: "completed", metadata: { filediff: { file: "/workspace/app/src/a.ts" } } }, + }, + { + type: "tool", + tool: "apply_patch", + state: { + status: "completed", + metadata: { + files: [ + { filePath: "/workspace/app/src/b.ts" }, + { filePath: "/workspace/app/src/old.ts", movePath: "/workspace/app/src/new.ts" }, + ], + }, + }, + }, + ] + + expect(editPaths(parts, "/workspace")).toEqual([ + "/workspace/app/src/a.ts", + "/workspace/app/src/b.ts", + "/workspace/app/src/new.ts", + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 549fe75af021..3c14910e70eb 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -385,6 +385,7 @@ describe("SourceController.reactivate", () => { it("rebuilds the active source via the build factory and refetches", async () => { let builds = 0 let fetches = 0 + const dirs: Array = [] const factory = (): DiffSource => { builds++ return { @@ -397,7 +398,10 @@ describe("SourceController.reactivate", () => { } const posted: unknown[] = [] const controller = new SourceController( - () => factory(), + (_id, ctx) => { + dirs.push(ctx.dir) + return factory() + }, () => [WORKSPACE_DESC], (m) => posted.push(m), ) @@ -406,9 +410,11 @@ describe("SourceController.reactivate", () => { expect(builds).toBe(1) expect(fetches).toBe(1) + controller.setContext({ workspaceRoot: "/repo", dir: "/repo/app_beta" }) await controller.reactivate() expect(builds).toBe(2) expect(fetches).toBe(2) + expect(dirs).toEqual([undefined, "/repo/app_beta"]) controller.stop() })