feat(vscode): restore multi-project section and drag-and-drop support

Restore Agent Manager sections and worktree drag-and-drop when multiple
projects are shown, with ordering and section moves scoped to the owning
project.

- Extract shared worktree ordering/grouping logic used by both single and
  multi-project modes
- Add per-project drag-and-drop providers with scoped identifiers
- Preserve live run statuses when state payloads omit them
- Match single-project label resolution using ordered session titles
- Make section auto-rename tracking request-scoped to prevent stale
  renames
- Expand multi-project Storybook story with persisted ordering, sections,
  and grouped worktrees
- Add project-store isolation and ordering tests
This commit is contained in:
marius-kilocode
2026-08-03 12:53:40 +02:00
parent c554409080
commit 9819c1c315
14 changed files with 387 additions and 143 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

@@ -733,7 +733,11 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.setWorktreeOrder") {
this.state?.setWorktreeOrder(m.order)
const state = this.getStateManager()
if (state) {
state.setWorktreeOrder(m.order)
this.pushState()
}
return null
}
if (m.type === "agentManager.setSessionsCollapsed") {
@@ -619,6 +619,7 @@ interface SetTabOrderIn {
interface SetWorktreeOrderIn {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test"
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
const state = (projectId: string, order: string[]) => ({
type: "agentManager.state" as const,
projectId,
worktrees: order.map((id) => ({
id,
branch: `${projectId}-${id}`,
path: `/repo/${projectId}/${id}`,
parentBranch: "main",
createdAt: "2026-01-01",
})),
sessions: [],
sections: [],
worktreeOrder: order,
})
describe("project stores", () => {
it("keeps worktree order isolated between projects", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a", ["same", "other"]))
second.applyState(state("b", ["same", "other"]))
first.setWorktreeOrder(["other", "same"])
expect(first.worktreeOrder()).toEqual(["other", "same"])
expect(second.worktreeOrder()).toEqual(["same", "other"])
})
it("preserves live run statuses when state omits them", () => {
const store = createProjectStore("a")
store.applyState(state("a", ["same", "other"]))
store.setRunStatuses({
same: { worktreeId: "same", state: "running" },
})
store.applyState(state("a", ["other", "same"]))
expect(store.runStatuses()).toEqual({
same: { worktreeId: "same", state: "running" },
})
})
})
@@ -7,6 +7,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
} from "../../webview-ui/agent-manager/section-helpers"
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
@@ -112,6 +113,23 @@ describe("isGrouped", () => {
})
})
describe("sortWorktrees", () => {
it("applies persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["c", "a", "b"]).map((item) => item.id)).toEqual(["c", "a", "b"])
})
it("keeps multi-version siblings adjacent at the first group position", () => {
const all = [wt("a", { groupId: "g" }), wt("b"), wt("c", { groupId: "g" })]
expect(sortWorktrees(all, ["b", "c", "a"]).map((item) => item.id)).toEqual(["b", "c", "a"])
})
it("appends worktrees missing from persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["b"]).map((item) => item.id)).toEqual(["b", "a", "c"])
})
})
describe("isGroupStart", () => {
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
@@ -158,6 +158,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
type TopLevelItem,
} from "./section-helpers"
import {} from "./section-dnd"
@@ -775,39 +776,7 @@ const AgentManagerContent: Component = () => {
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
const sortedWorktrees = createMemo(() => {
const ordered = applyTabOrder(worktrees(), sidebarWorktreeOrder())
if (ordered.length === 0) return []
// Collect grouped worktrees by groupId
const grouped = new Map<string, WorktreeState[]>()
for (const wt of ordered) {
if (!wt.groupId) continue
const list = grouped.get(wt.groupId) ?? []
list.push(wt)
grouped.set(wt.groupId, list)
}
// Build output: interleave groups at the position of their earliest member
const result: WorktreeState[] = []
const placed = new Set<string>()
for (const wt of ordered) {
if (placed.has(wt.id)) continue
if (wt.groupId) {
if (placed.has(wt.groupId)) continue
placed.add(wt.groupId)
const group = grouped.get(wt.groupId) ?? []
for (const g of group) {
result.push(g)
placed.add(g.id)
}
} else {
result.push(wt)
placed.add(wt.id)
}
}
return result
})
const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder()))
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
@@ -2249,6 +2218,7 @@ const AgentManagerContent: Component = () => {
<ProjectList
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
@@ -19,10 +19,12 @@ import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL } from "./navigate"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { ProjectBranchDialog } from "./ProjectBranchDialog"
import type { ProjectStore } from "./project/store"
interface Props {
projects: AgentProjectSnapshot[]
states: Record<string, AgentManagerStateMessage>
store?: (projectId: string) => ProjectStore
stats: Record<string, Record<string, WorktreeGitStats>>
local: Record<string, LocalGitStats>
prs: Record<string, Record<string, PRStatus | null>>
@@ -205,6 +207,7 @@ export const ProjectList: Component<Props> = (props) => {
<ProjectSidebarBody
project={project}
state={props.states[project.id]}
store={props.store?.(project.id)}
stats={props.stats[project.id]}
local={props.local[project.id]}
prs={props.prs[project.id]}
@@ -1,26 +1,40 @@
import { For, Show, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { DragDropProvider, DragDropSensors } from "@thisbeyond/solid-dnd"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
createSortable,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import type {
AgentManagerStateMessage,
AgentProjectSnapshot,
LocalGitStats,
PRStatus,
ProjectSessionInfo,
WorktreeState,
WorktreeGitStats,
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import { formatRelativeDate } from "../src/utils/date"
import SectionHeader from "./SectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { ProjectActions } from "./ProjectActions"
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
interface Props {
project: AgentProjectSnapshot
state?: AgentManagerStateMessage
store?: ProjectStore
busy?: (id: string) => boolean
stats?: Record<string, WorktreeGitStats>
local?: LocalGitStats
@@ -40,8 +54,21 @@ interface Props {
/** Permanent real sidebar body for one expanded project. */
export const ProjectSidebarBody: Component<Props> = (props) => {
const vscode = useVSCode()
const store = props.store ?? createProjectStore(props.project.id)
if (!props.store) {
createEffect(() => {
const state = props.state
if (state) store.applyState(state)
})
}
const [pending, setPending] = createSignal<string>()
const [renaming, setRenaming] = createSignal<string>()
const [renamingSection, setRenamingSection] = createSignal<string>()
const [pendingSection, setPendingSection] = createSignal<
{ ids: Set<string>; state?: AgentManagerStateMessage } | undefined
>()
const [dragging, setDragging] = createSignal<string>()
const [dragOrigin, setDragOrigin] = createSignal<string[]>()
const [name, setName] = createSignal("")
let pendingTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(pendingTimer))
@@ -61,14 +88,105 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const sessions = (worktreeId: string | null) =>
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
const active = () => props.selectedProject === props.project.id
const runs = createMemo(() => Object.fromEntries((state()?.runStatuses ?? []).map((run) => [run.worktreeId, run])))
const sections = () => state()?.sections ?? []
const runs = () => store.runStatuses()
const sections = () => store.sections()
const worktrees = () => store.worktrees()
const order = () => store.worktreeOrder()
const localSessions = () => sessions(null)
const ungrouped = () => state()?.worktrees.filter((wt) => !wt.sectionId) ?? []
const members = (sectionId: string) => state()?.worktrees.filter((wt) => wt.sectionId === sectionId) ?? []
const sorted = createMemo(() => sortWorktrees(worktrees(), order()))
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 post = (message: Record<string, unknown>) =>
vscode.postMessage({ ...message, projectId: props.project.id } as never)
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
if (typeof value !== "string") return
const prefix = `${props.project.id}:${kind}:`
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined
}
const createSection = (worktreeIds?: string[]) => {
setPendingSection({ ids: new Set(sections().map((section) => section.id)), state: state() })
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
color: randomColor(),
worktreeIds,
})
}
createEffect(() => {
const previous = pendingSection()
if (!previous) return
const current = state()
if (current === previous.state) return
const created = (current?.sections ?? []).find((section) => !previous.ids.has(section.id))
setPendingSection(undefined)
if (!created) return
setRenamingSection(created.id)
})
const worktreeIds = createMemo(() => new Set(worktrees().map((wt) => wt.id)))
const sectionIds = createMemo(() => new Set(sections().map((section) => scope("section", section.id))))
const home = createMemo(
() =>
new Map(
worktrees().map(
(wt) => [scope("worktree", wt.id), wt.sectionId ? scope("section", wt.sectionId) : undefined] as const,
),
),
)
const detector = sectionAwareDetector(sectionIds, home)
const dragIds = createMemo(() => sorted().map((wt) => scope("worktree", wt.id)))
const onDragStart = (event: DragEvent) => {
const id = parse("worktree", event.draggable?.id)
if (!id || !worktreeIds().has(id)) return
setDragging(id)
setDragOrigin(order())
document.body.classList.add("am-wt-dragging-active")
}
const onDragOver = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const to = parse("worktree", event.droppable?.id)
if (!from || !to || !worktreeIds().has(from) || !worktreeIds().has(to)) return
store.setWorktreeOrder((previous) => {
const current = applyTabOrder(
sorted().map((wt) => ({ id: wt.id })),
previous,
).map((item) => item.id)
return reorderTabs(current, from, to) ?? previous
})
}
const onDragEnd = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const section = parse("section", event.droppable?.id)
const to = parse("worktree", event.droppable?.id)
setDragging(undefined)
const origin = dragOrigin()
setDragOrigin(undefined)
document.body.classList.remove("am-wt-dragging-active")
if (!from || !worktreeIds().has(from)) {
if (origin) store.setWorktreeOrder(origin)
return
}
if (section && sections().some((item) => item.id === section)) {
post({ type: "agentManager.moveToSection", worktreeIds: [from], sectionId: section })
return
}
if (!to || !worktreeIds().has(to)) {
if (origin) store.setWorktreeOrder(origin)
return
}
post({ type: "agentManager.setWorktreeOrder", order: order() })
}
onCleanup(() => document.body.classList.remove("am-wt-dragging-active"))
// Escape unmounts the focused rename input, which fires a synchronous blur
// that would re-commit the cancelled value; this flag swallows that blur.
let cancelled = false
@@ -86,62 +204,64 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
setRenaming(undefined)
}
const renderWorktree = (worktree: NonNullable<Props["state"]>["worktrees"][number]) => (
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || worktree.branch}
subtitle={worktree.label && worktree.label !== worktree.branch ? worktree.branch : undefined}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={false}
groupStart={false}
groupEnd={false}
groupSize={0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.worktree.newSection"),
worktreeIds: [worktree.id],
})
}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
)
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 sortable = createSortable(scope("worktree", worktree.id))
void sortable
return (
<div use:sortable class={`am-wt-sortable ${sortable.isActiveDraggable ? "am-wt-dragging" : ""}`}>
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || label()}
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
groupEnd={isGroupEnd(worktree, idx(), list)}
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() => createSection([worktree.id])}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
</div>
)
}
return (
<div class="am-project-body" data-project-body={props.project.id}>
@@ -202,52 +322,73 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
})
}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
/>
</div>
<div class="am-worktree-list">
{/*
SectionHeader registers a drop target via solid-dnd, which throws
without a DragDropProvider ancestor and kills the whole render.
Multi-project has no drag-and-drop yet, so this provider is a
no-op context until DnD lands here.
*/}
<DragDropProvider onDragStart={() => {}} onDragEnd={() => {}}>
<DragDropProvider
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
collisionDetector={detector}
>
<DragDropSensors />
<For each={sections()}>
{(section, index) => (
<SectionHeader
section={section}
count={members(section.id).length}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
<ConstrainDragXAxis />
<SortableProvider ids={dragIds()}>
<For each={top()}>
{(item, index) => {
if (item.kind === "worktree") {
const list = ungrouped()
return renderWorktree(item.wt, () => list.indexOf(item.wt), list)
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === sections().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={members(section.id)}>{renderWorktree}</For>
</div>
</Show>
</SectionHeader>
)}
</For>
<For each={ungrouped()}>{renderWorktree}</For>
const section = item.section
const list = members(section.id)
return (
<SectionHeader
section={section}
dropId={scope("section", section.id)}
count={list.length}
autoRename={renamingSection() === section.id}
onRenameEnd={() => {
if (renamingSection() === section.id) setRenamingSection(undefined)
}}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === top().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={list}>{(wt, wtIndex) => renderWorktree(wt, wtIndex, list)}</For>
</div>
</Show>
</SectionHeader>
)
}}
</For>
</SortableProvider>
<DragOverlay>
{(() => {
const wt = sorted().find((item) => item.id === dragging())
if (!wt) return null
return (
<div class="am-wt-overlay">
<Icon name="branch" size="small" />
<span>{wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch)}</span>
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
</div>
</div>
@@ -12,6 +12,8 @@ interface Props {
children?: JSX.Element
/** When true, auto-enter rename mode (e.g. after creation). */
autoRename?: boolean
/** Scoped drop id used when multiple project DnD providers are mounted. */
dropId?: string
onToggle: () => void
onRename: (name: string) => void
onDelete: () => void
@@ -59,7 +61,7 @@ const SectionHeader: Component<Props> = (props) => {
props.onToggle()
}
const droppable = createDroppable(props.section.id)
const droppable = createDroppable(props.dropId ?? props.section.id)
return (
<div
@@ -78,9 +78,11 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined)
setRunScriptConfigured(state.runScriptConfigured === true)
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
const runs: Record<string, RunStatus> = {}
for (const item of state.runStatuses ?? []) runs[item.worktreeId] = item
setRunStatuses(runs)
if (state.runStatuses) {
const runs: Record<string, RunStatus> = {}
for (const item of state.runStatuses) runs[item.worktreeId] = item
setRunStatuses(runs)
}
// Reconcile busy flags with the worktree list (deleted worktrees drop out).
const ids = new Set(state.worktrees.map((wt) => wt.id))
setBusy((prev) => {
@@ -3,11 +3,44 @@
* Pure functions — no solid-dnd dependency so they remain testable.
*/
import type { WorktreeState, SectionState } from "../src/types/messages"
import { applyTabOrder } from "./tab-order"
export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState }
export type SidebarItem = { type: "local" | "wt" | "session"; id: string }
/** Apply persisted order while keeping multi-version worktrees adjacent. */
export function sortWorktrees(all: WorktreeState[], order: string[]): WorktreeState[] {
const ordered = applyTabOrder(all, order)
if (ordered.length === 0) return []
const groups = new Map<string, WorktreeState[]>()
for (const wt of ordered) {
if (!wt.groupId) continue
const group = groups.get(wt.groupId) ?? []
group.push(wt)
groups.set(wt.groupId, group)
}
const result: WorktreeState[] = []
const placed = new Set<string>()
for (const wt of ordered) {
if (placed.has(wt.id)) continue
if (!wt.groupId) {
result.push(wt)
placed.add(wt.id)
continue
}
if (placed.has(wt.groupId)) continue
placed.add(wt.groupId)
for (const item of groups.get(wt.groupId) ?? []) {
result.push(item)
placed.add(item.id)
}
}
return result
}
/** Build a canonical sidebar order containing section IDs and every worktree ID. */
export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], order: string[]): string[] {
const valid = new Set([...secs.map((sec) => sec.id), ...all.map((wt) => wt.id)])
@@ -1227,13 +1227,14 @@ const projectB: AgentProjectSnapshot = {
missing: false,
}
const wt = (id: string, branch: string, label?: string): WorktreeState => ({
const wt = (id: string, branch: string, label?: string, opts: Partial<WorktreeState> = {}): WorktreeState => ({
id,
branch,
path: `/repos/x/.kilo/worktrees/${id}`,
parentBranch: "main",
createdAt: "2026-07-20T10:00:00Z",
label,
...opts,
})
const projectState = (
@@ -1242,12 +1243,18 @@ const projectState = (
sessions: { id: string; worktreeId: string | null }[],
sections: NonNullable<AgentManagerStateMessage["sections"]> = [],
baseBranch = "main",
worktreeOrder?: string[],
): AgentManagerStateMessage => ({
type: "agentManager.state",
projectId,
worktrees,
sessions: sessions.map((s) => ({ id: s.id, worktreeId: s.worktreeId, createdAt: "2026-07-20T10:00:00Z" })),
sections,
worktreeOrder: worktreeOrder ?? [
...worktrees.filter((item) => !item.sectionId).map((item) => item.id),
...sections.map((item) => item.id),
...worktrees.filter((item) => item.sectionId).map((item) => item.id),
],
staleWorktreeIds: [],
isGitRepo: true,
defaultBaseBranch: baseBranch,
@@ -1296,18 +1303,30 @@ export const MultiProjectSidebar: Story = {
states={{
[projectA.id]: projectState(
projectA.id,
[wt("wt-a1", "feature/project-list", "Project list UI"), wt("wt-a2", "fix/session-routing")],
[
wt("wt-a1", "feature/project-list", "Project list UI", { sectionId: "sec-a1" }),
wt("wt-a2", "fix/session-routing"),
wt("wt-a3", "feat/project-list-v2", undefined, { groupId: "grp-a1" }),
wt("wt-a4", "feat/project-list-v3", undefined, { groupId: "grp-a1" }),
],
[
{ id: "ses-a1", worktreeId: null },
{ id: "ses-a2", worktreeId: "wt-a1" },
],
[{ id: "sec-a1", name: "Agent Manager", color: "Blue", order: 0, collapsed: false }],
"main",
["wt-a2", "sec-a1", "wt-a1", "wt-a3", "wt-a4"],
),
[projectB.id]: projectState(
projectB.id,
[wt("wt-b1", "feat/gateway-routing", "Gateway routing")],
[
wt("wt-b1", "feat/gateway-routing", "Gateway routing", { sectionId: "sec-b1" }),
wt("wt-b2", "fix/api"),
],
[{ id: "ses-b1", worktreeId: null }],
[{ id: "sec-b1", name: "In progress", color: null, order: 0, collapsed: false }],
"master",
["wt-b2", "sec-b1", "wt-b1"],
),
}}
stats={{
@@ -869,6 +869,7 @@ export interface SetTabOrderRequest {
// Persist sidebar worktree order
export interface SetWorktreeOrderRequest {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}