feat(sidebar): inline SSH reconnect control on workspace cards

Replaces the blocking SshDisconnectedDialog with an inline pill in the
workspace card title row, and unifies the SSH connect vocabulary across the
sidebar card, terminal overlay, host-header menu, and status-bar row.

- new src/renderer/src/ssh/ modules: typechecker-total status predicates
  (recoverability), a shared in-flight connect registry, the promoted UI
  connect timeout, and the shared connect verb table
- WorktreeCardSshHostControl: one 16px pill shape for every state, icon-only
  in compact/new card modes, passive glyph for connected/null/removed hosts
- migrates the four duplicated status predicates to the shared module
- deletes SshDisconnectedDialog (and its window-capture Enter handler)
This commit is contained in:
Jinjing
2026-08-04 11:04:43 -07:00
parent 4734428654
commit c3cad041dc
36 changed files with 1080 additions and 426 deletions
@@ -66,6 +66,7 @@ import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
type EphemeralVmRecipeOption = NonNullable<OrcaHooks['environmentRecipes']>[number]
@@ -210,33 +211,6 @@ function getSshStatusLabel(status: SshConnectionStatus): string {
return SSH_STATUS_LABELS[status] ?? status
}
// Why: bound how long the run-target picker waits on a host connect so a stalled backend
// connect can't leave the row's disabled/spinner state stuck forever. The backend keeps going.
const RUN_TARGET_CONNECT_UI_TIMEOUT_MS = 20_000
async function withUiConnectTimeout<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
translate(
'auto.components.NewWorkspaceComposerCard.connectTimedOut',
'Connection timed out. It may still be connecting in the background.'
)
)
)
}, RUN_TARGET_CONNECT_UI_TIMEOUT_MS)
})
try {
return await Promise.race([promise, timeout])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element {
// Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and
// the run-setup toggle live in the section header above, so the card carries no chrome of its
@@ -1,15 +1,10 @@
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove'
const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet<SshConnectionStatus> = new Set([
'connecting',
'deploying-relay',
'reconnecting'
])
export function isSshTargetConnecting(status: SshConnectionStatus): boolean {
return SSH_TARGET_CONNECTING_STATUSES.has(status)
return isConnectingSshStatus(status)
}
export function shouldClearPendingSshReset({
@@ -26,7 +26,7 @@ type AutoRenameFailedDialogProps = {
* CLI output when main still holds it (in-memory, lost on restart), falling
* back to the persisted excerpt — either can run many lines, so it gets a
* dedicated scrollable surface rather than a tooltip — see the sibling
* SshDisconnectedDialog pattern.
* AddRemoteHostDialog pattern.
*/
export function AutoRenameFailedDialog({
open,
@@ -150,7 +150,7 @@ export function AutoRenameFailedDialog({
</div>
<DialogFooter>
{/* Why: Close backs the user out, so it stays quiet (outline, not a
solid CTA) — matching the sibling SshDisconnectedDialog. */}
solid CTA) — matching the other sidebar dialogs. */}
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{translate('auto.components.sidebar.AutoRenameFailedDialog.aed1623b1e', 'Close')}
</Button>
@@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { sshConnectVerb } from '@/ssh/ssh-connect-verb'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat'
import {
@@ -74,18 +75,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
const [renameOpen, setRenameOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const mountedRef = useMountedRef()
const sshConnected = useAppStore((s) => {
const sshStatus = useAppStore((s) => {
const parsed = parseExecutionHostId(row.hostId)
if (parsed?.kind !== 'ssh') {
return false
return null
}
return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected'
return s.sshConnectionStates.get(parsed.targetId)?.status ?? null
})
const model = buildHostHeaderMenuModel({
kind: row.kind,
health: row.health,
sshConnected,
sshConnected: sshStatus === 'connected',
compatibility: row.compatibility
})
const removalTarget = resolveHostRemoval(row.hostId)
@@ -242,7 +243,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
{model.actions.includes('ssh-reconnect') && (
<DropdownMenuItem onSelect={() => void runSshAction('connect')}>
<Plug className="size-3.5" />
{translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')}
{sshConnectVerb(sshStatus)}
</DropdownMenuItem>
)}
{model.actions.includes('ssh-disconnect') && (
@@ -1,205 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Loader2, Server, ServerOff } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { statusColor } from '@/components/settings/SshTargetCard'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
type SshDisconnectedDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
targetId: string
targetLabel: string
status: SshConnectionStatus
}
const STATUS_MESSAGES: Partial<Record<SshConnectionStatus, string>> = {
get disconnected() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.disconnected',
'This SSH host is not connected.'
)
},
get reconnecting() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
'Reconnecting to the remote host...'
)
},
get 'reconnection-failed'() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed',
'Reconnection to the remote host failed.'
)
},
get error() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.376bed88e5',
'The connection to the remote host encountered an error.'
)
},
get 'auth-failed'() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.authFailed',
'Authentication to the remote host failed.'
)
}
}
function isReconnectable(status: SshConnectionStatus): boolean {
return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
}
export function SshDisconnectedDialog({
open,
onOpenChange,
targetId,
targetLabel,
status
}: SshDisconnectedDialogProps): React.JSX.Element {
const [connecting, setConnecting] = useState(false)
const mountedRef = useMountedRef()
const handleReconnect = useCallback(async () => {
setConnecting(true)
try {
await window.api.ssh.connect({ targetId })
if (mountedRef.current) {
onOpenChange(false)
}
} catch (err) {
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.sidebar.SshDisconnectedDialog.656368f3a2',
'Reconnection failed'
)
)
} finally {
if (mountedRef.current) {
setConnecting(false)
}
}
}, [mountedRef, targetId, onOpenChange])
const isConnecting =
connecting ||
status === 'connecting' ||
status === 'deploying-relay' ||
status === 'reconnecting'
const reconnectingMessage =
STATUS_MESSAGES.reconnecting ??
translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
'Reconnecting to the remote host...'
)
const disconnectedMessage =
STATUS_MESSAGES.disconnected ??
translate(
'auto.components.sidebar.SshDisconnectedDialog.disconnected',
'This SSH host is not connected.'
)
const message = isConnecting
? reconnectingMessage
: (STATUS_MESSAGES[status] ?? disconnectedMessage)
const showReconnect = isReconnectable(status)
useEffect(() => {
// Window-level Enter handler. The dialog typically appears while focus
// is inside an embedded terminal (xterm) or editor (monaco) that
// aggressively reclaims focus, so dialog-scoped key handlers never
// fire. Listening on window (capture phase) catches Enter regardless
// of where focus actually lives while the dialog is open.
if (!open || !showReconnect || isConnecting) {
return undefined
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter' || event.defaultPrevented) {
return
}
if (event.isComposing) {
return
}
event.preventDefault()
event.stopPropagation()
void handleReconnect()
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [open, showReconnect, isConnecting, handleReconnect])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm gap-3 p-5" showCloseButton={false}>
<DialogHeader className="gap-1">
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
{isConnecting ? (
<Loader2 className="size-4 text-yellow-500 animate-spin" />
) : (
<ServerOff className="size-4 text-muted-foreground" />
)}
{isConnecting
? translate(
'auto.components.sidebar.SshDisconnectedDialog.cb5938ae79',
'Reconnecting...'
)
: translate(
'auto.components.sidebar.SshDisconnectedDialog.11552bf786',
'SSH Disconnected'
)}
</DialogTitle>
<DialogDescription className="text-xs">{message}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2.5 rounded-md border border-border/50 bg-card/40 px-3 py-2">
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium">{targetLabel}</span>
</div>
{isConnecting ? (
<Loader2 className="size-3.5 shrink-0 text-yellow-500 animate-spin" />
) : (
<span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} />
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isConnecting}
>
{translate('auto.components.sidebar.SshDisconnectedDialog.89385db176', 'Dismiss')}
</Button>
{showReconnect && (
<Button size="sm" onClick={() => void handleReconnect()} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{translate(
'auto.components.sidebar.SshDisconnectedDialog.ca4a7892af',
'Connecting...'
)}
</>
) : (
translate('auto.components.sidebar.SshDisconnectedDialog.4afcca1d24', 'Reconnect')
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -80,10 +80,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => <div data-testid="inline-agents" />
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => (
<div data-testid="context-menu-wrapper">{children}</div>
@@ -111,10 +111,6 @@ vi.mock('./WorktreeCardAgents', () => ({
)
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -124,10 +124,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => <div data-worktree-agents="" />
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -63,10 +63,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -53,10 +53,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -68,10 +68,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -59,10 +59,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -70,24 +70,6 @@ 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',
@@ -144,7 +126,9 @@ describe('WorktreeCard SSH reconnect prompt', () => {
worktreeCardProperties = ['status']
})
it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => {
// Supersedes the old "does not auto-open the blocking reconnect dialog" assertion:
// the dialog is gone, so no card state can open one.
it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => {
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
@@ -152,11 +136,50 @@ describe('WorktreeCard SSH reconnect prompt', () => {
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={true} />
)
// 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')
expect(markup).toContain('Connect to SSH host Remote target')
expect(markup).toContain('Connect')
expect(markup).not.toContain('ssh-disconnected-dialog')
})
it('names the failure state in the control verb rather than a generic Connect', () => {
sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Reconnect SSH host Remote target')
expect(markup).toContain('authentication failed')
})
it('renders the passive host glyph, not a control, when the SSH host is connected', () => {
sshConnectionStates.set('ssh-target-1', { status: 'connected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Project on SSH host Remote target')
expect(markup).not.toContain('Connect to SSH host')
})
// Why: the control keys off repo.connectionId, which is orthogonal to workspace kind —
// a folder workspace on a disconnected SSH host must get the same affordance.
it('offers the control for a folder workspace on a disconnected SSH host', () => {
sshConnectionStates.set('ssh-target-1', { status: 'error' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).toContain('Retry SSH connection to Remote target')
})
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
@@ -21,13 +21,13 @@ import {
} from 'lucide-react'
import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
@@ -94,7 +94,8 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
selectRuntimeAwareSshStatus,
selectRuntimeAwareSshTargetLabel
selectRuntimeAwareSshTargetLabel,
selectRuntimeAwareSshTargetRemoved
} from '@/store/slices/runtime-environment-ssh'
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
@@ -362,9 +363,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
}, [sshOwnerEnvironmentId])
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
// Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous).
const activeViewIsTerminal = useAppStore(
(s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
// Why: only reported on positive evidence, so a removed host never offers a Connect that can only fail.
const sshTargetRemoved = useAppStore((s) =>
repo?.connectionId
? selectRuntimeAwareSshTargetRemoved(s, sshOwnerEnvironmentId, repo.connectionId)
: false
)
const parsedRepoHost = parseExecutionHostId(repo?.executionHostId)
@@ -390,8 +393,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status
})
// Why: the reconnect dialog blocks, so it never auto-shows for the active card (would steal app-wide focus); opens only on deliberate focus (handleClick).
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
const [titleRenaming, setTitleRenaming] = useState(false)
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
// Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state.
@@ -877,10 +878,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree.id,
worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined)
)
// Why: a deliberate card click warrants the blocking reconnect prompt; skip it when a terminal already shows the overlay.
if (isSshDisconnected && !activeViewIsTerminal) {
setShowDisconnectedDialog(true)
}
onActivate?.()
},
[
@@ -893,7 +890,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
isDeleting,
activationRowKey,
isSshDisconnected,
activeViewIsTerminal,
onActivate,
onImmediateActivate,
onSelectionGesture
@@ -1428,28 +1424,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center">
{isSshDisconnected ? (
<ServerOff className="size-3 text-red-400" />
) : (
<Server className="size-3 text-muted-foreground" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected
? translate(
'auto.components.sidebar.WorktreeCard.021538e1d1',
'SSH disconnected'
)
: translate(
'auto.components.sidebar.WorktreeCard.ca74db7550',
'Project on SSH host'
)}
</TooltipContent>
</Tooltip>
<WorktreeCardSshHostControl
targetId={repo.connectionId}
targetLabel={sshTargetLabel || repo.displayName}
status={sshStatus}
targetRemoved={sshTargetRemoved}
sshOwnerEnvironmentId={sshOwnerEnvironmentId}
iconOnly={compactCards || newCardStyle}
onPointerDown={stopQuickActionPointerPropagation}
/>
)}
{!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && (
@@ -1457,7 +1440,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center">
{isRuntimeDisconnected ? (
<ServerOff className="size-3 text-red-400" />
// Passive by design: runtime ("Orca server") hosts have no
// renderer-reachable connect API, unlike the SSH glyph above which is
// now a control. Don't "fix" the inconsistency by wiring one up.
<ServerOff className="size-3 text-destructive" />
) : (
<Server className="size-3 text-muted-foreground" />
)}
@@ -1940,16 +1926,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
</WorktreeContextMenu>
)}
{repo?.connectionId && (
<SshDisconnectedDialog
open={showDisconnectedDialog && isSshDisconnected}
onOpenChange={setShowDisconnectedDialog}
targetId={repo.connectionId}
targetLabel={sshTargetLabel || repo.displayName}
status={sshStatus ?? 'disconnected'}
/>
)}
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 && (
<AutoRenameFailedDialog
@@ -0,0 +1,305 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl'
import { useAppStore } from '@/store'
import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight'
import type { SshConnectionState } from '../../../../shared/ssh-types'
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }))
const environmentSshMocks = vi.hoisted(() => ({
connectRuntimeEnvironmentSshTarget: vi.fn(),
resyncRuntimeEnvironmentSshTargets: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: toastMocks.error } }))
vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks)
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace('{{value0}}', values?.value0 ?? '')
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-tooltip="">{children}</span>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
function installSshApi(
connect: ReturnType<typeof vi.fn>,
overrides: Record<string, ReturnType<typeof vi.fn>> = {}
): void {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
connect,
listTargets: vi.fn().mockResolvedValue([]),
listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
...overrides
}
}
})
}
function renderControl(
props: Partial<React.ComponentProps<typeof WorktreeCardSshHostControl>> = {}
) {
return render(
<WorktreeCardSshHostControl
targetId="ssh-target-1"
targetLabel="devbox"
status="disconnected"
targetRemoved={false}
sshOwnerEnvironmentId={null}
iconOnly={false}
onPointerDown={() => {}}
{...props}
/>
)
}
describe('WorktreeCardSshHostControl', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
resetSshConnectInFlightForTests()
toastMocks.error.mockReset()
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset()
installSshApi(vi.fn().mockResolvedValue(undefined))
})
afterEach(() => {
cleanup()
})
it('offers a Connect control naming the host for a disconnected target', () => {
renderControl()
expect(screen.getByRole('button', { name: 'Connect to SSH host devbox' })).toBeEnabled()
})
// Why: the verb must match the terminal overlay, host-header menu, and status-bar row.
it.each([
['auth-failed', 'Reconnect'],
['error', 'Retry'],
['reconnection-failed', 'Retry'],
['disconnected', 'Connect']
] as const)('labels the %s state %s', (status, verb) => {
renderControl({ status })
expect(screen.getByRole('button')).toHaveTextContent(verb)
})
it('distinguishes an auth failure from a generic connection failure in the tooltip', () => {
const { container } = renderControl({ status: 'auth-failed' })
expect(container.querySelector('[data-tooltip]')).toHaveTextContent(
'devbox · authentication failed'
)
cleanup()
const retry = renderControl({ status: 'error' })
expect(retry.container.querySelector('[data-tooltip]')).toHaveTextContent(
'devbox · connection failed'
)
})
it.each(['error', 'reconnection-failed', 'auth-failed'] as const)(
'tints the %s state with the destructive token, not the quiet one',
(status) => {
renderControl({ status })
expect(screen.getByRole('button')).toHaveClass('text-destructive')
}
)
it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)(
'shows a disabled busy control while the host is %s',
(status) => {
renderControl({ status })
const button = screen.getByRole('button', { name: 'Connecting to SSH host devbox' })
expect(button).toBeDisabled()
expect(button).toHaveAttribute('aria-busy', 'true')
}
)
it('renders the passive host glyph, not a control, when connected', () => {
renderControl({ status: 'connected' })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument()
})
// Why: runtime-owned targets deliberately have no renderer-visible status; the card has
// always shown the plain host glyph there rather than a false disconnected state.
it('renders the passive host glyph for a null status', () => {
renderControl({ status: null })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument()
})
// Why: this is the exact bug targetRemoved exists to prevent — a Connect that can only fail.
it('never offers Connect for a removed host, even in a failed state', () => {
renderControl({ status: 'error', targetRemoved: true })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText('SSH host devbox was removed')).toBeInTheDocument()
})
it('keeps the label available to assistive tech but not on screen in icon-only mode', () => {
renderControl({ iconOnly: true })
const button = screen.getByRole('button', { name: 'Connect to SSH host devbox' })
expect(button.querySelector('.sr-only')).toHaveTextContent('Connect')
expect(button).toHaveClass('w-4')
})
it('connects and mirrors the returned state so deferred PTY reattach can resume', async () => {
const connectedState: SshConnectionState = {
targetId: 'ssh-target-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'linux'
}
const connect = vi.fn().mockResolvedValue(connectedState)
installSshApi(connect)
const user = userEvent.setup()
renderControl()
await user.click(screen.getByRole('button'))
expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' })
await waitFor(() =>
expect(useAppStore.getState().sshConnectionStates.get('ssh-target-1')).toEqual(connectedState)
)
})
// Why: reconnecting a host and navigating to its workspace are separate intents; the
// control sits inside the card's own click target.
it('does not activate the surrounding card when clicked', async () => {
const onCardClick = vi.fn()
const user = userEvent.setup()
render(
<div onClick={onCardClick}>
<WorktreeCardSshHostControl
targetId="ssh-target-1"
targetLabel="devbox"
status="disconnected"
targetRemoved={false}
sshOwnerEnvironmentId={null}
iconOnly={false}
onPointerDown={() => {}}
/>
</div>
)
await user.click(screen.getByRole('button'))
expect(onCardClick).not.toHaveBeenCalled()
})
it('reports connect failures and resyncs target metadata so a ghost host converges', async () => {
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-target-1" not found'))
const listTargets = vi
.fn()
.mockResolvedValue([
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
])
const listRemovedTargetLabels = vi
.fn()
.mockResolvedValue({ 'ssh-target-1': 'devbox (removed)' })
installSshApi(connect, { listTargets, listRemovedTargetLabels })
const user = userEvent.setup()
renderControl()
await user.click(screen.getByRole('button'))
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-target-1" not found')
)
await waitFor(() => {
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
expect(useAppStore.getState().removedSshTargetLabels.get('ssh-target-1')).toBe(
'devbox (removed)'
)
})
})
it('routes connect to the owning Orca server for a remote-owned target', async () => {
const connect = vi.fn().mockResolvedValue(undefined)
installSshApi(connect)
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null)
const user = userEvent.setup()
renderControl({ sshOwnerEnvironmentId: 'env-1' })
await user.click(screen.getByRole('button'))
await waitFor(() =>
expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith(
'env-1',
'ssh-target-1'
)
)
// The local ssh API must never see a remote host's target.
expect(connect).not.toHaveBeenCalled()
})
// Why: N cards can share one host, and a passphrase-gated target would prompt N times.
it('suppresses a sibling card dialing a host that is already connecting', async () => {
const connect = vi.fn().mockReturnValue(new Promise(() => {}))
installSshApi(connect)
const user = userEvent.setup()
render(
<>
<WorktreeCardSshHostControl
targetId="ssh-target-1"
targetLabel="devbox"
status="disconnected"
targetRemoved={false}
sshOwnerEnvironmentId={null}
iconOnly={false}
onPointerDown={() => {}}
/>
<WorktreeCardSshHostControl
targetId="ssh-target-1"
targetLabel="devbox"
status="disconnected"
targetRemoved={false}
sshOwnerEnvironmentId={null}
iconOnly={false}
onPointerDown={() => {}}
/>
</>
)
const [first, second] = screen.getAllByRole('button')
await user.click(first)
await waitFor(() => expect(second).toBeDisabled())
expect(connect).toHaveBeenCalledTimes(1)
})
it('keeps the pill 16px tall in every state so the title row never shifts', () => {
const heights = new Set<string>()
for (const status of ['disconnected', 'error', 'connecting'] as const) {
cleanup()
renderControl({ status })
const classes = screen.getByRole('button').className
heights.add(classes.split(' ').find((token) => token.startsWith('h-')) ?? 'none')
}
expect([...heights]).toEqual(['h-4'])
})
})
@@ -0,0 +1,273 @@
import { useCallback } from 'react'
import { Loader2, Server, ServerOff } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import {
connectRuntimeEnvironmentSshTarget,
resyncRuntimeEnvironmentSshTargets
} from '@/runtime/runtime-environment-ssh-state'
import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb'
import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
import {
beginSshConnect,
endSshConnect,
isSshConnectInFlight,
useSshConnectInFlight
} from '@/ssh/ssh-connect-in-flight'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
type WorktreeCardSshHostControlProps = {
targetId: string
/** Card passes `sshTargetLabel || repo.displayName` — the selector can return a bare target id. */
targetLabel: string
/** Null for runtime-owned targets: renders the passive connected glyph, as before. */
status: SshConnectionStatus | null
targetRemoved: boolean
/** Non-null when the SSH target belongs to a remote Orca server; routes connect to that runtime. */
sshOwnerEnvironmentId: string | null
/** True when the row cannot afford a visible label: icon-only with an sr-only label. */
iconOnly: boolean
onPointerDown: React.PointerEventHandler<HTMLButtonElement>
}
// One shape for every state, from the sibling rename-failed control in the same title row
// (WorktreeCard.tsx). States differ only by color token, so the pill never changes height.
const PILL_BASE =
'h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none has-[>svg]:!px-0.5'
const PILL_QUIET =
'text-muted-foreground border border-worktree-sidebar-border bg-worktree-sidebar shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring'
const PILL_FAILED =
'text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring'
function PassiveGlyph({
icon,
tooltip,
accessibleName,
targetLabel
}: {
icon: React.ReactNode
tooltip: string
accessibleName: string
targetLabel: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center" data-ssh-target-label={targetLabel}>
{icon}
<span className="sr-only">{accessibleName}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export function WorktreeCardSshHostControl({
targetId,
targetLabel,
status,
targetRemoved,
sshOwnerEnvironmentId,
iconOnly,
onPointerDown
}: WorktreeCardSshHostControlProps): React.JSX.Element | null {
const setSshConnectionState = useAppStore((store) => store.setSshConnectionState)
// Why: shared registry, not local state — the terminal overlay and every other card on
// this host dial the same connection, and the store status lags a click by one IPC hop.
const inFlight = useSshConnectInFlight(targetId)
const handleConnect = useCallback(async () => {
if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
return
}
beginSshConnect(targetId)
try {
if (sshOwnerEnvironmentId) {
// Bucket state is written inside the helper, mirroring the local path.
await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
} else {
const connectState = await withUiConnectTimeout(window.api.ssh.connect({ targetId }))
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands;
// the waiting deferred PTY reattach path keys off this renderer store.
setSshConnectionState(targetId, connectState)
}
}
} catch (err) {
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
'SSH connection failed'
)
)
// Why: a failed connect usually means the renderer's target metadata is stale
// (target removed, or re-added under a new id). Resync so the control converges to
// the removed state instead of offering the same failing Connect forever (STA-1468).
// Apply the target list first — a removed-labels failure must not discard it.
if (sshOwnerEnvironmentId) {
void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {})
} else {
void (async () => {
const targets = await window.api.ssh.listTargets()
useAppStore.getState().setSshTargetsMetadata(targets)
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
})().catch(() => {})
}
} finally {
// Registry state outlives this component; sidebar rows unmount under virtualization.
endSshConnect(targetId)
}
}, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
// Why: a removed host can never connect, so it is checked before any status — offering
// Connect there is the exact bug targetRemoved exists to prevent. It also drops the
// destructive tint: a removed host is a settled fact, not an error to act on.
if (targetRemoved) {
return (
<PassiveGlyph
targetLabel={targetLabel}
icon={<ServerOff className="size-3 text-muted-foreground" />}
tooltip={translate(
'auto.components.sidebar.WorktreeCardSshHostControl.removedTooltip',
'SSH host removed — reconnect unavailable'
)}
accessibleName={translate(
'auto.components.sidebar.WorktreeCardSshHostControl.removedName',
'SSH host {{value0}} was removed',
{ value0: targetLabel }
)}
/>
)
}
// A null status is a runtime-owned target: no renderer-reachable connect, and the card
// has always shown the plain host glyph here rather than a disconnected state.
if (status === null || status === 'connected') {
return (
<PassiveGlyph
targetLabel={targetLabel}
icon={<Server className="size-3 text-muted-foreground" />}
tooltip={translate(
'auto.components.sidebar.WorktreeCardSshHostControl.connectedTooltip',
'Project on SSH host'
)}
accessibleName={translate(
'auto.components.sidebar.WorktreeCardSshHostControl.connectedName',
'Project on SSH host {{value0}}',
{ value0: targetLabel }
)}
/>
)
}
const connecting = inFlight || isConnectingSshStatus(status)
const canConnect = canConnectSshStatus(status)
if (!connecting && !canConnect) {
// Defensive: every remaining member is either connecting or recoverable, but never
// render a dead button if the union grows.
return null
}
const failed = status === 'error' || status === 'reconnection-failed' || status === 'auth-failed'
const label = connecting ? sshConnectingLabel() : sshConnectVerb(status)
const accessibleName = connecting
? translate(
'auto.components.sidebar.WorktreeCardSshHostControl.connectingName',
'Connecting to SSH host {{value0}}',
{ value0: targetLabel }
)
: status === 'auth-failed'
? translate(
'auto.components.sidebar.WorktreeCardSshHostControl.authFailedName',
'Reconnect SSH host {{value0}} — authentication failed',
{ value0: targetLabel }
)
: failed
? translate(
'auto.components.sidebar.WorktreeCardSshHostControl.retryName',
'Retry SSH connection to {{value0}}',
{ value0: targetLabel }
)
: translate(
'auto.components.sidebar.WorktreeCardSshHostControl.connectName',
'Connect to SSH host {{value0}}',
{ value0: targetLabel }
)
const tooltip = connecting
? accessibleName
: status === 'auth-failed'
? translate(
'auto.components.sidebar.WorktreeCardSshHostControl.authFailedTooltip',
'{{value0}} · authentication failed',
{ value0: targetLabel }
)
: failed
? translate(
'auto.components.sidebar.WorktreeCardSshHostControl.failedTooltip',
'{{value0}} · connection failed',
{ value0: targetLabel }
)
: accessibleName
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
// Why: icon-xs defaults to size-6 (24px); the pill is 16px, and the densest
// card mode must not become the tallest.
className={cn(
PILL_BASE,
failed ? PILL_FAILED : PILL_QUIET,
iconOnly && 'w-4 justify-center !px-0 has-[>svg]:!px-0'
)}
aria-label={accessibleName}
data-ssh-target-label={targetLabel}
aria-busy={connecting || undefined}
disabled={connecting}
onPointerDown={onPointerDown}
onClick={(event) => {
// Reconnecting a host and navigating to a workspace are separate intents.
event.stopPropagation()
event.preventDefault()
void handleConnect()
}}
>
{connecting ? (
<Loader2 className="size-2.5 animate-spin motion-reduce:animate-none" />
) : (
<ServerOff className="size-2.5" />
)}
{iconOnly ? (
<span className="sr-only">{label}</span>
) : (
// Why: reserve the widest verb's width so a remote status transition can't
// jitter the title row mid-connect.
<span className="min-w-[3.5rem] text-left">{label}</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltip}
</TooltipContent>
</Tooltip>
{/* Why: announce transitions only, not every render, or a busy sidebar spams AT. */}
<span className="sr-only" role="status" aria-live="polite">
{accessibleName}
</span>
</>
)
}
@@ -144,10 +144,6 @@ vi.mock('./CacheTimer', () => ({
// NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one.
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -212,26 +212,6 @@ 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),
@@ -142,10 +142,6 @@ vi.mock('./WorktreeCardAgents', () => ({
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -143,10 +143,6 @@ vi.mock('./WorktreeCardAgents', () => ({
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -5,13 +5,11 @@ import { translate } from '@/i18n/i18n'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '../../store'
import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard'
import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability'
import { sshConnectVerb } from '@/ssh/ssh-connect-verb'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh'
function isReconnectable(status: SshConnectionStatus): boolean {
return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
}
function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string | null {
switch (status?.phase) {
case 'pulling':
@@ -132,13 +130,13 @@ export function SshTargetStatusRow({
</div>
{busy ? (
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
) : isReconnectable(status) ? (
) : canConnectSshStatus(status) ? (
<button
type="button"
onClick={() => void handleConnect()}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70"
>
{translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')}
{sshConnectVerb(status)}
</button>
) : status === 'connected' ? (
<button
@@ -7,6 +7,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay'
import { useAppStore } from '@/store'
import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight'
import type { SshConnectionState } from '../../../../shared/ssh-types'
const toastMocks = vi.hoisted(() => ({
@@ -59,6 +60,7 @@ function installSshConnect(
describe('TerminalSshReconnectOverlay', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
resetSshConnectInFlightForTests()
toastMocks.error.mockReset()
deleteFlowMocks.runWorktreeDelete.mockReset()
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
@@ -106,7 +108,7 @@ describe('TerminalSshReconnectOverlay', () => {
)
expect(screen.getByText(/Connecting to devbox/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Connecting.../ })).toBeDisabled()
expect(screen.getByRole('button', { name: /Connecting…/ })).toBeDisabled()
expect(connect).not.toHaveBeenCalled()
})
@@ -123,10 +125,10 @@ describe('TerminalSshReconnectOverlay', () => {
/>
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
await user.click(screen.getByRole('button', { name: 'Reconnect' }))
await waitFor(() => expect(toastMocks.error).toHaveBeenCalledWith('Passphrase rejected'))
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeEnabled()
})
it('resyncs target metadata after a failed connect so a stale overlay converges', async () => {
@@ -262,6 +264,44 @@ describe('TerminalSshReconnectOverlay', () => {
expect(useAppStore.getState().sshTargetsHydrated).toBe(false)
})
// Why: the sidebar card control, the host-header menu, and this overlay can be on screen
// at once; a shared verb table keeps them from naming the same click three ways.
it.each([
['disconnected', 'Connect'],
['auth-failed', 'Reconnect'],
['error', 'Retry'],
['reconnection-failed', 'Retry']
] as const)('labels the %s action %s, matching every other SSH surface', (status, verb) => {
installSshConnect(vi.fn().mockResolvedValue(undefined))
render(
<TerminalSshReconnectOverlay targetId="ssh-target-1" targetLabel="devbox" status={status} />
)
expect(screen.getByRole('button', { name: verb })).toBeEnabled()
})
it('suppresses a second connect while one is already in flight for the same target', async () => {
const connect = vi.fn().mockReturnValue(new Promise(() => {}))
installSshConnect(connect)
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay
targetId="ssh-target-1"
targetLabel="devbox"
status="disconnected"
/>
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
// Why: N surfaces share one connection; a second dial on a passphrase-gated
// target means a second credential prompt.
await waitFor(() => expect(screen.getByRole('button', { name: /Connecting…/ })).toBeDisabled())
expect(connect).toHaveBeenCalledTimes(1)
})
it('publishes the returned SSH state so deferred terminal reattach can resume', async () => {
const connectedState: SshConnectionState = {
targetId: 'ssh-target-1',
@@ -1,8 +1,7 @@
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import { Loader2, Server, ServerOff } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
@@ -11,6 +10,15 @@ import {
connectRuntimeEnvironmentSshTarget,
resyncRuntimeEnvironmentSshTargets
} from '@/runtime/runtime-environment-ssh-state'
import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb'
import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
import {
beginSshConnect,
endSshConnect,
isSshConnectInFlight,
useSshConnectInFlight
} from '@/ssh/ssh-connect-in-flight'
type TerminalSshReconnectOverlayProps = {
targetId: string
@@ -26,16 +34,6 @@ type TerminalSshReconnectOverlayProps = {
sshOwnerEnvironmentId?: string | null
}
// Why: relay deployment/reconnect are host-driven transient states; the
// failure statuses need a user-initiated retry before the PTY can resume.
function isConnectingStatus(status: SshConnectionStatus): boolean {
return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
}
function canConnectStatus(status: SshConnectionStatus): boolean {
return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
}
function messageForStatus(status: SshConnectionStatus, targetLabel: string): string {
switch (status) {
case 'auth-failed':
@@ -81,24 +79,25 @@ export function TerminalSshReconnectOverlay({
worktreeId,
sshOwnerEnvironmentId = null
}: TerminalSshReconnectOverlayProps): React.JSX.Element {
const [connecting, setConnecting] = useState(false)
const mountedRef = useMountedRef()
const setSshConnectionState = useAppStore((store) => store.setSshConnectionState)
const isConnecting = connecting || isConnectingStatus(status)
// Why: shared registry, not local state — the sidebar card control can dial the same
// target, and the store status lags a click by one IPC hop.
const connecting = useSshConnectInFlight(targetId)
const isConnecting = connecting || isConnectingSshStatus(status)
// Why: a removed target can never reconnect, so never offer Connect for it.
const showConnect = !targetRemoved && canConnectStatus(status)
const showConnect = !targetRemoved && canConnectSshStatus(status)
const handleConnect = useCallback(async () => {
if (isConnecting) {
if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
return
}
setConnecting(true)
beginSshConnect(targetId)
try {
if (sshOwnerEnvironmentId) {
// Bucket state is written inside the helper, mirroring the local path.
await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
} else {
const connectState = await window.api.ssh.connect({ targetId })
const connectState = await withUiConnectTimeout(window.api.ssh.connect({ targetId }))
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands;
// the waiting deferred PTY reattach path keys off this renderer store.
@@ -130,11 +129,10 @@ export function TerminalSshReconnectOverlay({
})().catch(() => {})
}
} finally {
if (mountedRef.current) {
setConnecting(false)
}
// Registry state outlives this component, so clear it even after unmount.
endSshConnect(targetId)
}
}, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
}, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
// Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through.
return (
@@ -204,16 +202,10 @@ export function TerminalSshReconnectOverlay({
{!showConnect || isConnecting ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{translate(
'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectingButton',
'Connecting...'
)}
{sshConnectingLabel()}
</>
) : (
translate(
'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectButton',
'Connect'
)
sshConnectVerb(status)
)}
</Button>
)}
@@ -8246,7 +8246,7 @@ export function connectPanePty(
const alreadyConnected =
useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected'
if (!alreadyConnected) {
// Wait for the user-driven connect (SshDisconnectedDialog → passphrase → ssh.connect) to complete.
// Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete.
// Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails.
const outcome = await new Promise<UserInitiatedSshConnectOutcome>((resolve) => {
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
+2 -1
View File
@@ -24,6 +24,7 @@ import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constan
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -2806,7 +2807,7 @@ export function useIpcEvents(): void {
const previous = store.sshConnectionStates?.get(targetId)
store.setSshConnectionState(targetId, state)
if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) {
if (canConnectSshStatus(state.status)) {
reconnectAuthorityByTarget.delete(targetId)
reconnectCoordinator.invalidate(targetId)
// Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay.
+21
View File
@@ -5155,6 +5155,19 @@
"3b7ea51793": "Clear search",
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
"9a4d0f6b21": "Too long"
},
"WorktreeCardSshHostControl": {
"connectFailed": "SSH connection failed",
"removedTooltip": "SSH host removed — reconnect unavailable",
"removedName": "SSH host {{value0}} was removed",
"connectedTooltip": "Project on SSH host",
"connectedName": "Project on SSH host {{value0}}",
"connectingName": "Connecting to SSH host {{value0}}",
"authFailedName": "Reconnect SSH host {{value0}} — authentication failed",
"retryName": "Retry SSH connection to {{value0}}",
"connectName": "Connect to SSH host {{value0}}",
"authFailedTooltip": "{{value0}} · authentication failed",
"failedTooltip": "{{value0}} · connection failed"
}
},
"shared": {
@@ -14599,6 +14612,14 @@
"emptyTrace": "No log is available for this GitLab job.",
"timedOut": "Timed out loading the GitLab job log."
}
},
"ssh": {
"sshConnectVerb": {
"reconnect": "Reconnect",
"retry": "Retry",
"connect": "Connect",
"connecting": "Connecting…"
}
}
},
"components": {
@@ -1,3 +1,4 @@
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
@@ -9,7 +10,7 @@ export type SelectedRepoSshGate = {
}
export function isSshConnectInProgress(status: SshConnectionStatus | null): boolean {
return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
return isConnectingSshStatus(status)
}
export function getSelectedRepoSshGate(input: {
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
beginSshConnect,
endSshConnect,
isSshConnectInFlight,
resetSshConnectInFlightForTests,
subscribeSshConnectInFlight
} from './ssh-connect-in-flight'
describe('ssh connect in-flight registry', () => {
beforeEach(() => {
resetSshConnectInFlightForTests()
})
it('tracks connects per target, so one host dialing does not disable another', () => {
beginSshConnect('ssh-a')
expect(isSshConnectInFlight('ssh-a')).toBe(true)
expect(isSshConnectInFlight('ssh-b')).toBe(false)
})
it('clears the target when the connect settles', () => {
beginSshConnect('ssh-a')
endSshConnect('ssh-a')
expect(isSshConnectInFlight('ssh-a')).toBe(false)
})
it('notifies subscribers on both edges', () => {
const listener = vi.fn()
subscribeSshConnectInFlight(listener)
beginSshConnect('ssh-a')
endSshConnect('ssh-a')
expect(listener).toHaveBeenCalledTimes(2)
})
// Why: every surface renders from one registry entry, so a duplicate begin must not
// emit again — and the paired end must not clear the flag while a connect is still live.
it('ignores a duplicate begin without re-notifying', () => {
const listener = vi.fn()
subscribeSshConnectInFlight(listener)
beginSshConnect('ssh-a')
beginSshConnect('ssh-a')
expect(listener).toHaveBeenCalledTimes(1)
expect(isSshConnectInFlight('ssh-a')).toBe(true)
})
// Why: handleConnect ends in a finally block that can run for a target it never began
// (early return paths), and a spurious notify would re-render every subscribed card.
it('ignores an end for a target that was never in flight', () => {
const listener = vi.fn()
subscribeSshConnectInFlight(listener)
endSshConnect('ssh-a')
expect(listener).not.toHaveBeenCalled()
expect(isSshConnectInFlight('ssh-a')).toBe(false)
})
it('stops notifying after unsubscribe, so unmounted sidebar rows do not leak', () => {
const listener = vi.fn()
const unsubscribe = subscribeSshConnectInFlight(listener)
unsubscribe()
beginSshConnect('ssh-a')
expect(listener).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,51 @@
import { useCallback, useSyncExternalStore } from 'react'
// Why: the store status lags a user click by one IPC hop (main broadcasts 'connecting'
// after ssh.connect starts), and every workspace card on a host shares one connection.
// Component-local state would let two surfaces — or N cards on the same host — each fire
// a connect, which on a passphrase-gated target means N credential prompts.
const inFlightTargetIds = new Set<string>()
const listeners = new Set<() => void>()
function emit(): void {
for (const listener of listeners) {
listener()
}
}
export function subscribeSshConnectInFlight(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function beginSshConnect(targetId: string): void {
if (inFlightTargetIds.has(targetId)) {
return
}
inFlightTargetIds.add(targetId)
emit()
}
export function endSshConnect(targetId: string): void {
if (!inFlightTargetIds.delete(targetId)) {
return
}
emit()
}
export function isSshConnectInFlight(targetId: string): boolean {
return inFlightTargetIds.has(targetId)
}
export function useSshConnectInFlight(targetId: string): boolean {
const getSnapshot = useCallback(() => inFlightTargetIds.has(targetId), [targetId])
return useSyncExternalStore(subscribeSshConnectInFlight, getSnapshot, getSnapshot)
}
/** Test-only: the registry is module state, so specs must reset it between cases. */
export function resetSshConnectInFlightForTests(): void {
inFlightTargetIds.clear()
emit()
}
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SSH_CONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from './ssh-connect-ui-timeout'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
describe('withUiConnectTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('passes a resolved connect state straight through', async () => {
await expect(withUiConnectTimeout(Promise.resolve('connected'))).resolves.toBe('connected')
})
it('propagates the connect rejection rather than the timeout message', async () => {
const failing = withUiConnectTimeout(Promise.reject(new Error('Passphrase rejected')))
await expect(failing).rejects.toThrow('Passphrase rejected')
})
// Why: ssh.connect has no timeout of its own, so a stalled backend would otherwise leave
// the control disabled with a spinner forever.
it('rejects a stalled connect once the UI budget elapses', async () => {
const stalled = withUiConnectTimeout(new Promise(() => {}))
const assertion = expect(stalled).rejects.toThrow(/Connection timed out/)
await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS)
await assertion
})
it('clears the timer when the connect settles first, leaving no pending work', async () => {
await withUiConnectTimeout(Promise.resolve('connected'))
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,31 @@
import { translate } from '@/i18n/i18n'
// Why: ssh.connect has no built-in timeout, so bound how long a UI control waits on it —
// a stalled backend connect must not leave a disabled spinner stuck forever. The backend
// keeps going regardless.
export const SSH_CONNECT_UI_TIMEOUT_MS = 20_000
export async function withUiConnectTimeout<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
// Key kept from the original NewWorkspaceComposerCard home so existing
// translations survive the move.
translate(
'auto.components.NewWorkspaceComposerCard.connectTimedOut',
'Connection timed out. It may still be connecting in the background.'
)
)
)
}, SSH_CONNECT_UI_TIMEOUT_MS)
})
try {
return await Promise.race([promise, timeout])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { translate } from '@/i18n/i18n'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
// Why: the sidebar card, the terminal overlay, the host-header menu, and the status-bar row
// can all be on screen at once. One vocabulary here stops them describing the same click
// three different ways.
export function sshConnectVerb(status: SshConnectionStatus | null | undefined): string {
switch (status) {
case 'auth-failed':
return translate('auto.ssh.sshConnectVerb.reconnect', 'Reconnect')
case 'error':
case 'reconnection-failed':
return translate('auto.ssh.sshConnectVerb.retry', 'Retry')
default:
return translate('auto.ssh.sshConnectVerb.connect', 'Connect')
}
}
export function sshConnectingLabel(): string {
return translate('auto.ssh.sshConnectVerb.connecting', 'Connecting…')
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import { canConnectSshStatus, isConnectingSshStatus } from './ssh-connection-recoverability'
// Union growth is caught by the typechecker (the modules use total Records), not here.
// These cases pin the classification itself, which four call sites now depend on.
const ALL_STATUSES: SshConnectionStatus[] = [
'disconnected',
'connecting',
'auth-failed',
'deploying-relay',
'connected',
'reconnecting',
'reconnection-failed',
'error'
]
describe('isConnectingSshStatus', () => {
it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)(
'treats %s as an attempt already under way',
(status) => {
expect(isConnectingSshStatus(status)).toBe(true)
}
)
it.each(['disconnected', 'auth-failed', 'connected', 'reconnection-failed', 'error'] as const)(
'does not treat %s as connecting',
(status) => {
expect(isConnectingSshStatus(status)).toBe(false)
}
)
})
describe('canConnectSshStatus', () => {
it.each(['disconnected', 'auth-failed', 'reconnection-failed', 'error'] as const)(
'offers a user-driven connect for %s',
(status) => {
expect(canConnectSshStatus(status)).toBe(true)
}
)
it.each(['connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)(
'withholds connect for %s',
(status) => {
expect(canConnectSshStatus(status)).toBe(false)
}
)
})
// Why: runtime-owned targets deliberately yield a null status. Both predicates must read
// that as "nothing to offer" so the card falls through to the passive host glyph.
describe('absent status', () => {
it.each([null, undefined])('classifies %s as neither connecting nor connectable', (status) => {
expect(isConnectingSshStatus(status)).toBe(false)
expect(canConnectSshStatus(status)).toBe(false)
})
})
describe('the two predicates together', () => {
it('never claims a status is both connecting and connectable', () => {
for (const status of ALL_STATUSES) {
expect(isConnectingSshStatus(status) && canConnectSshStatus(status)).toBe(false)
}
})
it('leaves only connected outside both sets, so no state renders a dead control', () => {
const unclassified = ALL_STATUSES.filter(
(status) => !isConnectingSshStatus(status) && !canConnectSshStatus(status)
)
expect(unclassified).toEqual(['connected'])
})
})
@@ -0,0 +1,36 @@
import type { SshConnectionStatus } from '../../../shared/ssh-types'
// Why: a total Record makes a new SshConnectionStatus member a typecheck failure. An
// array + .includes() would silently classify it as "not recoverable", leaving its cards
// with a dead glyph and no way to reconnect.
const CONNECTING_BY_STATUS: Record<SshConnectionStatus, boolean> = {
disconnected: false,
connecting: true,
'auth-failed': false,
'deploying-relay': true,
connected: false,
reconnecting: true,
'reconnection-failed': false,
error: false
}
const CAN_CONNECT_BY_STATUS: Record<SshConnectionStatus, boolean> = {
disconnected: true,
connecting: false,
'auth-failed': true,
'deploying-relay': false,
connected: false,
reconnecting: false,
'reconnection-failed': true,
error: true
}
/** Relay deployment and reconnect are host-driven transients: no user action helps yet. */
export function isConnectingSshStatus(status: SshConnectionStatus | null | undefined): boolean {
return status ? CONNECTING_BY_STATUS[status] : false
}
/** Failure states a user-initiated connect can recover from. */
export function canConnectSshStatus(status: SshConnectionStatus | null | undefined): boolean {
return status ? CAN_CONNECT_BY_STATUS[status] : false
}