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/agent-manager-notification-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Open attention notifications for Agent Manager sessions in Agent Manager instead of the sidebar, selecting the right project, worktree or Local tab, and session
50 changes: 22 additions & 28 deletions packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { DiffSourceCatalog } from "../diff/sources/catalog"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { composeDiffId, normalizeScope } from "./diff-scope"
Expand Down Expand Up @@ -77,6 +76,8 @@ import { createMultiVersion, type MultiVersionHost } from "./provider-multi-vers
import { handleProjectMessage, routeProjectSession, type ProjectMessageDeps } from "./project/messages"
import { createProjectWiring, type ProjectWiring } from "./project/wiring"
import { ProjectScope } from "./project/scope"
import { revealManagedSession } from "./reveal-session"
import { resolveWorktreeFile } from "./worktree-file-path"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
import { focusPanelPrompt, revealPanel } from "./focus-panel"
Expand Down Expand Up @@ -1640,34 +1641,10 @@ export class AgentManagerProvider implements Disposable {
this.host.openFolder(target, true)
}

/** Open a file from a worktree or local session in the VS Code editor.
* Absolute paths are opened directly; relative paths resolve against the
* context's worktree directory (repo root for local) with symlink-traversal
* protection. The id may be a worktree id, session id, or `local`. */
/** Open a file from a worktree or local session in the VS Code editor. */
private openWorktreeFile(id: string, filePath: string, line?: number, column?: number): void {
if (isAbsolutePath(filePath)) {
this.host.openFile(filePath, line, column)
return
}
const state = this.getStateManager()
if (!state) return
const worktree = state.getWorktree(id)
const session = worktree ? undefined : state.getSession(id)
const base = worktree?.path ?? (session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getRoot())
if (!base) return
// Resolve real paths to prevent symlink traversal and normalize for
// consistent comparison on both Unix and Windows.
let resolved: string
try {
const root = fs.realpathSync(base)
resolved = fs.realpathSync(path.resolve(base, filePath))
// Directory-boundary check: append path.sep so "/foo/bar" won't match "/foo/bar2/..."
if (resolved !== root && !resolved.startsWith(root + path.sep)) return
} catch (err) {
console.error("[Kilo New] AgentManagerProvider: Cannot resolve file path:", err)
return
}
this.host.openFile(resolved, line, column)
const target = resolveWorktreeFile(this.getStateManager(), id, filePath, this.getRoot())
if (target) this.host.openFile(target, line, column)
}

