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/fix-multi-project-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/project-local-navigation-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Show previous and next navigation hints using each project's own Agent Manager sidebar order.
73 changes: 71 additions & 2 deletions packages/kilo-vscode/tests/unit/navigate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -411,7 +411,76 @@ 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("keeps multi-version worktrees adjacent", () => {
const order = buildProjectNavOrder([
project({
id: "A",
expanded: true,
worktrees: [{ id: "aw1", groupId: "g" }, { id: "aw2" }, { id: "aw3", groupId: "g" }],
worktreeOrder: ["aw1", "aw2", "aw3"],
sections: [],
unassigned: [],
}),
])

expect(order.map((e) => e.id)).toEqual([
localNavId("A"),
worktreeNavId("A", "aw1"),
worktreeNavId("A", "aw3"),
worktreeNavId("A", "aw2"),
])
})

it("matches raw ungrouped order when sections are present", () => {
const order = buildProjectNavOrder([
project({
id: "A",
expanded: true,
worktrees: [
{ id: "aw1", groupId: "g" },
{ id: "aw2" },
{ id: "aw3", groupId: "g" },
{ id: "aw4", sectionId: "s1" },
],
worktreeOrder: ["aw1", "aw2", "aw3", "s1", "aw4"],
sections: [{ id: "s1", collapsed: false }],
unassigned: [],
}),
])

expect(order.map((e) => e.id)).toEqual([
localNavId("A"),
worktreeNavId("A", "aw1"),
worktreeNavId("A", "aw2"),
worktreeNavId("A", "aw3"),
worktreeNavId("A", "aw4"),
])
})

it("excludes unassigned sessions when the sessions section is collapsed", () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/kilo-vscode/tests/unit/project-local-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test"
import { projectAdjacentHint } from "../../webview-ui/agent-manager/project-local-navigation"

describe("projectAdjacentHint", () => {
it("does not leak a hint to another project with the same raw ID", () => {
expect(projectAdjacentHint("project-a", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe(
"next",
)
expect(projectAdjacentHint("project-b", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe(
"",
)
})

it("uses the active project's local sidebar order", () => {
expect(projectAdjacentHint("project-a", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe(
"next",
)
expect(projectAdjacentHint("project-a", "project-a", "local", "shared", ["local", "shared"], "prev", "next")).toBe(
"prev",
)
expect(
projectAdjacentHint("project-b", "project-b", "shared", "local", ["local", "other", "shared"], "prev", "next"),
).toBe("")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -2347,6 +2352,7 @@ const AgentManagerContent: Component = () => {
t={t}
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
onShortcuts={handleShowKeyboardShortcuts}
shortcutMap={projectShortcutMap}
/>
</Show>
<Show when={!multiProject()}>
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -217,13 +218,15 @@ export const ProjectList: Component<Props> = (props) => {
sessions={props.sessions[project.id]}
selectedProject={props.selectedProject}
selection={props.selection}
currentSessionID={props.currentSessionID}
bindings={props.bindings}
t={props.t}
onSelectLocal={(projectId) => select({ projectId, kind: "local" })}
onSelectWorktree={(projectId, worktreeId) => select({ projectId, kind: "worktree", worktreeId })}
onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })}
onNewWorktree={newWorktree}
onDefaultBranch={defaultBranch}
shortcutMap={props.shortcutMap}
/>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
Expand All @@ -31,6 +32,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
Expand All @@ -42,13 +45,15 @@ interface Props {
sessions?: ProjectSessionInfo[]
selectedProject?: string
selection?: string
currentSessionID?: () => string | undefined
bindings: Record<string, string>
t: LanguageContextValue["t"]
onSelectLocal: (projectId: string) => void
onSelectWorktree: (projectId: string, worktreeId: string) => void
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. */
Expand Down Expand Up @@ -97,9 +102,23 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId)
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order()))
const sidebarOrder = createMemo(() =>
projectSidebarOrder(top(), sorted(), sections(), members, state()?.sessionsCollapsed ? [] : localSessions()),
)
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 scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
if (typeof value !== "string") return
Expand Down Expand Up @@ -214,6 +233,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}
Expand All @@ -222,6 +242,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
navHint={navHint(worktree.id)}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
Expand Down Expand Up @@ -284,6 +305,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}>
Expand Down
41 changes: 27 additions & 14 deletions packages/kilo-vscode/webview-ui/agent-manager/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* Returns the action to take: select a session by ID, go to local, or do nothing.
*/

import { sortWorktrees } from "./section-helpers"

/** Sentinel value for the local repo selection. */
export const LOCAL = "local" as const

Expand Down Expand Up @@ -137,8 +139,8 @@ export function focusChatSearch(reset: { history(v: boolean): void; review(v: bo
* Multi-project navigation.
*
* In multi-project mode the sidebar shows an accordion of projects; each
* expanded project renders its own Local item, worktrees (sections first,
* then ungrouped), and an unassigned-sessions list. Keyboard previous/next
* expanded project renders its own Local item, ungrouped worktrees, section
* members, and an unassigned-sessions list. Keyboard previous/next
* and numeric shortcuts must traverse every expanded project in visual
* order, not just the active one.
*
Expand All @@ -160,7 +162,9 @@ export interface NavEntry {
export interface ProjectNavInput {
id: string
expanded: boolean
worktrees: { id: string; sectionId?: string }[]
worktrees: { id: string; sectionId?: string; groupId?: string }[]
/** Persisted top-level order containing worktree and section IDs. */
worktreeOrder?: string[]
sections: { id: string; collapsed: boolean }[]
sessionsCollapsed: boolean
/** Visible unassigned (root, no worktree) sessions in render order. */
Expand All @@ -174,30 +178,39 @@ 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[] = []
for (const p of projects) {
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 = sortWorktrees(p.worktrees, p.worktreeOrder ?? [])
Comment thread
marius-kilocode marked this conversation as resolved.
const rank = new Map((p.worktreeOrder ?? []).map((id, index) => [id, index] as const))
const ungrouped = worktrees.filter((w) => !w.sectionId)
if (p.sections.length > 0) {
ungrouped.sort(
(a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER),
)
}
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 ungrouped) {
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 } })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { adjacentHint } from "./navigate"
import { buildSidebarOrder } from "./section-helpers"

export function projectSidebarOrder(...args: Parameters<typeof buildSidebarOrder>): string[] {
return buildSidebarOrder(...args).map((item) => item.id)
}

export function projectAdjacentHint(
projectId: string,
activeProjectId: string | undefined,
itemId: string,
activeId: string | undefined,
flatIds: string[],
prev: string,
next: string,
): string {
if (projectId !== activeProjectId) return ""
return adjacentHint(itemId, activeId, flatIds, prev, next)
}
Loading
Loading