Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/multirepo-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces.
99 changes: 80 additions & 19 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -385,6 +386,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly anacondaDesktop = new AnacondaDesktopBridge()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
private sessionGitDirectories = new Map<string, string>() // Stable Git root resolved for each session.
private sessionGitRecoveries = new Set<string>() // 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -4783,21 +4806,54 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
event: Extract<ProviderEvent, { type: "message.part.updated" }>,
sessionID?: string,
) {
const part = event.properties.part as {
type?: string
metadata?: Record<string, unknown>
state?: { status?: string; input?: Record<string, unknown>; metadata?: Record<string, unknown> }
}
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<boolean> {
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<void> {
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 }),
Comment thread
johnnyeric marked this conversation as resolved.
).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 {
Expand All @@ -4810,15 +4866,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}

public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise<void> {
public async refreshGitStatus(directory = this.getWorkspaceDirectory(), sessionID?: string): Promise<void> {
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
Expand Down
26 changes: 20 additions & 6 deletions packages/kilo-vscode/src/diff/DiffViewerProvider.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,6 +13,7 @@ type CommentHandler = (comments: unknown[], autoSend: boolean) => void

export interface DiffViewerProviderOptions {
sessionIdProvider?: () => string | undefined
sessionDirectoryProvider?: (sessionId: string) => string | undefined
}

/**
Expand All @@ -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(
Expand All @@ -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")
}

Expand All @@ -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
}

Expand All @@ -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,
})
Expand Down Expand Up @@ -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<void> {
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",
Expand All @@ -212,7 +226,7 @@ export class DiffViewerProvider implements vscode.Disposable {
vscodeLanguage: vscode.env.language,
languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get<string>("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
Expand Down
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
},
),
Expand Down
47 changes: 47 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/session-edits.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined {
if (!value || typeof value !== "object") return
return value as Record<string, unknown>
}

function value(input: unknown): string | undefined {
return typeof input === "string" && input.length > 0 ? input : undefined
}

function files(part: Record<string, unknown>): 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)))
})
}
9 changes: 4 additions & 5 deletions packages/kilo-vscode/src/review-utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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")
}
22 changes: 22 additions & 0 deletions packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading