From 610e5bedf1d6eb99efd91e9e4a1a7239f9ec1807 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:00:57 -0700 Subject: [PATCH] fix(ssh): make reconnection prompts non-blocking unless the project is focused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 10 +++--- .../src/components/sidebar/WorktreeCard.tsx | 33 ++++++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 9af104c6c18..4d0fdec107a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -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', () => { ) - 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', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a823442d23d..003878d3acf 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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( - 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