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

Make the Agent Manager diff review follow the sidebar selection instead of a single session. Switching session tabs inside a worktree no longer refetches the Branch, Staged, and Unstaged scopes, the Session scope now swaps to the active session's changes on tab switch, and the Local tab gains the Session scope so sessions running in the workspace can be reviewed on their own. The Session scope shows a notice when snapshots are disabled instead of a blank list, worktrees without an open session now still show their branch diff, and the Apply dialog lists the worktree's changes again.
17 changes: 9 additions & 8 deletions packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.requestWorktreeDiffFile") {
void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file)
void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope), m.diffSessionId), m.file)
return null
}
if (m.type === "agentManager.applyWorktreeDiff") {
Expand All @@ -712,7 +712,7 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.startDiffWatch") {
this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope)))
this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope), m.diffSessionId))
return null
}
if (m.type === "agentManager.stopDiffWatch") {
Expand Down Expand Up @@ -1766,18 +1766,19 @@ export class AgentManagerProvider implements Disposable {
}

/** Open a file from a worktree or local session in the VS Code editor.
* Absolute paths (Unix `/…` or Windows `C:\…`) are opened directly.
* Relative paths are resolved against the session's worktree directory
* (or repo root for local sessions) with symlink-traversal protection. */
private openWorktreeFile(sessionId: string, filePath: string, line?: number, column?: number): void {
* 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`. */
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 session = state.getSession(sessionId)
const base = session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getRoot()
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.
Expand Down
8 changes: 4 additions & 4 deletions packages/kilo-vscode/src/agent-manager/delete-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import type { ManagedSession } from "./WorktreeStateManager"
* Determine whether diff polling should stop when a worktree is being removed.
*
* Returns true when the worktree being deleted is currently the diff target
* (either by directory path or because one of its orphaned sessions is the
* active diff session).
* (either by directory path or because the diff context is the worktree
* itself or one of its orphaned sessions).
*/
export function shouldStopDiffPolling(
worktreePath: string,
orphaned: ManagedSession[],
diffTarget: { directory: string } | undefined,
diffSessionId: string | undefined,
diffCtx: string | undefined,
): boolean {
if (diffTarget && normalizePath(diffTarget.directory) === normalizePath(worktreePath)) return true
if (diffSessionId && orphaned.some((s) => s.id === diffSessionId)) return true
if (diffCtx && orphaned.some((s) => s.worktreeId === diffCtx || s.id === diffCtx)) return true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: the "diff context is the worktree itself" case isn't actually checked

The updated doc comment says polling stops when the diff context is the worktree being deleted, but diffCtx is only ever compared against session ids here — the deleted worktree's own id never reaches this function. It therefore only works while the worktree still has sessions.

A worktree with no sessions is now a valid diff context (one of the fixes in this PR), and WorktreeDiffController.request() clears this.target for the currently active id (reachable from the Apply dialog, which now requests the very same ctx#branch id the panel is watching). So diffTarget can be undefined while orphaned is empty, this returns false, diffs.stop() is skipped, and the poll interval keeps running git in the removed directory. Passing the worktree id in and comparing it directly to diffCtx would close that gap.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return false
}
31 changes: 20 additions & 11 deletions packages/kilo-vscode/src/agent-manager/diff-scope.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/**
* Composite diff-source keying for Agent Manager.
*
* Agent Manager keys diff sources by *context* (a session id, or the `local`
* Agent Manager keys diff sources by *context* (a worktree id, or the `local`
* workspace pseudo-context) while the standalone Changes viewer keys by
* *scope* (branch / staged / unstaged / session). To expose scopes in Agent
* Manager we compose the two into a single id the SourceController can build.
* The context is the sidebar selection, so it stays stable when the user
* switches between session tabs of the same worktree; only the Session scope
* follows the active session, carried inside the id.
*
* ctx = "local" | "<sessionId>"
* ctx = "local" | "<worktreeId>"
* scope = "branch" | "staged" | "unstaged" | "session"
* id = `${ctx}#${scope}`
* id = `${ctx}#${scope}` (git scopes)
* id = `${ctx}#session:<sid>` (session scope, sid = active session id)
*
* `ctx#branch` is the default and reproduces the pre-scope behavior exactly.
*/
Expand All @@ -18,8 +22,10 @@ export type DiffScope = "branch" | "staged" | "unstaged" | "session"
export const DEFAULT_DIFF_SCOPE: DiffScope = "branch"

const SEP = "#"
const SESSION_TOKEN = "session:"

export function composeDiffId(ctx: string, scope: DiffScope): string {
export function composeDiffId(ctx: string, scope: DiffScope, sessionId?: string): string {
if (scope === "session" && sessionId) return `${ctx}${SEP}${SESSION_TOKEN}${sessionId}`
return `${ctx}${SEP}${scope}`
}

Expand All @@ -28,11 +34,13 @@ export function composeDiffId(ctx: string, scope: DiffScope): string {
* id (no separator) by assuming the default branch scope, which keeps the
* pre-scope messages working unchanged.
*/
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
export function parseDiffId(id: string): { ctx: string; scope: DiffScope; sessionId?: string } {
const idx = id.lastIndexOf(SEP)
if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
const scope = id.slice(idx + SEP.length)
if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
const token = id.slice(idx + SEP.length)
const ctx = id.slice(0, idx)
if (token.startsWith(SESSION_TOKEN)) return { ctx, scope: "session", sessionId: token.slice(SESSION_TOKEN.length) }
if (isDiffScope(token)) return { ctx, scope: token }
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
}

Expand All @@ -46,12 +54,13 @@ export function normalizeScope(value: unknown): DiffScope {

/**
* Map a scope to the underlying standalone-viewer source id the catalog knows
* how to build. `branch` maps to the workspace source; `session` is handled
* separately because it needs the session id embedded in the source id.
* how to build. `branch` maps to the workspace source; `session` needs the
* active session id embedded in the source id (the context id is a worktree
* or `local`, not a session).
*/
export function scopeToSourceId(scope: DiffScope, ctx: string): string {
export function scopeToSourceId(scope: DiffScope, ctx: string, sessionId?: string): string {
if (scope === "staged") return "staged"
if (scope === "unstaged") return "unstaged"
if (scope === "session") return `session:${ctx}`
if (scope === "session") return `session:${sessionId ?? ctx}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: the ?? ctx fallback can now build a session source from a worktree id

When ctx was a session id this fallback degraded gracefully. Now that ctx is a worktree id (or local), it produces session:<worktreeId>. DiffSourceCatalog.build accepts anything non-empty after session:, so a bare ctx#session id — which composeDiffId yields whenever the session scope is active while the active session is momentarily undefined (webview-ui/agent-manager/diff-review-scope.ts:38) — becomes a snapshot fetch for a session that does not exist. SourceController.runFetch swallows the rejection and returns true, so polling continues and the user sees a permanently empty Session diff with no notice.

The only thing preventing that id from being sent today is that the reset-to-Branch effect (diff-review-scope.ts:60) is created before the watch effect, so it wins the flush. Consider making the missing-session case explicit at both ends instead of falling back to the context id — the new case in tests/unit/diff-scope.test.ts currently pins the fallback as intended behavior.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return "workspace"
}
12 changes: 12 additions & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ interface WorktreeDiffLoadingMessage {
loading: boolean
}

/** Source-level notice for a diff context (e.g. snapshots disabled). */
interface WorktreeDiffNoticeMessage {
type: "agentManager.worktreeDiffNotice"
sessionId: string
notice?: string
}

interface WorktreeDiffMessage {
type: "agentManager.worktreeDiff"
sessionId: string
Expand Down Expand Up @@ -339,6 +346,7 @@ export type AgentManagerOutMessage =
| RepoInfoMessage
| ApplyWorktreeDiffResultMessage
| WorktreeDiffLoadingMessage
| WorktreeDiffNoticeMessage
| WorktreeDiffMessage
| WorktreeDiffFileMessage
| RevertWorktreeFileResultMessage
Expand Down Expand Up @@ -552,12 +560,16 @@ interface RequestWorktreeDiffFileIn {
sessionId: string
file: string
scope?: string
/** Active session for the session scope (ctx alone is a worktree/local id). */
diffSessionId?: string
}

interface StartDiffWatchIn {
type: "agentManager.startDiffWatch"
sessionId: string
scope?: string
/** Active session for the session scope (ctx alone is a worktree/local id). */
diffSessionId?: string
}

interface StopDiffWatchIn {
Expand Down
41 changes: 20 additions & 21 deletions packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export class WorktreeDiffController {
sessionId: source.descriptor.id,
loading,
}),
notice: (source, notice) => ({
type: "agentManager.worktreeDiffNotice",
sessionId: source.descriptor.id,
notice,
}),
diffs: (source, diffs) => ({
type: "agentManager.worktreeDiff",
sessionId: source.descriptor.id,
Expand Down Expand Up @@ -81,8 +86,8 @@ export class WorktreeDiffController {
}

public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean {
// Pass the parsed context id, not the composite id, so the orphaned-session
// check matches real session ids.
// The parsed context id is a worktree id (or `local`), so the
// orphaned-session check matches sessions of the deleted worktree.
const current = this.controller.currentId
const ctxId = current ? parseDiffId(current).ctx : undefined
return shouldStopDiffPolling(path, sessions, this.target, ctxId)
Expand Down Expand Up @@ -225,6 +230,9 @@ export class WorktreeDiffController {
const { ctx } = parseDiffId(id)
const resolved = await this.resolve(ctx)
this.target = resolved ? { sessionId: id, ...resolved } : undefined
// Clear any stale source notice up front; sources only push a notice when
// one is active, so a swap away from a noticing source must reset it.
this.ctx.post({ type: "agentManager.worktreeDiffNotice", sessionId: id, notice: undefined })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: a notice that stops being reported mid-poll keeps its banner

Clearing on activate handles source swaps, but not the case where the same source stays active and stops reporting. SourceController.runFetch only posts when result.notice !== undefined, so if the user does exactly what the snapshots-disabled banner asks (enable snapshots in the config), the source stops returning the notice and the banner stays up until the context is re-activated. Posting the notice on every fetch — or posting undefined when a fetch reports none — would let it clear itself.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

this.controller.setContext({
workspaceRoot: this.ctx.getRoot(),
dir: resolved?.directory,
Expand All @@ -250,21 +258,11 @@ export class WorktreeDiffController {
return undefined
}

const session = state.getSession(ctxId)
if (!session) {
this.ctx.log(
`resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`,
)
return undefined
}
if (!session.worktreeId) {
this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`)
return undefined
}

const worktree = state.getWorktree(session.worktreeId)
// The context is the worktree itself (the sidebar selection), not one of
// its sessions — resolution survives session churn inside the worktree.
const worktree = state.getWorktree(ctxId)
if (!worktree) {
this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`)
this.ctx.log(`resolveDiffTarget: worktree ${ctxId} not found`)
return undefined
}
const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree)
Expand All @@ -287,13 +285,14 @@ export class WorktreeDiffController {

/**
* Build the active source for a composite id by delegating to the catalog.
* The composite id (ctx#scope) is preserved as the descriptor id so the
* webview keys diff data by context+scope. Context resolution (dir/base)
* already happened in activate() and is carried by the PanelContext.
* The composite id (`ctx#scope`, or `ctx#session:<sid>` for the session
* scope) is preserved as the descriptor id so the webview keys diff data by
* context+scope. Context resolution (dir/base) already happened in
* activate() and is carried by the PanelContext.
*/
private source(id: string, panelCtx: PanelContext): DiffSource {
const { ctx, scope } = parseDiffId(id)
const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx)
const { ctx, scope, sessionId } = parseDiffId(id)
const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx, sessionId), panelCtx)
return {
...built,
descriptor: { ...built.descriptor, id },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from "bun:test"
import { composeDiffId, parseDiffId, scopeDescriptors } from "../../webview-ui/agent-manager/diff-scope-state"

describe("agent-manager webview diff scope descriptors", () => {
it("offers the three git scopes without an active session", () => {
const descriptors = scopeDescriptors("wt_1")
expect(descriptors.map((d) => d.type)).toEqual(["workspace", "staged", "unstaged"])
expect(descriptors.map((d) => d.id)).toEqual(["wt_1#branch", "wt_1#staged", "wt_1#unstaged"])
})

it("adds the session scope with the active session embedded", () => {
const descriptors = scopeDescriptors("wt_1", "ses_abc")
expect(descriptors.map((d) => d.type)).toEqual(["workspace", "staged", "unstaged", "session"])
const session = descriptors[3]!
expect(session.id).toBe("wt_1#session:ses_abc")
expect(session.group).toBe("Session")
expect(session.capabilities.revert).toBe(false)
})

it("embeds the active local session for the local context", () => {
const descriptors = scopeDescriptors("local", "ses_abc")
expect(descriptors[3]!.id).toBe("local#session:ses_abc")
})

it("round-trips the session descriptor id", () => {
expect(parseDiffId(composeDiffId("wt_1", "session", "ses_abc"))).toEqual({
ctx: "wt_1",
scope: "session",
sessionId: "ses_abc",
})
})
})
39 changes: 28 additions & 11 deletions packages/kilo-vscode/tests/unit/diff-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,38 @@ import {
describe("diff-scope composite ids", () => {
it("round-trips context and scope", () => {
expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" })
expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" })
expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" })
expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" })
expect(parseDiffId(composeDiffId("wt_abc", "staged"))).toEqual({ ctx: "wt_abc", scope: "staged" })
expect(parseDiffId(composeDiffId("wt_abc", "unstaged"))).toEqual({ ctx: "wt_abc", scope: "unstaged" })
expect(parseDiffId(composeDiffId("wt_abc", "session"))).toEqual({ ctx: "wt_abc", scope: "session" })
})

it("parses session ids containing no separator as default branch scope", () => {
expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE })
it("embeds the active session id in the session scope", () => {
const id = composeDiffId("wt_abc", "session", "ses_xyz")
expect(id).toBe("wt_abc#session:ses_xyz")
expect(parseDiffId(id)).toEqual({ ctx: "wt_abc", scope: "session", sessionId: "ses_xyz" })
expect(parseDiffId(composeDiffId("local", "session", "ses_xyz"))).toEqual({
ctx: "local",
scope: "session",
sessionId: "ses_xyz",
})
})

it("parses context ids containing no separator as default branch scope", () => {
expect(parseDiffId("wt_abc")).toEqual({ ctx: "wt_abc", scope: DEFAULT_DIFF_SCOPE })
})

it("treats an unknown trailing segment as part of the context, not a scope", () => {
// A session id that happens to contain '#' but not a valid scope keeps the
// A context id that happens to contain '#' but not a valid scope keeps the
// full id as context and falls back to branch.
expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE })
expect(parseDiffId("wt_a#bogus")).toEqual({ ctx: "wt_a#bogus", scope: DEFAULT_DIFF_SCOPE })
})

it("isDiffScope guards the closed enum", () => {
expect(isDiffScope("branch")).toBe(true)
expect(isDiffScope("staged")).toBe(true)
expect(isDiffScope("unstaged")).toBe(true)
expect(isDiffScope("session")).toBe(true)
expect(isDiffScope("session:ses_xyz")).toBe(false)
expect(isDiffScope("turn")).toBe(false)
expect(isDiffScope("")).toBe(false)
})
Expand All @@ -43,10 +55,15 @@ describe("diff-scope composite ids", () => {
})

it("maps scopes to catalog source ids", () => {
expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace")
expect(scopeToSourceId("staged", "ses_abc")).toBe("staged")
expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged")
expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc")
expect(scopeToSourceId("branch", "wt_abc")).toBe("workspace")
expect(scopeToSourceId("staged", "wt_abc")).toBe("staged")
expect(scopeToSourceId("unstaged", "wt_abc")).toBe("unstaged")
expect(scopeToSourceId("session", "wt_abc", "ses_xyz")).toBe("session:ses_xyz")
expect(scopeToSourceId("session", "local", "ses_xyz")).toBe("session:ses_xyz")
expect(scopeToSourceId("branch", "local")).toBe("workspace")
})

it("falls back to the context id for a session scope without a session id", () => {
expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc")
})
})
Loading
Loading