Files
kirillk 8100652397 fix(agent-manager): close the gaps the cross-cutting review found
Twelve findings from a review of this PR against the recent gh, worktree,
staleness, and pooling work. The theme is that the hardening landed on the
paths this PR touched, while adjacent paths kept the old semantics.

VS Code:

- The batched GraphQL lookup became the primary PR path after 81b9c21c24,
  and it bypassed everything this PR added: a 20s budget instead of
  BUDGET.gh, no timeout classification, and no quarantine or health gate,
  so a parked worktree still cost a git process per full sync and could
  become the working directory of the whole `gh api graphql` call. It now
  runs on the shared budgets, skips parked worktrees, and reports a
  timeout as a timeout instead of retrying it as an unsupported field.
- Orphan reporting re-checks git registration before naming a directory,
  so a worktree the pool created mid-pass is no longer offered for
  deletion, and an unanswerable re-check reports nothing.
- Restoring a worktree clears its PR polling backoff, instead of leaving
  the worktree the user just repaired parked for up to half an hour.
- The orphan kind reaches the webview: a directory that still holds a
  checkout is marked and gets a confirmation that says it may hold
  uncommitted work, rather than "nothing here is tracked by git".
- Removing a stale entry no longer dead-ends when terminals cannot be
  stopped. Nothing on that path deletes files, so for an unregistered
  worktree the only action offered used to fail with an unrelated error.
- PR import runs gh on BUDGET.gh and reports a hang as a timeout.
- findWorktreeByPath and prepareWorktreePath compare with pathKey, so a
  symlinked parent or a case variant cannot hide a registration.
- Quarantine gained a non-mutating peek, so generating a diagnostics
  report no longer releases the quarantines it is reporting.
- The stats loop gained the same per-worktree backoff as the PR loop; an
  explicit refresh still measures a parked worktree.

JetBrains:

- A poll prunes stale worktree metadata only when no mutation holds the
  repository lock, and never waits for it. `git worktree add` registers a
  worktree before its checkout is written, so pruning underneath one
  could delete the metadata of a worktree being created.
- `git status` is off the 5s probe budget: it scans the working tree, so
  on a large or cold worktree the probe budget reported unavailable for a
  measurement that would have succeeded. The sync prune uses the write
  budget its docstring already claimed.
2026-09-15 11:07:14 -04:00

