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/fix-multi-project-session-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories.
100 changes: 91 additions & 9 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { diffSummary as localDiffSummary } from "./agent-manager/local-diff"
import { getWorkspaceRoot } from "./review-utils"
import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item"
import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller"
import type { RemoteStatusService } from "./services/RemoteStatusService"
Expand Down Expand Up @@ -441,6 +440,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private statsGitOps: GitOps | null = null
private cachedStats: unknown = null
private cachedGitRepo = false
private cachedGitDirectory: string | undefined
private gitStatusRevision = 0

private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null

Expand Down Expand Up @@ -1664,6 +1665,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(payload, directory) => {
if (directory && !this.isCurrentProjectDirectory(directory)) return false
if (!directory && isEventFromForeignProject(payload, this.projectID)) return false
const event = unwrapSyncEvent(payload)
if (!event) return false

Expand All @@ -1675,6 +1678,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

// message.part.* events are always session-scoped; drop if session unknown.
if (!sessionId) return !isSessionScopedPartEvent(event.type)
if (!directory && !this.isCurrentProjectSession(sessionId)) return false

if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
return true
Expand Down Expand Up @@ -1821,15 +1825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.memory.fetch(),
this.seedSessionStatusMap(),
])
this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory())
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
await this.refreshGitStatus(this.getWorkspaceDirectory())
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.postMessage({ type: "extensionDataReady" })

if (this.cachedGitRepo) this.startStatsPolling()

