feat(agent-manager): non-blocking worktree setup with unified busy state (#532)

Show new worktrees in the sidebar immediately after creation with a
spinner icon, allowing users to switch to other worktrees while the
setup script runs instead of being blocked by a full-screen overlay.

- Add worktreeId to worktreeSetup messages so the webview can track
  which worktree each setup event belongs to
- Push state to sidebar immediately after git worktree creation so the
  entry appears before the setup script runs
- Replace separate deletingWorktrees/settingUpWorktrees signals with a
  unified busyWorktrees map (WorktreeBusyState with reason field) for
  consistent loading behavior across setup and deletion
- Scope the setup overlay to only show when the setting-up worktree is
  selected; switching away hides it, switching back restores it
- Hide the delete button on busy worktrees instead of showing a second
  spinner
- Ensure failed session creation cleans up stale sidebar entries via
  pushState() after removeWorktree()
This commit is contained in:
Marius
2026-02-20 17:10:54 +01:00
committed by GitHub
parent e2e4520be3
commit 309bcaa297
3 changed files with 117 additions and 43 deletions
@@ -241,11 +241,26 @@ export class AgentManagerProvider implements vscode.Disposable {
parentBranch: result.parentBranch,
groupId,
})
// Push state immediately so the sidebar shows the new worktree with a loading indicator
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Setting up workspace...",
branch: result.branch,
worktreeId: worktree.id,
})
return { worktree, result }
}
/** Create a CLI session in a worktree directory. Returns null on failure. */
private async createSessionInWorktree(worktreePath: string, branch: string): Promise<SessionInfo | null> {
private async createSessionInWorktree(
worktreePath: string,
branch: string,
worktreeId?: string,
): Promise<SessionInfo | null> {
let client: HttpClient
try {
client = this.connectionService.getHttpClient()
@@ -254,6 +269,7 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.worktreeSetup",
status: "error",
message: "Not connected to CLI backend",
worktreeId,
})
return null
}
@@ -263,6 +279,7 @@ export class AgentManagerProvider implements vscode.Disposable {
status: "starting",
message: "Starting session...",
branch,
worktreeId,
})
try {
@@ -273,13 +290,14 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.worktreeSetup",
status: "error",
message: `Failed to create session: ${err}`,
worktreeId,
})
return null
}
}
/** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult): void {
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void {
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
@@ -287,6 +305,7 @@ export class AgentManagerProvider implements vscode.Disposable {
message: "Worktree ready",
sessionId,
branch: result.branch,
worktreeId,
})
this.postToWebview({
type: "agentManager.sessionMeta",
@@ -308,21 +327,22 @@ export class AgentManagerProvider implements vscode.Disposable {
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
if (!session) {
const state = this.getStateManager()
const manager = this.getWorktreeManager()
state?.removeWorktree(created.worktree.id)
await manager?.removeWorktree(created.result.path)
this.pushState()
return null
}
const state = this.getStateManager()!
state.addSession(session.id, created.worktree.id)
this.registerWorktreeSession(session.id, created.result.path)
this.notifyWorktreeReady(session.id, created.result)
this.notifyWorktreeReady(session.id, created.result, created.worktree.id)
this.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
return null
}
@@ -360,7 +380,7 @@ export class AgentManagerProvider implements vscode.Disposable {
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
const state = this.getStateManager()!
if (!state.getSession(sessionId)) {
@@ -370,7 +390,7 @@ export class AgentManagerProvider implements vscode.Disposable {
}
this.registerWorktreeSession(sessionId, created.result.path)
this.notifyWorktreeReady(sessionId, created.result)
this.notifyWorktreeReady(sessionId, created.result, created.worktree.id)
this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
return null
}
@@ -608,7 +628,7 @@ export class AgentManagerProvider implements vscode.Disposable {
}
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
private async runSetupScriptForWorktree(worktreePath: string, branch?: string, worktreeId?: string): Promise<void> {
const root = this.getWorkspaceRoot()
if (!root) return
try {
@@ -619,6 +639,7 @@ export class AgentManagerProvider implements vscode.Disposable {
status: "creating",
message: "Running setup script...",
branch,
worktreeId,
})
const runner = new SetupScriptRunner(this.outputChannel, service)
await runner.runIfConfigured({ worktreePath, repoPath: root })
@@ -63,6 +63,13 @@ interface SetupState {
message: string
branch?: string
error?: boolean
worktreeId?: string
}
interface WorktreeBusyState {
reason: "setting-up" | "deleting"
message?: string
branch?: string
}
/** Sidebar selection: LOCAL for workspace, worktree ID for a worktree, or null for an unassigned session. */
@@ -232,8 +239,7 @@ const AgentManagerContent: Component = () => {
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
const [loadingWorktrees, setLoadingWorktrees] = createSignal<Set<string>>(new Set())
const [busyWorktrees, setBusyWorktrees] = createSignal<Map<string, WorktreeBusyState>>(new Map())
const [worktreesLoaded, setWorktreesLoaded] = createSignal(false)
const [sessionsLoaded, setSessionsLoaded] = createSignal(false)
const [isGitRepo, setIsGitRepo] = createSignal(true)
@@ -601,7 +607,15 @@ const AgentManagerContent: Component = () => {
const ev = msg as AgentManagerWorktreeSetupMessage
if (ev.status === "ready" || ev.status === "error") {
const error = ev.status === "error"
setSetup({ active: true, message: ev.message, branch: ev.branch, error })
// Remove from busy map
if (ev.worktreeId) {
setBusyWorktrees((prev) => {
const next = new Map(prev)
next.delete(ev.worktreeId!)
return next
})
}
setSetup({ active: true, message: ev.message, branch: ev.branch, error, worktreeId: ev.worktreeId })
globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500)
if (!error && ev.sessionId) {
session.selectSession(ev.sessionId)
@@ -610,7 +624,15 @@ const AgentManagerContent: Component = () => {
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
} else {
setSetup({ active: true, message: ev.message, branch: ev.branch })
// Track this worktree as setting up and auto-select it in the sidebar
if (ev.worktreeId) {
setBusyWorktrees(
(prev) =>
new Map([...prev, [ev.worktreeId!, { reason: "setting-up", message: ev.message, branch: ev.branch }]]),
)
setSelection(ev.worktreeId)
}
setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId })
}
}
@@ -647,10 +669,10 @@ const AgentManagerContent: Component = () => {
}
// Recover sessions collapsed state from extension-persisted state
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
// Clear deleting state for worktrees that have been removed
// Clear busy state for worktrees that have been removed
const ids = new Set(state.worktrees.map((wt) => wt.id))
setDeletingWorktrees((prev) => {
const next = new Set([...prev].filter((id) => ids.has(id)))
setBusyWorktrees((prev) => {
const next = new Map([...prev].filter(([id]) => ids.has(id)))
return next.size === prev.size ? prev : next
})
}
@@ -659,9 +681,9 @@ const AgentManagerContent: Component = () => {
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
if (ev.status === "done" && ev.groupId) {
// Clear loading state for all worktrees in this group
setLoadingWorktrees((prev) => {
const next = new Set(prev)
// Clear busy state for all worktrees in this group
setBusyWorktrees((prev) => {
const next = new Map(prev)
for (const wt of worktrees()) {
if (wt.groupId === ev.groupId) next.delete(wt.id)
}
@@ -678,7 +700,7 @@ const AgentManagerContent: Component = () => {
const ms = managedSessions().find((s) => s.id === ev.sessionId)
const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined
if (wt?.groupId) {
setLoadingWorktrees((prev) => new Set([...prev, wt.id]))
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "setting-up" as const }]]))
}
}
}
@@ -707,11 +729,11 @@ const AgentManagerContent: Component = () => {
agent: ev.agent,
files: ev.files,
})
// Clear loading state — use worktreeId from the message directly
// Clear busy state — use worktreeId from the message directly
// to avoid race condition where managedSessions() hasn't updated yet
if (ev.worktreeId) {
setLoadingWorktrees((prev) => {
const next = new Set(prev)
setBusyWorktrees((prev) => {
const next = new Map(prev)
next.delete(ev.worktreeId)
return next
})
@@ -789,7 +811,7 @@ const AgentManagerContent: Component = () => {
const wt = worktrees().find((w) => w.id === worktreeId)
if (!wt) return
const doDelete = () => {
setDeletingWorktrees((prev) => new Set([...prev, wt.id]))
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]]))
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
if (selection() === wt.id) {
const next = nextSelectionAfterDelete(
@@ -1092,7 +1114,7 @@ const AgentManagerContent: Component = () => {
const grouped = () => isGrouped(wt)
const start = () => isGroupStart(wt, idx())
const end = () => isGroupEnd(wt, idx())
const busy = () => deletingWorktrees().has(wt.id) || loadingWorktrees().has(wt.id)
const busy = () => busyWorktrees().has(wt.id)
const groupSize = () => {
if (!wt.groupId) return 0
return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length
@@ -1133,9 +1155,14 @@ const AgentManagerContent: Component = () => {
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<Show
when={!busyWorktrees().has(wt.id)}
fallback={<Spinner class="am-worktree-spinner" />}
>
<Icon name="branch" size="small" />
</Show>
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show when={!busy()} fallback={<Spinner class="am-worktree-spinner" />}>
<Show when={!busyWorktrees().has(wt.id)}>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
@@ -1379,23 +1406,48 @@ const AgentManagerContent: Component = () => {
</div>
</Show>
<Show when={setup().active}>
<div class="am-setup-overlay">
<div class="am-setup-card">
<Icon name="branch" size="large" />
<div class="am-setup-title">{setup().error ? "Workspace setup failed" : "Setting up workspace"}</div>
<Show when={setup().branch}>
<div class="am-setup-branch">{setup().branch}</div>
</Show>
<div class="am-setup-status">
<Show when={!setup().error} fallback={<Icon name="circle-x" size="small" />}>
<Spinner class="am-setup-spinner" />
</Show>
<span>{setup().message}</span>
</div>
</div>
</div>
</Show>
{(() => {
// Show setup overlay: either the transient ready/error state for the selected worktree,
// or if the selected worktree is still being set up (from busyWorktrees map)
const overlayState = () => {
const s = setup()
const sel = selection()
// Transient ready/error overlay for the selected worktree (or worktree-less setup)
if (s.active && (!s.worktreeId || sel === s.worktreeId)) return s
// Persistent setup-in-progress for the currently selected worktree
if (typeof sel === "string" && sel !== LOCAL) {
const busy = busyWorktrees().get(sel)
if (busy?.reason === "setting-up") {
const wt = worktrees().find((w) => w.id === sel)
return { active: true, message: busy.message, branch: busy.branch ?? wt?.branch }
}
}
return null
}
return (
<Show when={overlayState()}>
{(state) => (
<div class="am-setup-overlay">
<div class="am-setup-card">
<Icon name="branch" size="large" />
<div class="am-setup-title">
{state().error ? "Workspace setup failed" : "Setting up workspace"}
</div>
<Show when={state().branch}>
<div class="am-setup-branch">{state().branch}</div>
</Show>
<div class="am-setup-status">
<Show when={!state().error} fallback={<Icon name="circle-x" size="small" />}>
<Spinner class="am-setup-spinner" />
</Show>
<span>{state().message}</span>
</div>
</div>
</div>
)}
</Show>
)
})()}
<Show when={!contextEmpty()}>
<div class="am-chat-wrapper">
<ChatView
@@ -555,6 +555,7 @@ export interface AgentManagerWorktreeSetupMessage {
message: string
sessionId?: string
branch?: string
worktreeId?: string
}
// Agent Manager worktree state types (mirrored from WorktreeStateManager)