509 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
createSortable,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import type {
LocalGitStats,
ManagedSessionState,
PRStatus,
RunStatus,
SectionState,
WorktreeGitStats,
WorktreeState,
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL, adjacentHint } from "./navigate"
import { applyTabOrder, reorderTabs } from "./tab-order"
import { buildTopLevelItems, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { createWorktreeCompletion } from "./worktree-completion"
import { worktreeDropReference } from "./worktree-references"
import { beginPromptMentionDrop, endPromptMentionDrop } from "../src/utils/prompt-mention-drop"
import { outsideSidebar, sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { useVSCode } from "../src/context/vscode"
import { OrphanNotice } from "./OrphanNotice"
import type { OrphanDirectory } from "./project/store"
import SectionHeader from "./SectionHeader"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem, actionable } from "./WorktreeItem"
import { useBaseUpdate } from "./update-from-base"
import { WorktreeSectionActions } from "./WorktreeSectionActions"
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { LocalActivity } from "../src/components/shared/ActivityIcon"
import { label, type Activity } from "../src/utils/session-activity"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
/** Everything the legacy single-project sidebar body reads from the app. */
export interface SidebarBodyProps {
t: LanguageContextValue["t"]
projectId?: string
selection: () => string | null
currentSessionID: () => string | undefined
selectLocal: () => void
selectWorktree: (id: string) => void
onOpenComments?: (id: string) => void
onOpenPR?: (id: string) => void
activityFor: (id: string | null) => Activity
repoBranch: () => string | undefined
localStats: () => LocalGitStats | undefined
search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined }
bindings: () => Record<string, string>
defaultBranch: () => string
isGitRepo: () => boolean
loaded: () => boolean
worktreesLoaded: () => boolean
sessionsLoaded: () => boolean
onSearchRef: (ref: SidebarSearchMenuRef | undefined) => void
onSearchSelect: (item: SidebarSearchItem) => void
onCreateWorktree: () => void
onNewWorktree: () => void
onNewSection: () => void
onShortcuts: () => void
onHistory: () => void
sections: () => SectionState[]
sortedWorktrees: () => WorktreeState[]
sidebarOrder: () => { id: string }[]
sidebarWorktreeOrder: () => string[]
setSidebarWorktreeOrder: (fn: (prev: string[]) => string[]) => void
draggingWorktree: () => string | undefined
setDraggingWorktree: (id: string | undefined) => void
moveToSection: (ids: string[], sectionId: string | null) => void
moveSection: (id: string, dir: -1 | 1) => void
renamingSection: () => string | null
setRenamingSection: (id: string | null) => void
managedSessions: () => ManagedSessionState[]
worktreeLabel: (wt: WorktreeState) => string
worktreeSubtitle: (wt: WorktreeState) => string | undefined
pendingDelete: () => string | null
busy: (id: string) => boolean
blocked: (id: string) => boolean
isStaleWorktree: (id: string) => boolean
/** Why an unhealthy worktree is unhealthy, when known. */
worktreeHealth?: (id: string) => "absent-restorable" | "absent-gone" | "unregistered" | "unavailable" | undefined
/** Leftover folders under `.kilo/worktrees/` that no worktree claims. */
orphanDirectories?: () => OrphanDirectory[]
/** Restore a deleted worktree folder from its branch. */
onRestoreWorktree?: (id: string) => void
/** Drop the entry but move its sessions to Local. */
onRemoveStaleKeepSessions?: (id: string) => void
shortcutMap: () => Map<string, number>
worktreeStats: () => Record<string, WorktreeGitStats>
prStatuses: () => Record<string, PRStatus | null>
runStatuses: () => Record<string, RunStatus>
cancelPendingDelete: () => void
handleDeleteWorktree: (id: string, e: MouseEvent) => void
confirmRemoveStaleWorktree: (id: string) => void
track: (event: string, source: string, action: () => void) => () => void
}
/** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */
export const SidebarBody: Component<SidebarBodyProps> = (props) => {
const completion = createWorktreeCompletion(props.sortedWorktrees, () => props.projectId, props.worktreeLabel)
const sorted = completion.rows
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
const top = createMemo(() =>
buildTopLevelItems(props.sections(), ungrouped(), sorted(), props.sidebarWorktreeOrder()),
)
const vscode = useVSCode()
const updateBase = useBaseUpdate()
const localState = () => props.activityFor(null)
// Captured at worktree drag start so a release outside the sidebar, or a drop
// on the prompt, can undo a reorder applied while passing over sibling rows.
let origin: string[] | undefined
return (
<>
{/* Local repo item */}
<button
class={`am-local-item ${props.selection() === LOCAL ? "am-local-item-active" : ""}`}
data-sidebar-id="local"
onClick={() => props.selectLocal()}
>
<LocalActivity state={localState()} label={props.t(label(localState()))} />
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.repoBranch()}>
<span class="am-local-branch">{props.repoBranch()}</span>
</Show>
</div>
<div class="am-wt-actions-cell">
<Show when={props.localStats() === undefined}>
<StatsSkeleton />
</Show>
<Show
when={
props.localStats() &&
(props.localStats()!.files > 0 ||
props.localStats()!.additions > 0 ||
props.localStats()!.deletions > 0 ||
props.localStats()!.ahead > 0 ||
props.localStats()!.behind > 0)
}
>
<div class="am-worktree-stats">
<Show
when={props.localStats()!.additions > 0 || props.localStats()!.deletions > 0}
fallback={
<Show when={props.localStats()!.files > 0}>
<span class="am-stat-files">{props.localStats()!.files}f</span>
</Show>
}
>
<div class="am-worktree-stats-row">
<Show when={props.localStats()!.additions > 0}>
<span class="am-stat-additions">+{props.localStats()!.additions}</span>
</Show>
<Show when={props.localStats()!.deletions > 0}>
<span class="am-stat-deletions">
{""}
{props.localStats()!.deletions}
</span>
</Show>
</div>
</Show>
<Show when={props.localStats()!.ahead > 0 || props.localStats()!.behind > 0}>
<div class="am-worktree-stats-row">
<Show when={props.localStats()!.ahead > 0}>
<span class="am-worktree-commits">
{"↑"}
{props.localStats()!.ahead}
</span>
</Show>
<Show when={props.localStats()!.behind > 0}>
<span class="am-worktree-behind">
{"↓"}
{props.localStats()!.behind}
</span>
</Show>
</div>
</Show>
</div>
</Show>
<div class="am-wt-hover-actions">
<span class="am-shortcut-badge">{isMac ? "⌘" : "Ctrl+"}1</span>
</div>
</div>
</button>
{/* WORKTREES section */}
<div class="am-section am-section-grow">
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
actions={
<WorktreeSectionActions
items={props.search.items}
current={props.search.current}
bindings={props.bindings()}
branch={props.defaultBranch()}
git={props.isGitRepo()}
loaded={props.loaded()}
t={props.t}
onRef={(value) => props.onSearchRef(value)}
onSelect={props.onSearchSelect}
onCreate={props.onCreateWorktree}
onNew={props.onNewWorktree}
onSection={props.onNewSection}
onShortcuts={props.onShortcuts}
onHistory={props.onHistory}
onSettings={() =>
vscode.postMessage({ type: "openSettingsPanel", tab: "agentManager", projectId: props.projectId })
}
/>
}
/>
<div class="am-worktree-list">
<Show when={props.worktreesLoaded() && props.sessionsLoaded()} fallback={<WorktreeSkeleton />}>
<Show when={!props.isGitRepo()}>
<div class="am-not-git-notice">
<Icon name="warning" size="small" />
<span>{props.t("agentManager.notGitRepo")}</span>
</div>
</Show>
<Show when={props.isGitRepo()}>
{(() => {
const [renamingWt, setRenamingWt] = createSignal<string | null>(null)
const [renameValue, setRenameValue] = createSignal("")
const startRename = (wtId: string, current: string) => {
setRenamingWt(wtId)
setRenameValue(current)
}
let cancelled = false
const commitRename = (wtId: string) => {
if (cancelled) {
cancelled = false
return
}
const value = renameValue().trim()
setRenamingWt(null)
if (!value) return
vscode.postMessage({ type: "agentManager.renameWorktree", worktreeId: wtId, label: value })
}
const cancelRename = () => {
cancelled = true
setRenamingWt(null)
}
const hasSections = createMemo(() => props.sections().length > 0)
const wtIds = createMemo(() => sorted().map((wt) => wt.id))
const secIds = createMemo(() => new Set(props.sections().map((s) => s.id)))
const home = () => new Map(sorted().map((w) => [w.id, w.sectionId] as const))
const sectionAware = sectionAwareDetector(secIds, home)
const onWtDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") {
props.setDraggingWorktree(id)
origin = props.sidebarWorktreeOrder()
const wt = sorted().find((item) => item.id === id)
if (wt) {
beginPromptMentionDrop({
kind: "worktree",
worktree: worktreeDropReference(
wt,
props.worktreeLabel(wt),
props
.managedSessions()
.filter((session) => session.worktreeId === wt.id)
.map((session) => ({ id: session.id })),
wt.id === props.selection() || props.isStaleWorktree(wt.id) || props.busy(wt.id),
),
})
}
}
document.body.classList.add("am-wt-dragging-active")
}
const onWtDragOver = (event: DragEvent) => {
// Once the card leaves the sidebar it is on its way to the
// prompt, so stop reordering the list under it.
if (outsideSidebar(event.draggable)) return
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
if (secIds().has(to)) return
props.setSidebarWorktreeOrder((prev) => {
const cur = applyTabOrder(
sorted().map((w) => ({ id: w.id })),
prev,
).map((item: { id: string }) => item.id)
return reorderTabs(cur, from, to) ?? prev
})
}
const onWtDragEnd = (event: DragEvent) => {
const handled = endPromptMentionDrop()
const from = event.draggable?.id
const to = event.droppable?.id
props.setDraggingWorktree(undefined)
document.body.classList.remove("am-wt-dragging-active")
// A drop on the prompt inserts a mention. Do not also move the
// worktree to whatever section happens to be under the pointer.
// Both this path and an outside release undo the pass-over
// reorder so the sidebar matches the persisted order.
if (handled || outsideSidebar(event.draggable)) {
if (origin) props.setSidebarWorktreeOrder(() => origin!)
origin = undefined
return
}
origin = undefined
if (typeof from === "string" && typeof to === "string" && secIds().has(to)) {
props.moveToSection([from], to)
return
}
vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: props.sidebarWorktreeOrder() })
}
return (
<DragDropProvider
onDragStart={onWtDragStart}
onDragEnd={onWtDragEnd}
onDragOver={onWtDragOver}
collisionDetector={sectionAware}
>
<DragDropSensors />
<ConstrainDragXAxis />
<SortableProvider ids={wtIds()}>
{(() => {
const renderWt = (wt: WorktreeState, idx: () => number, list?: WorktreeState[]) => {
const wtSessions = createMemo(() =>
props.managedSessions().filter((ms) => ms.worktreeId === wt.id),
)
const navHint = () =>
adjacentHint(
wt.id,
props.selection() ?? props.currentSessionID() ?? "",
props.sidebarOrder().map((f) => f.id),
props.bindings().previousSession ?? "",
props.bindings().nextSession ?? "",
)
const groupSize = () =>
!wt.groupId ? 0 : sorted().filter((w) => w.groupId === wt.groupId).length
const sortable = createSortable(wt.id)
void sortable
return (
<div
use:sortable
class={`am-wt-sortable ${sortable.isActiveDraggable ? "am-wt-dragging" : ""}`}
>
<WorktreeItem
completed={completion.completed(wt.id)}
onCompletionEnd={() => completion.release(wt.id)}
worktree={wt}
label={props.worktreeLabel(wt)}
subtitle={props.worktreeSubtitle(wt)}
active={props.selection() === wt.id}
pendingDelete={props.pendingDelete() === wt.id}
busy={props.busy(wt.id)}
activity={props.activityFor(wt.id)}
blocked={props.blocked(wt.id)}
stale={props.isStaleWorktree(wt.id) || actionable(props.worktreeHealth?.(wt.id))}
health={props.worktreeHealth?.(wt.id)}
shortcut={props.shortcutMap().get(wt.id)}
stats={props.worktreeStats()[wt.id]}
navHint={navHint()}
sessions={wtSessions().length}
grouped={isGrouped(wt)}
groupStart={isGroupStart(wt, idx(), list ?? sorted())}
groupEnd={isGroupEnd(wt, idx(), list ?? sorted())}
groupSize={groupSize()}
renaming={renamingWt() === wt.id}
renameValue={renameValue()}
closeKeybind={props.bindings().closeWorktree ?? ""}
openKeybind={props.bindings().openWorktree ?? ""}
pr={
props.prStatuses()[wt.id] !== undefined
? (props.prStatuses()[wt.id] ?? undefined)
: undefined
}
runStatus={props.runStatuses()[wt.id]}
onOpenComments={() => props.onOpenComments?.(wt.id)}
onOpenPR={props.track("open_pull_request", "worktree_menu", () =>
props.onOpenPR?.(wt.id),
)}
sections={props.sections()}
currentSectionId={wt.sectionId}
onMoveToSection={(secId) => props.moveToSection([wt.id], secId)}
onMoveToNewSection={props.track("new_section", "worktree_menu", () =>
props.onNewSection(),
)}
onClick={() => props.selectWorktree(wt.id)}
onCancelDelete={props.cancelPendingDelete}
onDelete={(e) => props.handleDeleteWorktree(wt.id, e)}
onStartRename={(current) => startRename(wt.id, current)}
onRenameInput={(v) => setRenameValue(v)}
onCommitRename={() => commitRename(wt.id)}
onCancelRename={cancelRename}
onRemoveStale={() => props.confirmRemoveStaleWorktree(wt.id)}
onRestore={props.onRestoreWorktree ? () => props.onRestoreWorktree?.(wt.id) : undefined}
onRemoveKeepSessions={
props.onRemoveStaleKeepSessions
? () => props.onRemoveStaleKeepSessions?.(wt.id)
: undefined
}
onUpdateBase={() =>
updateBase(
wt.id,
props.projectId,
wtSessions().find((item) => item.id === props.currentSessionID())?.id,
)
}
onCopyPath={() => navigator.clipboard.writeText(wt.path)}
onOpen={props.track("open_worktree_window", "worktree_menu", () =>
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }),
)}
/>
</div>
)
}
if (hasSections()) {
const post = vscode.postMessage.bind(vscode)
return (
<For each={top()}>
{(item, idx) => {
if (item.kind === "section") {
const sec = item.section
const members = createMemo(() => sorted().filter((wt) => wt.sectionId === sec.id))
return (
<SectionHeader
section={sec}
count={members().length}
autoRename={props.renamingSection() === sec.id}
onRenameEnd={() => props.setRenamingSection(null)}
onToggle={() =>
post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id })
}
onRename={(name) =>
post({ type: "agentManager.renameSection", sectionId: sec.id, name })
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })}
onSetColor={(color) =>
post({ type: "agentManager.setSectionColor", sectionId: sec.id, color })
}
isFirst={idx() === 0}
isLast={idx() === top().length - 1}
onMoveUp={() => props.moveSection(sec.id, -1)}
onMoveDown={() => props.moveSection(sec.id, 1)}
>
<Show when={!sec.collapsed}>
<div class="am-section-group-body">
<For each={members()}>{(wt, wtIdx) => renderWt(wt, wtIdx, members())}</For>
</div>
</Show>
</SectionHeader>
)
}
const ug = ungrouped()
const wtIdx = () => ug.indexOf(item.wt)
return renderWt(item.wt, wtIdx, ug)
}}
</For>
)
}
return <For each={sorted()}>{(wt, idx) => renderWt(wt, idx)}</For>
})()}
</SortableProvider>
<DragOverlay>
{(() => {
const wt = sorted().find((w) => w.id === props.draggingWorktree())
if (!wt) return null
return (
<div class="am-wt-overlay">
<Icon name="branch" size="small" />
<span>{props.worktreeLabel(wt)}</span>
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
)
})()}
<Show when={sorted().length === 0}>
<button class="am-worktree-create" onClick={props.onNewWorktree}>
<Icon name="plus" size="small" />
<span>{props.t("agentManager.worktree.new")}</span>
</button>
</Show>
</Show>
</Show>
<OrphanNotice
orphans={props.orphanDirectories?.() ?? []}
onClean={(paths) => vscode.postMessage({ type: "agentManager.cleanOrphanDirectories", paths })}
/>
</div>
</div>
</>
)
}