fix(ssh): make reconnection prompts non-blocking unless the project is focused

The SSH reconnect dialog was auto-opened at render time for any active/restored
worktree whose SSH target was disconnected, blocking the entire UI even when the
user was working elsewhere. On app restart the previously-active worktree is
restored without a click, so the modal stole focus app-wide over unrelated work.

The disconnected state is now surfaced only through the non-blocking affordances
that already existed — the worktree card chip, the status-bar Connect action, and
the pane-scoped terminal reconnect overlay. The blocking dialog opens only on
deliberate focus (clicking the worktree card), and even then it is suppressed when
the active view is a terminal, since that pane already shows the in-context
reconnect overlay and a second prompt would just duplicate it.

Fixes #7314
This commit is contained in:
Neil
2026-07-04 02:00:57 -07:00
parent 9964d1ccbf
commit 610e5bedf1
2 changed files with 24 additions and 19 deletions
@@ -133,7 +133,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
worktreeCardProperties = ['status']
})
it('opens the reconnect dialog for an active disconnected SSH worktree during render', () => {
it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => {
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
@@ -141,9 +141,11 @@ describe('WorktreeCard SSH reconnect prompt', () => {
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={true} />
)
expect(markup).toContain('data-ssh-disconnected-dialog="open"')
expect(markup).toContain('data-ssh-status="disconnected"')
expect(markup).toContain('data-ssh-target-label="Remote target"')
// The dialog is blocking, so being the active/restored card must not steal
// focus app-wide; it only opens on deliberate click (see handleClick).
expect(markup).toContain('data-ssh-disconnected-dialog="closed"')
// The disconnected state is still discoverable via the non-blocking card chip.
expect(markup).toContain('SSH disconnected')
})
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
@@ -342,6 +342,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
return state?.status ?? 'disconnected'
})
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
// Why: a terminal view already carries its own in-pane reconnect overlay, so
// the blocking dialog would just duplicate it there; reserve the dialog for
// views without an in-context prompt. Default to terminal (suppress) for the
// ambiguous case so we err toward non-blocking.
const activeViewIsTerminal = useAppStore(
(s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
)
// Why: runtime ("Orca server") hosts get the same disconnected treatment as
// SSH — when the host's runtime environment has no live status, its worktrees
@@ -353,23 +360,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
return !s.runtimeStatusByEnvironmentId.get(parsed.environmentId)?.status
})
// Why: the reconnect dialog is blocking, so it is never auto-shown for a
// disconnected worktree just because it is the active/restored card — that
// would steal focus app-wide while the user works elsewhere. The card chip,
// status bar, and terminal overlay carry the non-blocking disconnected state;
// the dialog only opens on deliberate focus (see handleClick).
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null
const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState<string | null>(
null
)
const [titleRenaming, setTitleRenaming] = useState(false)
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
// Why: on restart the previously-active worktree is auto-restored without a
// click, so the dialog never opens. Auto-show it for the active card when SSH
// is disconnected, but keep dismissals sticky until that prompt key changes.
if (sshDisconnectedPromptKey !== lastSshDisconnectedPromptKey) {
setLastSshDisconnectedPromptKey(sshDisconnectedPromptKey)
if (sshDisconnectedPromptKey) {
setShowDisconnectedDialog(true)
}
}
// Why: read the target label from the store (populated during hydration in
// useIpcEvents.ts) instead of calling listTargets IPC per card instance.
const sshTargetLabel = useAppStore((s) =>
@@ -862,7 +860,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
})
onImmediateActivate?.(worktree.id, activationRowKey)
void activateWorktreeFromSidebar(worktree.id)
if (isSshDisconnected) {
// Why: clicking the card is a deliberate focus of this project, so the
// blocking reconnect prompt is appropriate here (unlike auto-restore) —
// but skip it when a terminal is active, since that pane already shows the
// in-context reconnect overlay and a second prompt would just duplicate it.
if (isSshDisconnected && !activeViewIsTerminal) {
setShowDisconnectedDialog(true)
}
onActivate?.()
@@ -875,6 +877,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isDeleting,
activationRowKey,
isSshDisconnected,
activeViewIsTerminal,
onActivate,
onImmediateActivate,
onSelectionGesture