From 71a8e500bdfc43d17a2b63be8024e0b6e2497a09 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 11:06:06 -0700 Subject: [PATCH] Resolve sidebar SSH reconnect prompts before commit --- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 144 ++++++++++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 13 +- .../WorktreeList.lineage-child-card.test.ts | 36 +++++ .../src/components/sidebar/WorktreeList.tsx | 19 ++- 4 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx 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 new file mode 100644 index 00000000000..681262a8fcf --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -0,0 +1,144 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type WorktreeCardComponent from './WorktreeCard' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const fetchLinearIssue = vi.fn() +const openModal = vi.fn() +const updateWorktreeMeta = vi.fn() + +let WorktreeCard: typeof WorktreeCardComponent +let sshConnectionStates = new Map() +let sshTargetLabels = new Map() +let worktreeCardProperties: WorktreeCardProperty[] = ['status'] + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + remoteBranchConflictByWorktreeId: {}, + settings: null, + sshConnectionStates, + sshTargetLabels, + updateWorktreeMeta, + worktreeCardProperties + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () => null +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'idle' +})) + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: ({ + open, + status, + targetLabel + }: { + open: boolean + status: string + targetLabel: string + }) => ( +
+ ) +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Remote repo', + badgeColor: '#999999', + addedAt: 1, + connectionId: 'ssh-target-1' + } +} + +function makeWorktree(): Worktree { + return { + id: 'worktree-1', + repoId: 'repo-1', + path: '/repo/worktrees/one', + displayName: 'Remote workspace', + branch: 'remote-workspace', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } +} + +describe('WorktreeCard SSH reconnect prompt', () => { + beforeAll(async () => { + WorktreeCard = (await import('./WorktreeCard')).default + }, 20_000) + + beforeEach(() => { + vi.clearAllMocks() + sshConnectionStates = new Map() + sshTargetLabels = new Map() + worktreeCardProperties = ['status'] + }) + + it('opens the reconnect dialog for an active disconnected SSH worktree during render', () => { + sshConnectionStates.set('ssh-target-1', { status: 'disconnected' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-ssh-disconnected-dialog="open"') + expect(markup).toContain('data-ssh-status="disconnected"') + expect(markup).toContain('data-ssh-target-label="Remote target"') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 147862e601b..199bb779024 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -174,16 +174,21 @@ const WorktreeCard = React.memo(function WorktreeCard({ }) const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) + const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null + const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState( + null + ) const [titleRenaming, setTitleRenaming] = 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 so the user sees the reconnect prompt immediately. - useEffect(() => { - if (isActive && isSshDisconnected) { + // is disconnected, but keep dismissals sticky until that prompt key changes. + if (sshDisconnectedPromptKey !== lastSshDisconnectedPromptKey) { + setLastSshDisconnectedPromptKey(sshDisconnectedPromptKey) + if (sshDisconnectedPromptKey) { setShowDisconnectedDialog(true) } - }, [isActive, isSshDisconnected]) + } // 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) => diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index a8e00e60381..b7b4f79be07 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -93,6 +93,26 @@ vi.mock('./WorktreeContextMenu', () => ({ WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: ({ + open, + status, + targetId, + targetLabel + }: { + open: boolean + status: string + targetId: string + targetLabel: string + }) => + React.createElement('aside', { + 'data-lineage-ssh-dialog': open ? 'open' : 'closed', + 'data-ssh-status': status, + 'data-ssh-target-id': targetId, + 'data-ssh-target-label': targetLabel + }) +})) + vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -358,6 +378,22 @@ describe('WorktreeList lineage child card renderer', () => { expect(agentRowIndex).toBeLessThan(childToggleIndex) }) + it('opens the reconnect dialog for an active disconnected lineage child during render', async () => { + setLineageFixtureState() + const repo = (mockStore.state.repos as Repo[])[0]! + repo.connectionId = 'ssh-target-1' + mockStore.state.activeWorktreeId = 'child' + mockStore.state.sshConnectionStates = new Map([['ssh-target-1', { status: 'disconnected' }]]) + mockStore.state.sshTargetLabels = new Map([['ssh-target-1', 'Remote target']]) + + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('data-lineage-ssh-dialog="open"') + expect(markup).toContain('data-ssh-status="disconnected"') + expect(markup).toContain('data-ssh-target-id="ssh-target-1"') + expect(markup).toContain('data-ssh-target-label="Remote target"') + }) + it('does not add group indentation when grouping is disabled', async () => { setLineageFixtureState('none') const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index b8bde6c4e77..77e6e84f6bf 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -919,6 +919,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const activeLineageChildSshDisconnected = activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected' + const lineageReconnectPromptKey = + activeLineageChildWorktreeId && activeLineageChildSshDisconnected + ? activeLineageChildWorktreeId + : null + const [lastLineageReconnectPromptKey, setLastLineageReconnectPromptKey] = useState( + null + ) const renderRowsRef = useRef(renderRows) renderRowsRef.current = renderRows const getVirtualItemKey = useCallback( @@ -1388,12 +1395,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree]) // Why: lightweight nested cards do not mount WorktreeCard, so the viewport - // owns the SSH reconnect prompt for an active lineage child. - useEffect(() => { - if (activeLineageChildWorktreeId && activeLineageChildSshDisconnected) { - setLineageReconnectWorktreeId(activeLineageChildWorktreeId) + // owns the SSH reconnect prompt for an active lineage child. The prompt key + // keeps dismissals sticky until the active/disconnected child changes. + if (lineageReconnectPromptKey !== lastLineageReconnectPromptKey) { + setLastLineageReconnectPromptKey(lineageReconnectPromptKey) + if (lineageReconnectPromptKey) { + setLineageReconnectWorktreeId(lineageReconnectPromptKey) } - }, [activeLineageChildWorktreeId, activeLineageChildSshDisconnected]) + } const handleContainerKeyDown = useCallback( (e: React.KeyboardEvent) => {