mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(agent-manager): keep ungrouped worktrees above sections (#10056)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user