mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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.
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user