Resolve sidebar SSH reconnect prompts before commit

This commit is contained in:
Neil
2026-05-31 04:08:34 -07:00
parent 81baea8ebb
commit 71a8e500bd
4 changed files with 203 additions and 9 deletions
@@ -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<string, { status: string }>()
let sshTargetLabels = new Map<string, string>()
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
}) => (
<div
data-ssh-disconnected-dialog={open ? 'open' : 'closed'}
data-ssh-status={status}
data-ssh-target-label={targetLabel}
/>
)
}))
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(
<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"')
})
})
@@ -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<string | null>(
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) =>
@@ -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()
@@ -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<string | null>(
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) => {