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]