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/bright-project-dnd.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The added pushState() on every drag end may not be needed, and it is not free

The multi-project body now applies the new order to its own store optimistically (ProjectSidebarBody.tsx:156-162), so unlike setSessionsCollapsed (which documents why it must round-trip) nothing here depends on the echoed state. Two side effects worth weighing:

  • pushState() calls pushProjectSessions(), which re-lists sessions for the project root and every worktree directory whenever the 2s freshness window has lapsed — i.e. a backend round trip per worktree on each drop.
  • The pushed payload includes tabOrder, and store.applyState() overwrites the store's tab order with it. The persisted order has terminal/review tab ids stripped (AgentManagerApp.tsx:506-508), so a worktree drag end can snap those tabs back to the tail of the tab bar. That already happens for renameWorktree/section ops, but this adds a new, much more frequent trigger.

If the push is only there so a background project's normalized order lands in the webview, the store already holds an equivalent order; consider dropping it (or gating on whether the normalization actually changed anything — WorktreeStateManager.setWorktreeOrder discards the changed result that moveSection already uses).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

}
return null
}
if (m.type === "agentManager.setSessionsCollapsed") {
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ interface SetTabOrderIn {

interface SetWorktreeOrderIn {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}

Expand Down
45 changes: 45 additions & 0 deletions packages/kilo-vscode/tests/unit/project-store.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
})
})
})
18 changes: 18 additions & 0 deletions packages/kilo-vscode/tests/unit/section-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This case does not actually exercise the grouping branch

With order = ["b", "c", "a"] the expected result is identical to plain applyTabOrder output, so the test passes even if the group-adjacency logic is removed. A case where the two differ would pin the behaviour the test name describes, e.g. sortWorktrees(all, ["a", "b", "c"]) should yield ["a", "c", "b"] — the g sibling pulled up next to its group start.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

})

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")]

Expand Down
36 changes: 3 additions & 33 deletions packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
type TopLevelItem,
} from "./section-helpers"
import {} from "./section-dnd"
Expand Down Expand Up @@ -845,39 +846,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))
Expand Down Expand Up @@ -2328,6 +2297,7 @@ const AgentManagerContent: Component = () => {
<ProjectList
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
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 @@ -19,11 +19,13 @@ import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL } from "./navigate"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { ProjectBranchDialog } from "./ProjectBranchDialog"
import type { ProjectStore } from "./project/store"
import type { ModeRouter } from "./mode-router"

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>>
Expand Down Expand Up @@ -208,6 +210,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]}
Expand Down
Loading
Loading