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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user