private postToWebview(message: AgentManagerOutMessage): void {
Expand Down Expand Up @@ -1759,6 +1736,23 @@ export class AgentManagerProvider implements Disposable {
return this.panel?.sessions.getSessionDirectories() ?? new Map()
}

/**
* Reveal a session Agent Manager owns: activate its project, open the panel,
* and select its worktree and session tab. False means Agent Manager does not
* own the session (or its worktree is gone) and the caller should fall back.
*/
public revealSession(sessionId: string): Promise<boolean> {
return revealManagedSession(sessionId, this.contexts, {
directories: () => this.getSessionDirectories(),
activate: (ctx) => this.activateProject(ctx),
projects: () => this.pushProjects(),
open: () => this.openPanel(),
state: () => this.waitForStateReady("revealSession"),
ready: () => this.waitForReady(),
post: (message) => this.panel?.postMessage(message),
})
}

public getWorktreeDirectories(): string[] {
return (
this.getStateManager()
Expand Down
94 changes: 94 additions & 0 deletions packages/kilo-vscode/src/agent-manager/reveal-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ProjectContext } from "./project/context"
import type { ProjectContexts } from "./project/contexts"
import { samePath } from "./project/paths"

export interface ManagedSessionTarget {
context: ProjectContext
projectId: string
sessionId: string
/** Absent for a session in the project's Local tabs. */
worktreeId?: string
}

interface RevealMessage {
type: "agentManager.revealSession"
projectId: string
sessionId: string
worktreeId?: string
}

/**
* Resolve a session Agent Manager owns, or undefined when the sidebar should
* handle it. Worktree ownership requires the worktree to still exist and still
* match the session's directory, so a removed worktree falls back.
*/
export function resolveManagedSession(
contexts: ProjectContexts,
directories: ReadonlyMap<string, string>,
sessionId: string,
): ManagedSessionTarget | undefined {
const directory = directories.get(sessionId)
if (!directory) return
const context = contexts.byDirectory(directory) ?? contexts.byLiveSession(sessionId)
if (!context) return
const state = context.peekState()
const session = state?.getSession(sessionId)
// A Local tab has no worktree. The panel also tracks plain sidebar sessions
// discovered in the project root, so only a session Agent Manager persisted
// counts as owned; anything else keeps the sidebar.
if (session && !session.worktreeId) {
if (!samePath(context.root, directory)) return
return { context, projectId: context.id, sessionId }
}
const worktree = session?.worktreeId
? state?.getWorktree(session.worktreeId)
: state?.getWorktrees().find((item) => samePath(item.path, directory))
if (!worktree || !samePath(worktree.path, directory)) return
return { context, projectId: context.id, sessionId, worktreeId: worktree.id }
}

export interface RevealDeps {
/** Session→directory map for sessions Agent Manager currently tracks. */
directories: () => ReadonlyMap<string, string>
/** Re-wire provider state for the newly activated project. */
activate: (context: ProjectContext) => void
/** Publish the project catalog so the webview applies the new active project. */
projects: () => void
open: () => void
/** Resolve once the project's worktree state is loaded. */
state: () => Promise<void>
/** Resolve false when the panel closed before it was ready. */
ready: () => Promise<boolean>
post: (message: RevealMessage) => void
}

/**
* Focus an Agent Manager-owned session. The project is activated and published
* before the reveal is posted, so the webview never rejects the message as
* belonging to its previous project.
*/
export async function revealManagedSession(
sessionId: string,
contexts: ProjectContexts,
deps: RevealDeps,
): Promise<boolean> {
const target = resolveManagedSession(contexts, deps.directories(), sessionId)
if (!target) return false
// Re-activating the project the panel already shows would reset its PR,
// stats, and busy-session state for no gain, so only switch when needed.
if (contexts.active()?.id !== target.projectId) {
contexts.activate(target.projectId)
deps.activate(target.context)
deps.projects()
}
deps.open()
await deps.state()
if (!(await deps.ready())) return false
deps.post({
type: "agentManager.revealSession",
projectId: target.projectId,
sessionId,
worktreeId: target.worktreeId,
})
return true
}
35 changes: 35 additions & 0 deletions packages/kilo-vscode/src/agent-manager/worktree-file-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as fs from "fs"
import * as path from "path"
import { isAbsolutePath } from "../path-utils"
import type { WorktreeStateManager } from "./WorktreeStateManager"

/**
* Resolve a file reference from a worktree or local session to an absolute
* path. Absolute inputs pass through; relative inputs resolve against the
* context's worktree directory (repo root for local). Real paths are resolved
* so a symlink cannot escape that directory, and out-of-bounds or unresolvable
* paths return undefined. The id may be a worktree id, session id, or `local`.
*/
export function resolveWorktreeFile(
state: WorktreeStateManager | undefined,
id: string,
file: string,
root: string | undefined,
): string | undefined {
if (isAbsolutePath(file)) return file
if (!state) return
const worktree = state.getWorktree(id)
const session = worktree ? undefined : state.getSession(id)
const base = worktree?.path ?? (session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : root)
if (!base) return
try {
const dir = fs.realpathSync(base)
const resolved = fs.realpathSync(path.resolve(base, file))
// Directory-boundary check: append path.sep so "/foo/bar" won't match "/foo/bar2/...".
if (resolved !== dir && !resolved.startsWith(dir + path.sep)) return
return resolved
} catch (err) {
console.error("[Kilo New] AgentManagerProvider: Cannot resolve file path:", err)
return
}
}
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export async function activate(context: vscode.ExtensionContext) {
visible: (sessionID) => connectionService.isVisible(sessionID),
os: showOSNotification,
show: async (sessionID, directory) => {
if (await agentManagerProvider.revealSession(sessionID)) return
await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus")
await provider.openSession(sessionID, directory)
},
Expand Down
136 changes: 136 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-reveal-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from "bun:test"
import { revealManagedSession, resolveManagedSession } from "../../src/agent-manager/reveal-session"
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"

const ROOT = "/repo"
const WORKTREE = { id: "worktree-a", path: "/repo/worktree-a" }

/** One project whose state reports the given managed sessions and worktrees. */
function contexts(
opts: {
sessions?: Record<string, { worktreeId: string | null }>
worktrees?: Array<{ id: string; path: string }>
active?: string
} = {},
) {
const state = {
getSession: (id: string) => opts.sessions?.[id],
getWorktree: (id: string) => opts.worktrees?.find((item) => item.id === id),
getWorktrees: () => opts.worktrees ?? [],
}
const context = { id: "project-a", root: ROOT, peekState: () => state }
const activated: string[] = []
const value = {
active: () => (opts.active === undefined ? undefined : { id: opts.active }),
activate: (id: string) => {
activated.push(id)
return context
},
byDirectory: () => context,
byLiveSession: () => undefined,
} as unknown as ProjectContexts
return { value, activated }
}

function deps(directory: string, calls: string[], messages: unknown[] = []) {
return {
directories: () => new Map([["session-a", directory]]),
activate: () => calls.push("activate"),
projects: () => calls.push("projects"),
open: () => calls.push("open"),
state: async () => void calls.push("state"),
ready: async () => true,
post: (message: unknown) => messages.push(message),
}
}

describe("resolveManagedSession", () => {
it("resolves the owning project and worktree for a tracked session", () => {
const result = resolveManagedSession(
contexts({ sessions: { "session-a": { worktreeId: WORKTREE.id } }, worktrees: [WORKTREE] }).value,
new Map([["session-a", WORKTREE.path]]),
"session-a",
)

expect(result).toMatchObject({ projectId: "project-a", sessionId: "session-a", worktreeId: WORKTREE.id })
})

it("falls back when the worktree was removed or the directory is stale", () => {
const result = resolveManagedSession(
contexts({ sessions: { "session-a": { worktreeId: WORKTREE.id } } }).value,
new Map([["session-a", WORKTREE.path]]),
"session-a",
)

expect(result).toBeUndefined()
})

it("resolves a session Agent Manager persisted in its Local tabs", () => {
const result = resolveManagedSession(
contexts({ sessions: { "session-a": { worktreeId: null } } }).value,
new Map([["session-a", ROOT]]),
"session-a",
)

expect(result).toMatchObject({ projectId: "project-a", sessionId: "session-a" })
expect(result?.worktreeId).toBeUndefined()
})

it("leaves a sidebar session in the project root to the sidebar", () => {
// The panel also tracks live sessions listed from the project root, so a
// root directory alone must not count as Agent Manager ownership.
const result = resolveManagedSession(contexts().value, new Map([["session-a", ROOT]]), "session-a")

expect(result).toBeUndefined()
})
})

describe("revealManagedSession", () => {
it("activates the project before revealing and scrolling the session", async () => {
const calls: string[] = []
const messages: unknown[] = []
const project = contexts({
sessions: { "session-a": { worktreeId: WORKTREE.id } },
worktrees: [WORKTREE],
active: "project-b",
})

const result = await revealManagedSession("session-a", project.value, deps(WORKTREE.path, calls, messages))

expect(result).toBe(true)
expect(project.activated).toEqual(["project-a"])
expect(calls).toEqual(["activate", "projects", "open", "state"])
expect(messages).toEqual([
{ type: "agentManager.revealSession", projectId: "project-a", sessionId: "session-a", worktreeId: WORKTREE.id },
])
})

it("does not re-activate the project the panel already shows", async () => {
// Re-activating resets PR, stats, and busy-session state, so revealing a
// session in the current project must not touch it.
const calls: string[] = []
const project = contexts({
sessions: { "session-a": { worktreeId: WORKTREE.id } },
worktrees: [WORKTREE],
active: "project-a",
})

const result = await revealManagedSession("session-a", project.value, deps(WORKTREE.path, calls))

expect(result).toBe(true)
expect(project.activated).toEqual([])
expect(calls).toEqual(["open", "state"])
})

it("reveals a Local session without a worktree", async () => {
const messages: unknown[] = []
const project = contexts({ sessions: { "session-a": { worktreeId: null } }, active: "project-a" })

const result = await revealManagedSession("session-a", project.value, deps(ROOT, [], messages))

expect(result).toBe(true)
expect(messages).toEqual([
{ type: "agentManager.revealSession", projectId: "project-a", sessionId: "session-a", worktreeId: undefined },
])
})
})
Loading
Loading