Merge pull request #12860 from Kilo-Org/agent-manager-12806-isolation

fix(vscode): isolate project worktree row state
This commit is contained in:
Marius
2026-08-04 16:17:33 +02:00
committed by GitHub
8 changed files with 139 additions and 23 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager worktree rows isolated when projects contain identical raw worktree IDs.
@@ -1050,6 +1050,7 @@ export class AgentManagerProvider implements Disposable {
if (!req) return
if (directory) {
req.directory = directory
req.projectId ??= this.contexts.byDirectory(directory)?.id
}
void this.startToolRequest(req)
}
@@ -19,6 +19,7 @@ export interface ToolTask {
export interface ToolRequest {
requestID: string
projectId?: string
sessionID?: string
directory?: string
sandboxInheritanceToken?: string
@@ -234,7 +235,14 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
const state = { ok: 0 }
const source = { sandboxInheritanceToken: req.sandboxInheritanceToken }
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: 0, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "creating",
total,
completed: 0,
groupId,
})
for (let i = 0; i < req.tasks.length; i++) {
const task = req.tasks[i]!
try {
@@ -248,10 +256,24 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
deps.log("Agent Manager tool task failed", msg)
deps.post({ type: "error", message: `Agent Manager tool task failed: ${msg}` })
}
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: state.ok, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "creating",
total,
completed: state.ok,
groupId,
})
}
deps.post({ type: "agentManager.multiVersionProgress", status: "done", total, completed: state.ok, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "done",
total,
completed: state.ok,
groupId,
})
if (state.ok === 0) deps.error(`Failed to start any Agent Manager sessions for request ${req.requestID}.`)
deps.log(`Agent Manager tool request ${req.requestID} complete: ${state.ok}/${total}`)
}
@@ -299,6 +321,7 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
if (parsed.length !== limited.length) return undefined
return {
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
projectId: typeof value.projectId === "string" ? value.projectId : undefined,
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
directory: typeof value.directory === "string" ? value.directory : undefined,
sandboxInheritanceToken:
@@ -32,6 +32,10 @@ describe("multi-project progress state", () => {
expect(first.busy().has("same")).toBe(true)
expect(second.busy().has("same")).toBe(false)
second.setBusy(new Map([["same", { reason: "deleting" as const }]]))
clearMultiVersionBusy(second, "group")
expect(second.busy().get("same")?.reason).toBe("deleting")
})
it("marks a newly created grouped worktree as busy in its project store", () => {
@@ -42,4 +46,14 @@ describe("multi-project progress state", () => {
expect(store.busy().get("same")?.reason).toBe("setting-up")
})
it("does not replace deletion progress when marking a grouped worktree", () => {
const store = createProjectStore("a")
store.applyState(state("a"))
store.setBusy(new Map([["same", { reason: "deleting" as const }]]))
markMultiVersionBusy(store, "a-session")
expect(store.busy().get("same")?.reason).toBe("deleting")
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import { projectAdjacentHint } from "../../webview-ui/agent-manager/project-local-navigation"
import { worktreeNavId } from "../../webview-ui/agent-manager/navigate"
import { projectAdjacentHint, projectWorktreeRow } from "../../webview-ui/agent-manager/project-local-navigation"
describe("projectAdjacentHint", () => {
it("does not leak a hint to another project with the same raw ID", () => {
@@ -22,4 +23,44 @@ describe("projectAdjacentHint", () => {
projectAdjacentHint("project-b", "project-b", "shared", "local", ["local", "other", "shared"], "prev", "next"),
).toBe("")
})
it("keeps shortcut and worktree keybinding values scoped for duplicate raw IDs", () => {
const bindings = {
previousSession: "Ctrl+Alt+Up",
nextSession: "Ctrl+Alt+Down",
closeWorktree: "Ctrl+Shift+W",
openWorktree: "Ctrl+Shift+O",
}
const shortcuts = new Map([
[worktreeNavId("project-a", "shared"), 2],
[worktreeNavId("project-b", "shared"), 4],
])
const first = projectWorktreeRow({
projectId: "project-a",
activeProjectId: "project-a",
worktreeId: "shared",
activeId: "local",
flatIds: ["local", "shared"],
bindings,
shortcuts,
})
const second = projectWorktreeRow({
projectId: "project-b",
activeProjectId: "project-a",
worktreeId: "shared",
activeId: "local",
flatIds: ["local", "shared"],
bindings,
shortcuts,
})
expect(first.navHint).toBe(bindings.nextSession)
expect(second.navHint).toBe("")
expect(first.shortcut).toBe(2)
expect(second.shortcut).toBe(4)
expect(first.closeKeybind).toBe(bindings.closeWorktree)
expect(first.openKeybind).toBe(bindings.openWorktree)
expect(second.closeKeybind).toBe(bindings.closeWorktree)
expect(second.openKeybind).toBe(bindings.openWorktree)
})
})
@@ -20,7 +20,6 @@ import type {
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import { projectAdjacentHint, projectSidebarOrder } from "./project-local-navigation"
import SectionHeader from "./SectionHeader"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem } from "./WorktreeItem"
@@ -32,6 +31,7 @@ import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
@@ -111,16 +111,16 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const post = (message: Record<string, unknown>) =>
vscode.postMessage({ ...message, projectId: props.project.id } as never)
const navHint = (id: string) =>
projectAdjacentHint(
props.project.id,
props.selectedProject,
id,
props.selection ?? props.currentSessionID?.(),
sidebarOrder(),
props.bindings.previousSession ?? "",
props.bindings.nextSession ?? "",
)
const row = (id: string) =>
projectWorktreeRow({
projectId: props.project.id,
activeProjectId: props.selectedProject,
worktreeId: id,
activeId: props.selection ?? props.currentSessionID?.(),
flatIds: sidebarOrder(),
bindings: props.bindings,
shortcuts: props.shortcutMap?.(),
})
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
@@ -229,6 +229,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => {
const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch)
const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined)
const values = () => row(worktree.id)
const sortable = createSortable(scope("worktree", worktree.id))
void sortable
return (
@@ -236,7 +237,6 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
shortcut={props.shortcutMap?.().get(`${props.project.id}:wt:${worktree.id}`)}
label={worktree.label || label()}
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
active={active() && props.selection === worktree.id}
@@ -245,7 +245,8 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
navHint={navHint(worktree.id)}
shortcut={values().shortcut}
navHint={values().navHint}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
@@ -253,8 +254,8 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
closeKeybind={values().closeKeybind}
openKeybind={values().openKeybind}
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
@@ -1,4 +1,4 @@
import { adjacentHint } from "./navigate"
import { adjacentHint, worktreeNavId } from "./navigate"
import { buildSidebarOrder } from "./section-helpers"
export function projectSidebarOrder(...args: Parameters<typeof buildSidebarOrder>): string[] {
@@ -17,3 +17,31 @@ export function projectAdjacentHint(
if (projectId !== activeProjectId) return ""
return adjacentHint(itemId, activeId, flatIds, prev, next)
}
interface Input {
projectId: string
activeProjectId?: string
worktreeId: string
activeId?: string
flatIds: string[]
bindings: Record<string, string>
shortcuts?: Map<string, number>
}
/** Resolve the project-scoped values rendered by one worktree row. */
export function projectWorktreeRow(input: Input) {
return {
shortcut: input.shortcuts?.get(worktreeNavId(input.projectId, input.worktreeId)),
navHint: projectAdjacentHint(
input.projectId,
input.activeProjectId,
input.worktreeId,
input.activeId,
input.flatIds,
input.bindings.previousSession ?? "",
input.bindings.nextSession ?? "",
),
closeKeybind: input.bindings.closeWorktree ?? "",
openKeybind: input.bindings.openWorktree ?? "",
}
}
@@ -1,6 +1,6 @@
import type { ProjectStore } from "./store"
/** Clear the loading indicators for every worktree in one multi-version group. */
/** Clear setup indicators for every worktree in one multi-version group. */
export function clearMultiVersionBusy(store: ProjectStore, groupId: string): void {
const ids = new Set(
store
@@ -9,7 +9,7 @@ export function clearMultiVersionBusy(store: ProjectStore, groupId: string): voi
.map((wt) => wt.id),
)
if (ids.size === 0) return
store.setBusy((prev) => new Map([...prev].filter(([id]) => !ids.has(id))))
store.setBusy((prev) => new Map([...prev].filter(([id, busy]) => !ids.has(id) || busy.reason === "deleting")))
}
/** Keep a newly created grouped worktree showing progress until its prompt starts. */
@@ -19,5 +19,8 @@ export function markMultiVersionBusy(store: ProjectStore, sessionId: string): vo
if (!id) return
const worktree = store.worktrees().find((item) => item.id === id)
if (!worktree?.groupId) return
store.setBusy((prev) => new Map([...prev, [id, { reason: "setting-up" as const }]]))
store.setBusy((prev) => {
if (prev.get(id)?.reason === "deleting") return prev
return new Map([...prev, [id, { reason: "setting-up" as const }]])
})
}