Summary
A worktree can get stuck on the loading spinner (WorktreeLoadingView) forever. The worktree itself is completely healthy (git intact, node_modules installed, terminal/zmx sessions alive and attached) — only the UI overlay is wedged. It does not recover on focus-away/return; the only fix is restarting the app.
I hit this on two worktrees simultaneously, both of which I had pinned while their setup/build was still running.
Environment
- Version: 0.10.2 (142000)
- macOS 26.5 (Apple Silicon)
Root cause (traced in source)
The loading overlay is gated on a single state field:
WorktreeDetailView.loadingInfo() returns a WorktreeLoadingInfo (→ shows WorktreeLoadingView) iff selectedRow.lifecycle == .pending.
lifecycle is set to .pending once, at creation: RepositoriesFeature.swift:1540. It is in-memory only (not persisted).
- For the setup-script path,
.pending is cleared only by .consumeSetupScript (RepositoriesFeature.swift:1594-1596 → lifecycleChanged(.idle)).
.consumeSetupScript is sent only from onSetupScriptConsumed?(), which fires behind this guard in WorktreeTerminalState.swift:
let tabId = createTab(TabCreation(/* initialInput: setup script */ ...))
if shouldConsumeSetupScript, tabId != nil {
onSetupScriptConsumed?()
}
There is no else branch, no timeout, and no failure path, and .pending is never re-evaluated. So if createTab returns nil (or that creation/consume step is interrupted), .consumeSetupScript is never sent, lifecycle stays .pending, and the overlay spins indefinitely. Because the lifecycle is in-memory, only an app restart (which resets it to .idle) clears it.
Hypothesized trigger (not yet deterministically reproduced)
Pinning a worktree while its setup script is still running. Pinning moves the row between sidebar buckets (unpinned → pinned), which churns sidebar/terminal state and appears to race the first-tab creation, so the tabId != nil branch is skipped and the consume signal is lost. Both worktrees I had pinned-mid-build ended up stuck; others in the same repo were fine.
I want to be honest that I have not reproduced this in a controlled run — the race depends on createTab returning nil at the wrong moment, so it may be intermittent. The design gap is concrete regardless of trigger: a .pending worktree whose consume signal never arrives has no recovery.
Impact
- Worktree appears permanently broken/"loading" even though all underlying work (terminals, running agents) is alive and fine.
- No in-app way to recover — requires quitting and relaunching the whole app.
Suggested fix
- Tie
.consumeSetupScript to the actual setup-script lifecycle (tab created and input dispatched / process exited), not a synchronous tabId != nil check.
- Add a failure/timeout transition out of
.pending so an interrupted or failed setup can never strand the row on the overlay.
- Optionally, make pinning defer until creation settles, or have pin/bucket-move be a no-op for the in-flight creation task.
Workaround
Quit and relaunch the app — .pending is in-memory, so it resets to .idle and the worktree loads normally. (zmx sessions persist across the restart, so no terminal/agent state is lost.)
Summary
A worktree can get stuck on the loading spinner (
WorktreeLoadingView) forever. The worktree itself is completely healthy (git intact,node_modulesinstalled, terminal/zmx sessions alive and attached) — only the UI overlay is wedged. It does not recover on focus-away/return; the only fix is restarting the app.I hit this on two worktrees simultaneously, both of which I had pinned while their setup/build was still running.
Environment
Root cause (traced in source)
The loading overlay is gated on a single state field:
WorktreeDetailView.loadingInfo()returns aWorktreeLoadingInfo(→ showsWorktreeLoadingView) iffselectedRow.lifecycle == .pending.lifecycleis set to.pendingonce, at creation:RepositoriesFeature.swift:1540. It is in-memory only (not persisted)..pendingis cleared only by.consumeSetupScript(RepositoriesFeature.swift:1594-1596→lifecycleChanged(.idle))..consumeSetupScriptis sent only fromonSetupScriptConsumed?(), which fires behind this guard inWorktreeTerminalState.swift:There is no
elsebranch, no timeout, and no failure path, and.pendingis never re-evaluated. So ifcreateTabreturnsnil(or that creation/consume step is interrupted),.consumeSetupScriptis never sent,lifecyclestays.pending, and the overlay spins indefinitely. Because the lifecycle is in-memory, only an app restart (which resets it to.idle) clears it.Hypothesized trigger (not yet deterministically reproduced)
Pinning a worktree while its setup script is still running. Pinning moves the row between sidebar buckets (
unpinned→pinned), which churns sidebar/terminal state and appears to race the first-tab creation, so thetabId != nilbranch is skipped and the consume signal is lost. Both worktrees I had pinned-mid-build ended up stuck; others in the same repo were fine.I want to be honest that I have not reproduced this in a controlled run — the race depends on
createTabreturning nil at the wrong moment, so it may be intermittent. The design gap is concrete regardless of trigger: a.pendingworktree whose consume signal never arrives has no recovery.Impact
Suggested fix
.consumeSetupScriptto the actual setup-script lifecycle (tab created and input dispatched / process exited), not a synchronoustabId != nilcheck..pendingso an interrupted or failed setup can never strand the row on the overlay.Workaround
Quit and relaunch the app —
.pendingis in-memory, so it resets to.idleand the worktree loads normally. (zmx sessions persist across the restart, so no terminal/agent state is lost.)