fix(vscode): fix multi-project navigation shortcuts

This commit is contained in:
marius-kilocode
2026-08-04 10:21:31 +02:00
parent fab0e96d08
commit b894206b63
8 changed files with 102 additions and 34 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix multi-project Agent Manager keyboard navigation and Cmd/Ctrl shortcut selection when worktrees are grouped in sections.
@@ -401,7 +401,7 @@ describe("buildProjectNavOrder", () => {
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")])
})
it("renders section members before ungrouped worktrees (matching the project body)", () => {
it("renders ungrouped worktrees before section members (matching the project body)", () => {
const order = buildProjectNavOrder([
project({
id: "A",
@@ -411,7 +411,30 @@ describe("buildProjectNavOrder", () => {
unassigned: [],
}),
])
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), worktreeNavId("A", "aw2")])
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2"), worktreeNavId("A", "aw1")])
})
it("follows persisted top-level section order and worktree order", () => {
const order = buildProjectNavOrder([
project({
id: "A",
expanded: true,
worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }, { id: "aw3", sectionId: "s2" }],
worktreeOrder: ["aw2", "s2", "s1", "aw3", "aw1"],
sections: [
{ id: "s1", collapsed: false },
{ id: "s2", collapsed: false },
],
unassigned: [],
}),
])
expect(order.map((e) => e.id)).toEqual([
localNavId("A"),
worktreeNavId("A", "aw2"),
worktreeNavId("A", "aw3"),
worktreeNavId("A", "aw1"),
])
})
it("excludes unassigned sessions when the sessions section is collapsed", () => {
@@ -103,7 +103,7 @@ import {
focusChatSearch,
LOCAL,
} from "./navigate"
import { createProjectNav } from "./project-nav"
import { buildProjectNavEntries, createProjectNav } from "./project-nav"
import {
addPendingTab as addLocalPendingTab,
nextTabAfterClose,
@@ -882,6 +882,11 @@ const AgentManagerContent: Component = () => {
)
/** Map from sidebar item id → 1-based shortcut number (⌘1 for LOCAL, ⌘2 for first worktree, etc.) */
const shortcutMap = createMemo(() => buildShortcutMap(sidebarOrder()))
const projectShortcutMap = createMemo(() =>
buildShortcutMap(
buildProjectNavEntries(projectList(), projectStates(), projectLive.sessions()).map((entry) => ({ id: entry.id })),
)
)
const moveToSection = (ids: string[], sec: string | null) =>
vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec })
@@ -2347,6 +2352,7 @@ const AgentManagerContent: Component = () => {
t={t}
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
onShortcuts={handleShowKeyboardShortcuts}
shortcutMap={projectShortcutMap}
/>
</Show>
<Show when={!multiProject()}>
@@ -39,6 +39,7 @@ interface Props {
t: LanguageContextValue["t"]
onSearchRef: (ref: SidebarSearchMenuRef) => void
onShortcuts: () => void
shortcutMap?: () => Map<string, number>
}
export const ProjectList: Component<Props> = (props) => {
@@ -224,6 +225,7 @@ export const ProjectList: Component<Props> = (props) => {
onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })}
onNewWorktree={newWorktree}
onDefaultBranch={defaultBranch}
shortcutMap={props.shortcutMap}
/>
)}
/>
@@ -31,6 +31,8 @@ import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
interface Props {
project: AgentProjectSnapshot
state?: AgentManagerStateMessage
@@ -49,6 +51,7 @@ interface Props {
onSelectSession: (projectId: string, sessionId: string) => void
onNewWorktree: (projectId: string) => void
onDefaultBranch: (projectId: string, selected?: string, detected?: string) => void
shortcutMap?: () => Map<string, number>
}
/** Permanent real sidebar body for one expanded project. */
@@ -214,6 +217,7 @@ 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}
@@ -284,6 +288,14 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
{(shortcut) => (
<span class="am-shortcut-badge">
{isMac ? "⌘" : "Ctrl+"}
{shortcut()}
</span>
)}
</Show>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.local?.branch}>
@@ -161,6 +161,8 @@ export interface ProjectNavInput {
id: string
expanded: boolean
worktrees: { id: string; sectionId?: string }[]
/** Worktree IDs in the exact visual order produced by the project body. */
worktreeOrder?: string[]
sections: { id: string; collapsed: boolean }[]
sessionsCollapsed: boolean
/** Visible unassigned (root, no worktree) sessions in render order. */
@@ -174,10 +176,10 @@ export const sessionNavId = (projectId: string, sessionId: string) => `${project
/**
* Build one global visual order across expanded projects.
*
* For each expanded project (in input order): Local, then worktrees in the
* order the multi-project body renders them (each non-collapsed section's
* members, then ungrouped), then visible unassigned sessions (when the
* sessions section is not collapsed). Collapsed projects contribute nothing.
* For each expanded project (in input order): Local, then ungrouped worktrees,
* then members of each non-collapsed section in top-level order, then visible
* unassigned sessions. This matches `buildTopLevelItems` and the project body.
* Collapsed projects contribute nothing.
*/
export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] {
const order: NavEntry[] = []
@@ -185,19 +187,29 @@ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] {
if (!p.expanded) continue
const pid = p.id
order.push({ id: localNavId(pid), target: { projectId: pid, kind: "local" } })
for (const sec of p.sections) {
const worktrees = p.worktreeOrder
? [...p.worktreeOrder, ...p.worktrees.map((w) => w.id)]
.filter((id, index, all) => all.indexOf(id) === index)
.map((id) => p.worktrees.find((w) => w.id === id))
.filter((w): w is NonNullable<typeof w> => !!w)
: p.worktrees
const rank = new Map((p.worktreeOrder ?? []).map((id, index) => [id, index] as const))
const secs = [...p.sections].sort(
(a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER),
)
for (const w of worktrees) {
if (!w.sectionId) {
order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } })
}
}
for (const sec of secs) {
if (sec.collapsed) continue
for (const w of p.worktrees) {
for (const w of worktrees) {
if (w.sectionId === sec.id) {
order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } })
}
}
}
for (const w of p.worktrees) {
if (!w.sectionId) {
order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } })
}
}
if (!p.sessionsCollapsed) {
for (const s of p.unassigned) {
order.push({ id: sessionNavId(pid, s.id), target: { projectId: pid, kind: "session", sessionId: s.id } })
@@ -19,7 +19,7 @@ import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo
*
* Handles previous/next (⌘⌥↑/↓) and numeric-shortcut (⌘1-9) navigation across
* the sidebar. In multi-project mode it builds one global visual order across
* every expanded project — Local, worktrees (sections first, then ungrouped),
* every expanded project — Local, ungrouped worktrees, then section members,
* and visible unassigned sessions — using stable project-qualified composite
* ids, and activates each target with a single atomic
* `agentManager.activateSelection` dispatch. In single-project mode it keeps
@@ -48,6 +48,31 @@ export interface ProjectNav {
jump: (index: number) => void
}
/** Build the same global order used by keyboard navigation and shortcut badges. */
export function buildProjectNavEntries(
projects: AgentProjectSnapshot[],
states: Record<string, AgentManagerStateMessage>,
sessions: Record<string, ProjectSessionInfo[]>,
): NavEntry[] {
return buildProjectNavOrder(
projects.map((p) => {
const st = states[p.id]
if (!st) {
return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] }
}
return {
id: p.id,
expanded: p.expanded,
worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId })),
worktreeOrder: st.worktreeOrder,
sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })),
sessionsCollapsed: st.sessionsCollapsed === true,
unassigned: (sessions[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })),
}
}),
)
}
/** DOM selector for the sidebar element backing a nav target. */
export const navSelector = (target: NavTarget): string => {
if (target.kind === "local") return `[data-sidebar-id="${target.projectId}:local"]`
@@ -74,24 +99,7 @@ export function createProjectNav(
if (!deps.multiProject()) return []
const states = deps.states()
const live = deps.sessions()
return buildProjectNavOrder(
deps.projects().map((p) => {
const st = states[p.id]
// Until a project's state payload arrives its body shows a spinner
// (no Local/worktrees/sessions) — exclude it from the nav order.
if (!st) {
return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] }
}
return {
id: p.id,
expanded: p.expanded,
worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId })),
sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })),
sessionsCollapsed: st.sessionsCollapsed === true,
unassigned: (live[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })),
}
}),
)
return buildProjectNavEntries(deps.projects(), states, live)
})
const currentId = createMemo((): string | undefined => {
@@ -145,7 +145,7 @@ export function buildSidebarOrder(
}
/** Build a map from sidebar item id → 1-based shortcut number (1 for LOCAL, 2+ for worktrees). */
export function buildShortcutMap(order: SidebarItem[]): Map<string, number> {
export function buildShortcutMap(order: { id: string }[]): Map<string, number> {
const map = new Map<string, number>()
for (let i = 0; i < order.length && i < 9; i++) {
map.set(order[i]!.id, i + 1)