From d47ffbe46dd3bb05d89826bc33c38794e0a9b1ef Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 26 Oct 2026 08:40:58 -0700
Subject: [PATCH] fix(ssh): say why an SSH host failed in the Add project host
picker (#33992)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Add project host picker showed a failed SSH host as "Needs attention
- SSH" with no reason. Its Connect handler also overwrote the failure
main had just published with the host's earlier state, and toasted
Electron's raw IPC error. The picker row now shows the same reason
Settings → SSH Hosts does. The handler keeps main's published failure
and toasts through the shared SSH connect-failure toast.
Fixes #29648
---
.../sidebar/AddRepoHostSelector.test.tsx | 24 +++++++
.../sidebar/AddRepoHostSelector.tsx | 7 +-
.../sidebar/sidebar-host-options.test.ts | 32 +++++++++
.../sidebar/sidebar-host-options.ts | 7 ++
.../use-add-repo-host-selection.test.ts | 72 ++++++++++++++++++-
.../sidebar/use-add-repo-host-selection.ts | 40 +++++------
6 files changed, 157 insertions(+), 25 deletions(-)
diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx
index 8d89a1016cd..3ce70d005fc 100644
--- a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx
@@ -94,6 +94,30 @@ describe('AddRepoHostSelector', () => {
expect(html).not.toContain('opacity-55')
})
+ it('says why a failed SSH host needs attention (#29648)', () => {
+ const html = renderToStaticMarkup(
+
+ )
+
+ expect(html).toContain('Needs attention - All configured authentication methods failed')
+ })
+
it('shows exact update guidance for incompatible runtime hosts', () => {
const html = renderToStaticMarkup(
{host.label}
- {getHostStatusDetail(host)}
+
+ {getHostStatusDetail(host)}
+
{canConnectHost ? (
diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts
index 5207fd2c987..45128f8f38b 100644
--- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts
+++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts
@@ -75,6 +75,38 @@ describe('sidebar host options', () => {
})
})
+ it('says why an SSH host failed, as Settings does (#29648)', () => {
+ const failed = (state: Pick & Partial) =>
+ buildSidebarHostOptions({
+ repos: [],
+ sshTargetLabels: new Map([['ssh-1', 'Builder']]),
+ sshConnectionStates: new Map([
+ ['ssh-1', { targetId: 'ssh-1', error: null, reconnectAttempt: 0, ...state }]
+ ]),
+ settings: { activeRuntimeEnvironmentId: null }
+ }).find((host) => host.id === 'ssh:ssh-1')
+
+ expect(
+ failed({ status: 'auth-failed', error: 'All configured authentication methods failed' })
+ ).toMatchObject({
+ health: 'error',
+ detail: 'SSH',
+ failureReason: 'All configured authentication methods failed'
+ })
+ expect(
+ failed({
+ status: 'error',
+ error: 'raw',
+ managedServer: {
+ kind: 'relay',
+ reason: 'refused',
+ detail: 'This SSH host cannot move yet: automations (1).'
+ }
+ })?.failureReason
+ ).toContain('automations (1)')
+ expect(failed({ status: 'connected' })).not.toHaveProperty('failureReason')
+ })
+
it('includes the focused runtime compatibility host', () => {
const hosts = buildSidebarHostOptions({
repos: [],
diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts
index aaa371115c9..8c6f4b022ec 100644
--- a/src/renderer/src/components/sidebar/sidebar-host-options.ts
+++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts
@@ -26,6 +26,7 @@ import type {
} from '../../../../shared/runtime-host-status'
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
import { translate } from '@/i18n/i18n'
+import { sshConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
import {
getWslHostRowDetail,
getWslHostRowHealth,
@@ -40,6 +41,8 @@ export type SidebarHostOption = {
kind: 'local' | 'ssh' | 'runtime'
health: ExecutionHostHealth
presence: 'local' | 'configured' | 'project' | 'active'
+ /** Why a failed SSH host failed, as Settings → SSH Hosts says it; host pickers show it. */
+ failureReason?: string
// Host menus need the compatibility verdict to explain version skew.
compatibility?: RuntimeCompatVerdict
blockedReason?: RuntimeHostBlockedReason
@@ -115,9 +118,13 @@ export function buildSidebarHostOptions(args: {
}
if (host.kind === 'ssh') {
const targetId = decodeURIComponent(host.id.slice('ssh:'.length))
+ const state = args.sshConnectionStates?.get(targetId)
// Disconnected hosts remain listed when configured or still referenced by projects.
return {
...host,
+ ...(host.health === 'error' && state
+ ? { failureReason: sshConnectFailureText(state, state.error?.trim() || host.detail) }
+ : {}),
presence: configuredSshTargetIds.has(targetId)
? 'configured'
: projectSshTargetIds.has(targetId)
diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
index b5d9f824885..b027af2a710 100644
--- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
+++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
@@ -52,7 +52,10 @@ vi.mock('react', async (importOriginal) => {
})
vi.mock('@/store', () => ({
- useAppStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState)
+ useAppStore: Object.assign(
+ (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
+ { getState: () => mocks.storeState }
+ )
}))
vi.mock('sonner', () => ({
@@ -106,6 +109,7 @@ describe('useAddRepoHostSelection', () => {
mocks.storeState.setActiveRuntimeEnvironmentPreference.mockResolvedValue(true)
mocks.storeState.sshConnectionStates = new Map()
mocks.storeState.runtimeEnvironments = []
+ mocks.storeState.setSshConnectionState.mockReset()
mocks.sshConnect.mockReset()
mocks.sshGetState.mockReset()
mocks.isWebClient = false
@@ -274,6 +278,72 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
+ it('keeps the failure main published for the host, and toasts its reason (#29648)', async () => {
+ mocks.stateValues = ['local', true]
+ mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' }
+ const published = {
+ targetId: 'ssh-1',
+ status: 'auth-failed' as const,
+ error: 'All configured authentication methods failed',
+ reconnectAttempt: 0
+ }
+ mocks.sshConnect.mockImplementation(async () => {
+ mocks.storeState.sshConnectionStates = new Map([['ssh-1', published]])
+ throw new Error(
+ "Error invoking remote method 'ssh:connect': Error: All configured authentication methods failed"
+ )
+ })
+ const { toast } = await import('sonner')
+ const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
+
+ await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost(
+ 'ssh:ssh-1'
+ )
+
+ expect(mocks.storeState.setSshConnectionState).toHaveBeenCalledTimes(1)
+ expect(toast.error).toHaveBeenCalledWith('All configured authentication methods failed')
+ })
+
+ it('marks the host failed with the error when connect rejects before main publishes', async () => {
+ mocks.stateValues = ['local', true]
+ mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' }
+ mocks.storeState.sshConnectionStates = new Map([
+ [
+ 'ssh-1',
+ {
+ targetId: 'ssh-1',
+ status: 'error',
+ error: 'old',
+ reconnectAttempt: 0,
+ managedServer: { kind: 'relay', reason: 'refused', detail: 'an earlier refusal' }
+ }
+ ]
+ ])
+ mocks.storeState.setSshConnectionState.mockImplementation((id: string, state: unknown) => {
+ mocks.storeState.sshConnectionStates = new Map([[id, state]])
+ })
+ mocks.sshConnect.mockRejectedValue(
+ new Error(
+ "Error invoking remote method 'ssh:connect': Error: connect ECONNREFUSED 10.0.0.1:22"
+ )
+ )
+ const { toast } = await import('sonner')
+ const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
+
+ await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost(
+ 'ssh:ssh-1'
+ )
+
+ expect(mocks.storeState.sshConnectionStates.get('ssh-1')).toEqual({
+ targetId: 'ssh-1',
+ status: 'error',
+ error: 'connect ECONNREFUSED 10.0.0.1:22',
+ reconnectAttempt: 0,
+ remotePlatform: undefined
+ })
+ expect(toast.error).toHaveBeenCalledWith('connect ECONNREFUSED 10.0.0.1:22')
+ })
+
it('does not auto-select the active runtime host while it is unavailable', async () => {
mocks.stateValues = ['local', false]
mocks.hostOptions[2] = {
diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
index 7d665eb50d6..7c47261a4ca 100644
--- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
+++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
@@ -17,6 +17,8 @@ import {
pickerExecutionHosts
} from '../../../../shared/managed-orcad-execution-host'
import { translate } from '@/i18n/i18n'
+import { extractIpcErrorMessage } from '@/lib/ipc-error'
+import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
import { isWebClientLocation } from '@/lib/web-client-location'
export function useAddRepoHostSelection({
@@ -197,29 +199,23 @@ export function useAddRepoHostSelection({
setStep('add')
setHostSelectorOpen(false)
} catch (err) {
- setSshConnectionState(
- parsed.targetId,
- previousState ?? {
+ const fallback = translate(
+ 'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
+ 'SSH connection failed.'
+ )
+ // Main publishes the failure and its reason; only replace our own optimistic row.
+ if (
+ useAppStore.getState().sshConnectionStates.get(parsed.targetId)?.status === 'connecting'
+ ) {
+ setSshConnectionState(parsed.targetId, {
targetId: parsed.targetId,
- status: 'disconnected',
- error:
- err instanceof Error
- ? err.message
- : translate(
- 'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
- 'SSH connection failed.'
- ),
- reconnectAttempt: 0
- }
- )
- toast.error(
- err instanceof Error
- ? err.message
- : translate(
- 'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
- 'SSH connection failed.'
- )
- )
+ status: 'error',
+ error: extractIpcErrorMessage(err, fallback),
+ reconnectAttempt: previousState?.reconnectAttempt ?? 0,
+ remotePlatform: previousState?.remotePlatform
+ })
+ }
+ toastSshConnectFailure(parsed.targetId, err, fallback)
}
},
[selectableHostOptions, setSshConnectionState, setStep, sshConnectionStates]