fix(agent-manager): keep ungrouped worktrees above sections (#10056)

This commit is contained in:
Marius
2026-05-08 12:07:06 +02:00
committed by GitHub
parent f9fa1a7efd
commit 562c828559
4 changed files with 82 additions and 45 deletions
@@ -44,7 +44,7 @@ export interface Section {
name: string
/** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */
color: string | null
/** Position among top-level sidebar children (sections and ungrouped worktrees). */
/** Position among sections. Ungrouped worktrees are always rendered above sections. */
order: number
collapsed: boolean
}
@@ -334,6 +334,14 @@ export class WorktreeStateManager {
void this.save()
}
private ordered(order: string[]): string[] {
const idx = new Map(order.map((id, i) => [id, i] as const))
return [...this.worktrees.values()]
.filter((wt) => !wt.sectionId)
.sort((a, b) => (idx.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (idx.get(b.id) ?? Number.MAX_SAFE_INTEGER))
.map((wt) => wt.id)
}
private setNormalizedWorktreeOrder(order: string[]): boolean {
const valid = new Set<string>()
for (const sec of this.sections.values()) valid.add(sec.id)
@@ -351,17 +359,22 @@ export class WorktreeStateManager {
for (const sec of [...this.sections.values()].sort((a, b) => a.order - b.order)) add(sec.id)
for (const wt of this.worktrees.values()) add(wt.id)
const normalized = [
...this.ordered(result),
...result.filter((id) => this.sections.has(id)),
...result.filter((id) => this.worktrees.get(id)?.sectionId),
]
const changed =
result.length !== this.worktreeOrder.length || result.some((id, idx) => id !== this.worktreeOrder[idx])
this.worktreeOrder = result
normalized.length !== this.worktreeOrder.length || normalized.some((id, idx) => id !== this.worktreeOrder[idx])
this.worktreeOrder = normalized
return this.syncSectionOrder() || changed
}
private syncSectionOrder(): boolean {
const top = this.worktreeOrder.filter((id) => {
if (this.sections.has(id)) return true
const wt = this.worktrees.get(id)
return !!wt && !wt.sectionId
return false
})
const index = new Map(top.map((id, idx) => [id, idx] as const))
const changes = [...this.sections.values()].map((sec) => {
@@ -33,14 +33,14 @@ describe("buildTopLevelItems", () => {
expect(result.every((r) => r.kind === "worktree")).toBe(true)
})
it("interleaves sections and worktrees per order", () => {
it("places ungrouped worktrees before sections", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1")
const s2 = sec("s2", 1)
const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"])
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ kind: "section", section: s1 })
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
expect(result[0]).toEqual({ kind: "worktree", wt: w1 })
expect(result[1]).toEqual({ kind: "section", section: s1 })
expect(result[2]).toEqual({ kind: "section", section: s2 })
})
@@ -52,11 +52,10 @@ describe("buildTopLevelItems", () => {
// Only s1 is in the order array
const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"])
expect(result).toHaveLength(4)
expect(result[0]).toEqual({ kind: "section", section: s1 })
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
// unordered items appended
expect(result[2]).toEqual({ kind: "section", section: s2 })
expect(result[3]).toEqual({ kind: "worktree", wt: w2 })
expect(result[0]).toEqual({ kind: "worktree", wt: w1 })
expect(result[1]).toEqual({ kind: "worktree", wt: w2 })
expect(result[2]).toEqual({ kind: "section", section: s1 })
expect(result[3]).toEqual({ kind: "section", section: s2 })
})
it("skips duplicate ids in order array", () => {
@@ -72,8 +71,8 @@ describe("buildTopLevelItems", () => {
const w2 = wt("w2")
const result = buildTopLevelItems([s1], [w2], [w1, w2], ["w1", "s1", "w2"])
expect(result).toEqual([
{ kind: "section", section: s1 },
{ kind: "worktree", wt: w2 },
{ kind: "section", section: s1 },
])
})
})
@@ -86,6 +85,12 @@ describe("completeSidebarOrder", () => {
expect(completeSidebarOrder([s1], [w1, w2], ["w2", "s1"])).toEqual(["w2", "s1", "w1"])
})
it("normalizes ungrouped worktrees above sections", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1")
expect(completeSidebarOrder([s1], [w1], ["s1", "w1"])).toEqual(["w1", "s1"])
})
it("drops stale ids and skips duplicates", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1")
@@ -160,7 +165,7 @@ describe("buildSidebarOrder", () => {
])
})
it("includes section worktrees in visual order", () => {
it("keeps ungrouped worktrees above section worktrees", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1", { sectionId: "s1" })
const w2 = wt("w2", { sectionId: "s1" })
@@ -171,9 +176,9 @@ describe("buildSidebarOrder", () => {
const result = buildSidebarOrder(items, sorted, [s1], members, [])
expect(result).toEqual([
{ type: "local", id: "local" },
{ type: "wt", id: "w3" },
{ type: "wt", id: "w1" },
{ type: "wt", id: "w2" },
{ type: "wt", id: "w3" },
])
})
@@ -191,7 +196,7 @@ describe("buildSidebarOrder", () => {
])
})
it("respects section order between sections and ungrouped worktrees", () => {
it("respects section order after ungrouped worktrees", () => {
const s1 = sec("s1", 0)
const s2 = sec("s2", 1)
const w1 = wt("w1", { sectionId: "s1" })
@@ -205,7 +210,7 @@ describe("buildSidebarOrder", () => {
return []
}
const result = buildSidebarOrder(items, sorted, [s1, s2], members, [])
expect(result.map((r) => r.id)).toEqual(["local", "w1", "w2", "w3"])
expect(result.map((r) => r.id)).toEqual(["local", "w2", "w1", "w3"])
})
it("appends unassigned sessions after worktrees", () => {
@@ -201,6 +201,14 @@ describe("WorktreeStateManager sections", () => {
expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id])
})
it("keeps ungrouped worktrees above moved sections", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const a = mgr.addSection("A", null)
mgr.setWorktreeOrder([a.id, wt.id])
expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id])
})
it("is a no-op at boundaries", () => {
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
@@ -320,5 +328,20 @@ describe("WorktreeStateManager sections", () => {
await loaded.load()
expect(loaded.getWorktreeOrder()).toContain(wt.id)
})
it("normalizes ungrouped worktrees above sections on load", async () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const sec = mgr.addSection("S", null)
await mgr.flush()
await mgr.save()
const file = path.join(root, ".kilo", "agent-manager.json")
const data = JSON.parse(fs.readFileSync(file, "utf-8"))
data.worktreeOrder = [sec.id, wt.id]
fs.writeFileSync(file, JSON.stringify(data))
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getWorktreeOrder()).toEqual([wt.id, sec.id])
})
})
})
@@ -11,6 +11,8 @@ export type SidebarItem = { type: "local" | "wt" | "session"; id: string }
/** 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)])
const secIds = new Set(secs.map((sec) => sec.id))
const wtMap = new Map(all.map((wt) => [wt.id, wt]))
const result: string[] = []
const seen = new Set<string>()
const add = (id: string) => {
@@ -21,7 +23,18 @@ export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[],
for (const id of order) add(id)
for (const sec of secs) add(sec.id)
for (const wt of all) add(wt.id)
return result
if (secs.length === 0) return result
return [
...result.filter((id) => {
const wt = wtMap.get(id)
return wt && !wt.sectionId
}),
...result.filter((id) => secIds.has(id)),
...result.filter((id) => {
const wt = wtMap.get(id)
return wt?.sectionId
}),
]
}
/** Check if this worktree is part of a multi-version group. */
@@ -42,8 +55,7 @@ export const isGroupEnd = (wt: WorktreeState, idx: number, list: WorktreeState[]
}
/**
* Build the interleaved list of sections and ungrouped worktrees
* ordered by sidebarWorktreeOrder.
* Build the top-level list with ungrouped worktrees before sections.
*/
export function buildTopLevelItems(
secs: SectionState[],
@@ -54,34 +66,18 @@ export function buildTopLevelItems(
if (secs.length === 0) {
return all.map((wt) => ({ kind: "worktree" as const, wt }))
}
const secMap = new Map(secs.map((s) => [s.id, s]))
const wtMap = new Map(ungrouped.map((wt) => [wt.id, wt]))
const result: TopLevelItem[] = []
const placed = new Set<string>()
for (const id of order) {
if (placed.has(id)) continue
placed.add(id)
const sec = secMap.get(id)
if (sec) {
result.push({ kind: "section", section: sec })
continue
}
const wt = wtMap.get(id)
if (wt) result.push({ kind: "worktree", wt })
}
for (const sec of secs) {
if (!placed.has(sec.id)) result.push({ kind: "section", section: sec })
}
for (const wt of ungrouped) {
if (!placed.has(wt.id)) result.push({ kind: "worktree", wt })
}
return result
const rank = new Map(order.map((id, idx) => [id, idx] as const))
const sort = <T extends { id: string }>(items: T[]) =>
[...items].sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER))
return [
...sort(ungrouped).map((wt) => ({ kind: "worktree" as const, wt })),
...sort(secs).map((section) => ({ kind: "section" as const, section })),
]
}
/**
* Build the flat visual order of all sidebar items matching what the user sees.
* LOCAL is always first, then worktrees in visual order (respecting section layout and
* LOCAL is always first, then worktrees in visual order (ungrouped first, then sections,
* skipping collapsed sections), then unassigned sessions.
*/
export function buildSidebarOrder(