diff --git a/.changeset/project-row-isolation.md b/.changeset/project-row-isolation.md new file mode 100644 index 00000000000..fe1129c2cc6 --- /dev/null +++ b/.changeset/project-row-isolation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager worktree rows isolated when projects contain identical raw worktree IDs. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 9c67a03b3ad..d218097b113 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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) } diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 927a6495370..91af753f991 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -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 { 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") + }) }) diff --git a/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts b/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts index b20bbc5f30a..7f5247b181d 100644 --- a/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts +++ b/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts @@ -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) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 3e150730373..e3e2679321f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -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) => { const post = (message: Record) => 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) => { 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) => { 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) => { 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()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts b/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts index ede97a8e129..8e963f62f81 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts @@ -1,4 +1,4 @@ -import { adjacentHint } from "./navigate" +import { adjacentHint, worktreeNavId } from "./navigate" import { buildSidebarOrder } from "./section-helpers" export function projectSidebarOrder(...args: Parameters): 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 + shortcuts?: Map +} + +/** 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 ?? "", + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts index 807c7b2be99..0a626885c7b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts @@ -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 }]]) + }) }