console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
Expand Down Expand Up @@ -1890,6 +1891,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)
const revision = this.revisions.get(sessionID)
const refresh = (this.refreshes.get(sessionID) ?? 0) + 1
this.refreshes.set(sessionID, refresh)
Expand Down Expand Up @@ -2105,10 +2107,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async flushPendingSessionRefresh(reason: string): Promise<void> {
if (!this.pendingSessionRefresh) return
console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason })
const scope = this.opts.projectQualifier?.()?.projectId
if (scope !== undefined) this.projectID = undefined
const ctx = this.sessionRefreshContext
try {
const resolved = await flushPendingSessionRefreshUtil(ctx)
if (resolved) this.projectID = resolved
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error)
}
Expand All @@ -2119,10 +2123,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Handle loading all sessions.
*/
private async handleLoadSessions(): Promise<void> {
const scope = this.opts.projectQualifier?.()?.projectId
if (scope !== undefined) this.projectID = undefined
const ctx = this.sessionRefreshContext
try {
const resolved = await loadSessionsUtil(ctx)
if (resolved) this.projectID = resolved
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to load sessions:", error)
this.postMessage({
Expand Down Expand Up @@ -4306,9 +4312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return
if (directory && !this.isCurrentProjectDirectory(directory)) return
if (
this.projectID &&
(!this.opts.projectQualifier || !directory) &&
(event.type === "session.created" || event.type === "session.updated") &&
event.properties.info.projectID !== undefined &&
event.properties.info.projectID !== null &&
Expand Down Expand Up @@ -4367,6 +4374,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
return

if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID)

if (event.type === "session.updated" && typeof event.properties.info.cost === "number") {
const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost)
this.requestCostAlert(event.properties.sessionID, cost)
Expand Down Expand Up @@ -4737,6 +4746,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return undefined
}

private isCurrentProjectDirectory(directory: string): boolean {
if (!this.opts.projectQualifier?.()) return true
const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
return dirs.some((dir) => sameDirectory(dir, directory))
}

private isCurrentProjectSession(sessionID: string): boolean {
if (!this.opts.projectQualifier || !this.opts.routeService) return true
const directory = this.opts.routeService.trySessionDirectory(sessionID)
return !directory || this.isCurrentProjectDirectory(directory)
}

private refreshGitStatusFromPart(
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
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)
}

private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean {
const roots = this.opts.projectQualifier?.()
? [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
: [this.getWorkspaceDirectory(sessionID)]
return roots.some((root) => {
const rel = path.relative(canonicalizePath(root), canonicalizePath(directory))
return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`))
})
}

public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise<void> {
const client = this.client
if (!client) return
const revision = ++this.gitStatusRevision
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 (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null
this.cachedGitDirectory = target
this.cachedGitRepo = found
this.postMessage({ type: "gitStatus", repo: found })
if (found) {
if (!this.statsPoller) this.startStatsPolling()
return
}
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.statsPoller = null
this.statsGitOps = null
}

private async resolveGitRoot(directory: string): Promise<string | undefined> {
const git = this.statsGitOps ?? new GitOps({ log: () => {} })
const root = await git.root(directory)
if (!this.statsGitOps) git.dispose()
return root
}

private getContextDirectory(): string {
return resolveContextDirectory({
currentSessionID: this.currentSession?.id,
Expand Down Expand Up @@ -4846,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.statsGitOps = git
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id),
localDiff: (dir, base) => localDiffSummary(git, dir, base),
git,
onStats: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1555,7 +1555,11 @@ export class AgentManagerProvider implements Disposable {
void this.sendRepoInfo()
if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c)))
this.stateReady = this.initializeState()
else this.projectPollers.sync(this.contexts)
else {
this.panel?.sessions.refreshSessions()
this.projectPollers.sync(this.contexts)
}
this.panel?.sessions.refreshGitStatus?.()
}
private onWorkspaceChanged(): void {
if (this.contexts.syncPinned()) {
Expand Down
4 changes: 4 additions & 0 deletions packages/kilo-vscode/src/agent-manager/GitOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export class GitOps {
return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "")
}

async root(cwd: string): Promise<string | undefined> {
return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined)
}

/**
* Resolve the remote name for a branch. Checks (in order):
* 1. The configured upstream's remote (e.g. upstream from `upstream/main`)
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/src/agent-manager/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface SessionProvider {
isSessionRouteAmbiguous?(sessionId: string): boolean
/** Exact directory for a project-qualified session ref, or undefined. */
routeSessionDirectoryFor?(ref: SessionRef): string | undefined
/** Re-check Git capability for the active project/session directory. */
refreshGitStatus?(): void
dispose(): void
}

Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export class VscodeHost implements Host {
unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref),
isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId),
routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref),
refreshGitStatus: () => void provider.refreshGitStatus(),
dispose: () => provider.dispose(),
}

Expand Down
5 changes: 4 additions & 1 deletion packages/kilo-vscode/src/kilo-provider-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,10 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess
* Returns true when the event carries a projectID that does not match the expected one.
* When expectedProjectID is undefined (not yet resolved), nothing is filtered.
*/
export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean {
export function isEventFromForeignProject(
event: StreamEvent | SyncPayload,
expectedProjectID: string | undefined,
): boolean {
if (!expectedProjectID || event.type !== "sync") return false
if (event.name === "session.created.1" || event.name === "session.deleted.1") {
return event.data.info.projectID !== expectedProjectID
Expand Down
4 changes: 2 additions & 2 deletions packages/kilo-vscode/src/kilo-provider/git-status.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"

export async function hasGit(client: KiloClient, directory: string): Promise<boolean> {
return client.project
.current({ directory })
return Promise.resolve()
.then(() => client.project.current({ directory }))
.then((r) => r.data?.vcs === "git")
.catch(() => false)
}
17 changes: 17 additions & 0 deletions packages/kilo-vscode/tests/unit/git-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ describe("GitOps", () => {
})
})

describe("root", () => {
it("resolves the nearest enclosing repository", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend"
return ""
})
expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend")
})

it("returns undefined outside a repository", async () => {
const git = ops(async () => {
throw new Error("not a git repo")
})
expect(await git.root("/workspace")).toBeUndefined()
})
})

describe("resolveRemote", () => {
it("uses upstream remote when upstream is configured", async () => {
const git = ops(async (args) => {
Expand Down
Loading
Loading