fix(ssh): make Move anyway the next step when saved items refuse a host's move (#34041)

* fix(ssh): show Move anyway on a host card whose move saved state refuses

A host whose move to its Orca server was refused over saved state (an
enabled SSH automation and its runs, stale workspace details, unsupported
saved tabs) stayed in error on every Connect. Move anyway, which connects
it and leaves those items on this computer, was only in the card's
overflow menu, so the host looked stuck. The card now offers Move anyway
next to its refusal, as it already does for Recover server.

Refs #29083.

* fix(ssh): make Move anyway the next step when saved items refuse a host's move

When a host's move to its Orca server is refused only over saved items
that Move anyway can leave on this computer (#29083's enabled
automation and its runs, for example), every surface now leads with it:
- the host card says which items can't move and that Move anyway
  connects the host and keeps them here, with Move anyway as its
  primary button;
- the refusal toast says the same and offers Move anyway, opening SSH
  Hosts where the move is confirmed;
- the terminal pane's Move anyway is primary too;
- the host picker and connect toast read the same line.

Refs #29083.
This commit is contained in:
Neil
2026-10-26 13:01:30 -07:00
committed by GitHub
parent faaa59049f
commit ec42b433ca
17 changed files with 245 additions and 45 deletions
@@ -128,7 +128,7 @@ export function SshHostMoveAnywayButton({
<Button
type="button"
size="xs"
variant="outline"
variant="default"
disabled={disabled}
onClick={() => setOpen(true)}
>
@@ -58,7 +58,7 @@ export function SshTargetServerStatusLine({
<Button
type="button"
size="xs"
variant="outline"
variant="default"
// A connect in flight may clear the refusal; a click would start a second one.
disabled={state ? isSshTargetConnecting(state.status) : false}
onClick={() => onOpenDialog('move-anyway')}
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const toastError = vi.hoisted(() => vi.fn())
const toastWarning = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastError, warning: toastWarning } }))
const storeState = vi.hoisted(() => ({ sshConnectionStates: new Map<string, unknown>() }))
vi.mock('../../store', () => ({ useAppStore: { getState: () => storeState } }))
const { toastSshHostMoveRefused } = await import('./ssh-managed-server-refusal-toast')
const { toastSshConnectFailure } = await import('@/ssh/ssh-connect-failure-toast')
const refused = {
kind: 'relay' as const,
reason: 'refused' as const,
canLeaveBehind: true as const,
detail: 'This SSH host cannot move yet: automations (1), automation runs (4).'
}
afterEach(() => {
toastError.mockClear()
toastWarning.mockClear()
vi.unstubAllGlobals()
})
describe('toastSshHostMoveRefused', () => {
it('offers Move anyway and says what stays behind (#29083)', () => {
toastSshHostMoveRefused('ssh-1', refused)
expect(toastWarning).toHaveBeenCalledWith(
expect.stringContaining(
'automations (1), automation runs (4). Move anyway connects the host'
),
{
id: 'ssh-host-move-refused:ssh-1',
duration: Number.POSITIVE_INFINITY,
action: expect.objectContaining({ label: 'Move anyway…' })
}
)
})
it('offers no Move anyway on a web client, which can’t run it', () => {
vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true })
toastSshHostMoveRefused('ssh-1', refused)
expect(toastError).toHaveBeenCalledWith(
expect.stringContaining('SSH Hosts in Settings shows why')
)
})
it('keeps one toast, with Move anyway, when the rejected connect toasts too', () => {
storeState.sshConnectionStates = new Map([['ssh-1', { managedServer: refused }]])
toastSshConnectFailure('ssh-1', new Error('This SSH host cannot move yet'), 'Connection failed')
expect(toastWarning).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ id: 'ssh-host-move-refused:ssh-1' })
)
})
})
@@ -0,0 +1,52 @@
import { toast } from 'sonner'
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { leaveBehindLine, REFUSAL_INLINE_CHARS } from '@/ssh/ssh-host-refused-move-copy'
import { useAppStore } from '../../store'
/** A refused move; when Move anyway clears it, say what stays behind and lead to the card that asks. */
export function toastSshHostMoveRefused(
targetId: string,
status: Extract<SshManagedServerStatus, { kind: 'relay' }>
): void {
// Web clients can't run this desktop's Move anyway.
if (!canOfferMoveAnywayToast(status)) {
toast.error(
translate(
'auto.hooks.ipcEvents.sshManagedServer.refusedBlocked',
'Orca couldn’t set itself up on this SSH host. SSH Hosts in Settings shows why.'
)
)
return
}
// A warning like the card line, kept until acted on or dismissed: it carries the way out.
toast.warning(leaveBehindLine(status.detail, REFUSAL_INLINE_CHARS).text, {
duration: Number.POSITIVE_INFINITY,
// The rejected connect toasts too; one id keeps a single toast that still offers Move anyway.
id: sshHostMoveRefusedToastId(targetId),
action: {
label: translate('auto.components.settings.moveAnyway.action', 'Move anyway…'),
onClick: () => {
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'ssh', repoId: null })
store.openSettingsPage()
}
}
})
}
export function sshHostMoveRefusedToastId(targetId: string): string {
return `ssh-host-move-refused:${targetId}`
}
export function canOfferMoveAnywayToast(
status: SshManagedServerStatus | undefined
): status is Extract<SshManagedServerStatus, { kind: 'relay' }> {
return (
status?.kind === 'relay' &&
status.reason === 'refused' &&
Boolean(status.canLeaveBehind) &&
!isWebClientLocation()
)
}
@@ -1,4 +1,3 @@
import { toast } from 'sonner'
import type {
SshConnectionState,
SshManagedServerLinkedProject
@@ -13,7 +12,7 @@ import {
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { toastSshHostMoveRefused } from './ssh-managed-server-refusal-toast'
import { useAppStore } from '../../store'
import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows'
import { rekeyWorktreeVisitHost } from '@/lib/worktree-visit-recency'
@@ -54,12 +53,7 @@ export function applySshManagedServerTransition(
previous.detail === next.detail
)
) {
toast.error(
translate(
'auto.hooks.ipcEvents.sshManagedServer.refusedBlocked',
'Orca couldn’t set itself up on this SSH host. SSH Hosts in Settings shows why.'
)
)
toastSshHostMoveRefused(targetId, next)
}
}
+2
View File
@@ -13156,6 +13156,8 @@
"nodeDownloadBlockedMirrorTried": "Neither this computer nor this host could download Node.js, including from the Node.js download mirror. Check the mirror setting, then connect again.",
"reassurance": "Runs on {{host}}, reached over your own SSH connection. Your code and files stay where they are. Only Orca’s list of projects, tabs and terminals moves to {{host}}.",
"refusedBlockedReason": "Orca isn’t set up on this host yet. {{reason}}",
"refusedLeaveBehind": "Some saved items for this host can’t move to its Orca server: {{items}}. Move anyway connects the host and keeps them on this computer.",
"refusedLeaveBehindUnnamed": "Some saved items for this host can’t move to its Orca server. Move anyway connects the host and keeps them on this computer.",
"windowsBuildUnsupported": "This host runs a version of Windows older than Windows 10 and Windows Server 2016, so it can’t run Orca’s server. Use a host with a newer version of Windows, or a Linux or macOS host.",
"windowsNoExtractor": "This Windows host has neither tar.exe nor PowerShell’s Expand-Archive, so Orca can’t unpack its server there. Install Windows PowerShell 5.1 on the host, then connect again."
},
+2
View File
@@ -12852,6 +12852,8 @@
"behindMigrating": "Ejecuta el servidor de Orca, que se actualizará cuando termine el traslado de sus proyectos.",
"behindCheckFailed": "Ejecuta el servidor de Orca; Orca no pudo comprobar si está por detrás de esta app.",
"refusedBlockedReason": "Orca aún no está configurado en este host. {{reason}}",
"refusedLeaveBehind": "Algunos elementos guardados de este host no se pueden mover a su servidor de Orca: {{items}}. «Mover de todos modos» conecta el host y los conserva en este equipo.",
"refusedLeaveBehindUnnamed": "Algunos elementos guardados de este host no se pueden mover a su servidor de Orca. «Mover de todos modos» conecta el host y los conserva en este equipo.",
"runtimeNoexec": "Este host no permite ejecutar programas desde ~/.orca-remote, por lo que Orca no puede iniciar su servidor allí. Esto normalmente significa que la carpeta de inicio está montada con noexec. Pídale al administrador del host que permita la ejecución de programas allí y luego conéctese nuevamente.",
"setupContactLost": "Orca perdió contacto con este host mientras configuraba su servidor. No se detuvo nada en el host. Conéctese nuevamente para continuar."
},
+2
View File
@@ -12996,6 +12996,8 @@
"behindMigrating": "Exécute le serveur Orca, qui se mettra à jour une fois le déplacement de ses projets terminé.",
"behindCheckFailed": "Exécute le serveur Orca ; Orca n’a pas pu vérifier s’il est en retard sur cette app.",
"refusedBlockedReason": "Orca n’est pas encore configuré sur cet hôte. {{reason}}",
"refusedLeaveBehind": "Certains éléments enregistrés pour cet hôte ne peuvent pas être déplacés vers son serveur Orca : {{items}}. « Déplacer quand même » connecte l’hôte et les conserve sur cet ordinateur.",
"refusedLeaveBehindUnnamed": "Certains éléments enregistrés pour cet hôte ne peuvent pas être déplacés vers son serveur Orca. « Déplacer quand même » connecte l’hôte et les conserve sur cet ordinateur.",
"runtimeNoexec": "Cet hôte n'autorise pas l'exécution de programmes à partir de ~/.orca-remote, donc Orca ne peut pas y démarrer son serveur. Cela signifie généralement que le dossier personnel est monté avec noexec. Demandez à l’administrateur de l’hôte d’autoriser l’exécution de programmes là-bas, puis reconnectez-vous.",
"setupContactLost": "Orca a perdu le contact avec cet hôte lors de la configuration de son serveur. Rien sur l'hôte n'a été arrêté. Reconnectez-vous pour continuer."
},
+2
View File
@@ -12836,6 +12836,8 @@
"behindMigrating": "Orca サーバーを実行しています。プロジェクトの移動が完了した後に更新されます。",
"behindCheckFailed": "Orca サーバーを実行しています。Orca はこのアプリより古いかどうかを確認できませんでした。",
"refusedBlockedReason": "このホストにはまだ Orca がセットアップされていません。{{reason}}",
"refusedLeaveBehind": "このホストの保存済み項目の一部は Orca サーバーに移動できません: {{items}}。「それでも移行」を選ぶとホストに接続し、これらはこのコンピューターに残ります。",
"refusedLeaveBehindUnnamed": "このホストの保存済み項目の一部は Orca サーバーに移動できません。「それでも移行」を選ぶとホストに接続し、これらはこのコンピューターに残ります。",
"setupContactLost": "Orca はサーバーのセットアップ中にこのホストとの接続を失いました。ホスト上では何も停止されませんでした。続行するには再度接続してください。",
"runtimeNoexec": "このホストでは ~/.orca-remote からのプログラムの実行が許可されていないため、Orca はそこでサーバーを起動できません。これは通常、ホームフォルダーが noexec でマウントされていることを意味します。ホストの管理者にそこでプログラムの実行を許可するよう依頼してから、再度接続してください。"
},
+2
View File
@@ -12836,6 +12836,8 @@
"behindMigrating": "Orca 서버를 실행합니다. 프로젝트 이동이 끝나면 업데이트됩니다.",
"behindCheckFailed": "Orca 서버를 실행합니다. Orca가 이 앱보다 뒤처졌는지 확인하지 못했습니다.",
"refusedBlockedReason": "아직 이 호스트에 Orca가 설정되지 않았습니다. {{reason}}",
"refusedLeaveBehind": "이 호스트의 일부 저장된 항목은 Orca 서버로 옮길 수 없습니다: {{items}}. '그래도 이동'을 선택하면 호스트에 연결되고 이 항목들은 이 컴퓨터에 남습니다.",
"refusedLeaveBehindUnnamed": "이 호스트의 일부 저장된 항목은 Orca 서버로 옮길 수 없습니다. '그래도 이동'을 선택하면 호스트에 연결되고 이 항목들은 이 컴퓨터에 남습니다.",
"runtimeNoexec": "이 호스트는 ~/.orca-remote에서 프로그램 실행을 허용하지 않아 Orca 서버를 시작할 수 없습니다. 보통 홈 폴더가 noexec로 마운트되어 있을 때 발생합니다. 호스트 관리자에게 이 위치에서 프로그램 실행을 허용하도록 요청한 후 다시 연결하세요.",
"setupContactLost": "서버를 설정하는 동안 Orca와 이 호스트의 연결이 끊겼습니다. 호스트의 어떤 프로세스도 중지되지 않았습니다. 계속하려면 다시 연결하세요."
},
+3 -1
View File
@@ -8617,7 +8617,9 @@
"nodeDownloadBlockedNoMirror": "Nem este computador nem este host conseguiram baixar o Node.js, necessário para o servidor Orca. Verifique a rede ou o proxy nas duas máquinas e conecte novamente.",
"nodeDownloadBlockedMirrorTried": "Nem este computador nem este host conseguiram baixar o Node.js, nem mesmo pelo espelho de downloads do Node.js. Verifique a configuração do espelho e conecte novamente.",
"reassurance": "É executado em {{host}}, acessado pela sua própria conexão SSH. Seu código e seus arquivos permanecem onde estão. Apenas a lista de projetos, abas e terminais do Orca é movida para {{host}}.",
"refusedBlockedReason": "O Orca ainda não está configurado neste host. {{reason}}"
"refusedBlockedReason": "O Orca ainda não está configurado neste host. {{reason}}",
"refusedLeaveBehind": "Alguns itens salvos deste host não podem ser movidos para o servidor do Orca: {{items}}. “Mover mesmo assim” conecta o host e os mantém neste computador.",
"refusedLeaveBehindUnnamed": "Alguns itens salvos deste host não podem ser movidos para o servidor do Orca. “Mover mesmo assim” conecta o host e os mantém neste computador."
},
"ZcodeCliSetupSection": {
"title": "Usar GLM com ZCode",
+2
View File
@@ -12850,6 +12850,8 @@
"behindMigrating": "运行 Orca 服务器,它会在项目迁移完成后更新。",
"behindCheckFailed": "运行 Orca 服务器;Orca 无法检查它是否落后于此应用。",
"refusedBlockedReason": "此主机尚未设置 Orca。{{reason}}",
"refusedLeaveBehind": "此主机的部分已保存项目无法移到其 Orca 服务器:{{items}}。“仍要迁移”会连接此主机,并将这些项目保留在这台电脑上。",
"refusedLeaveBehindUnnamed": "此主机的部分已保存项目无法移到其 Orca 服务器。“仍要迁移”会连接此主机,并将这些项目保留在这台电脑上。",
"runtimeNoexec": "此主机不允许执行 ~/.orca-remote 中的程序,因此 Orca 无法在那里启动服务器。这通常是因为主目录以 noexec 选项挂载。请联系主机管理员,允许在该目录中执行程序,然后重新连接。",
"setupContactLost": "Orca 在设置服务器时失去了与此主机的联系。主机上没有任何进程被停止。请重新连接以继续。"
},
@@ -1,5 +1,9 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import {
canOfferMoveAnywayToast,
toastSshHostMoveRefused
} from '@/hooks/ipc-events/ssh-managed-server-refusal-toast'
import { sshTargetConnectFailureText } from './ssh-host-server-status-copy'
import { nodeMirrorRecoveryLabel } from './ssh-host-node-download-copy'
import { runNodeMirrorRecovery, thisComputerNodeMirrorRecovery } from './node-dist-mirror-recovery'
@@ -11,11 +15,13 @@ export function toastSshConnectFailure(
fallback: string,
ownerEnvironmentId: string | null = null
): void {
const status = useAppStore.getState().sshConnectionStates.get(targetId)?.managedServer
if (!ownerEnvironmentId && canOfferMoveAnywayToast(status)) {
toastSshHostMoveRefused(targetId, status)
return
}
const text = sshTargetConnectFailureText(targetId, error, fallback)
const recovery = thisComputerNodeMirrorRecovery(
useAppStore.getState().sshConnectionStates.get(targetId)?.managedServer,
ownerEnvironmentId
)
const recovery = thisComputerNodeMirrorRecovery(status, ownerEnvironmentId)
if (!recovery) {
toast.error(text)
return
@@ -0,0 +1,59 @@
import { translate } from '@/i18n/i18n'
import type { SshHostServerStatusLine } from './ssh-host-server-status-copy'
// Why a cap: a refusal can list every blocking tab; the whole text stays behind "Details".
export const REFUSAL_INLINE_CHARS = 280
// Main's refusal reason (orcad-migration-refusal-reason.ts) lists the blocking items after this lead.
const REFUSAL_LEAD = /^This SSH host cannot move yet:\s*/
/** A refusal Move anyway clears: say what stays on this computer, so moving is the obvious next step. */
export function leaveBehindLine(
detail: string | undefined,
inlineChars: number
): SshHostServerStatusLine {
const reason = detail?.trim() ?? ''
const items = reason.replace(REFUSAL_LEAD, '').replace(/\.$/, '')
const inline = items.length > inlineChars ? `${items.slice(0, inlineChars).trimEnd()}…` : items
return {
tone: 'warning',
text: inline
? translate(
'auto.components.settings.sshHostServer.refusedLeaveBehind',
'Some saved items for this host can’t move to its Orca server: {{items}}. Move anyway connects the host and keeps them on this computer.',
{ items: inline }
)
: translate(
'auto.components.settings.sshHostServer.refusedLeaveBehindUnnamed',
'Some saved items for this host can’t move to its Orca server. Move anyway connects the host and keeps them on this computer.'
),
...(reason ? { detail: reason } : {})
}
}
/** Names what refuses the move; main writes that reason, with the way out, in English. */
export function refusedLine(
detail: string | undefined,
inlineChars: number
): SshHostServerStatusLine {
const reason = detail?.trim()
if (!reason) {
return {
tone: 'destructive',
text: translate(
'auto.components.settings.sshHostServer.refusedBlocked',
'Orca isn’t set up on this host yet: something on the host stops the move.'
)
}
}
const inline = reason.length > inlineChars ? `${reason.slice(0, inlineChars).trimEnd()}…` : reason
return {
tone: 'destructive',
text: translate(
'auto.components.settings.sshHostServer.refusedBlockedReason',
'Orca isn’t set up on this host yet. {{reason}}',
{ reason: inline }
),
detail: reason
}
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SshManagedServerUpdateNote } from '../../../shared/ssh-types'
import { sshConnectFailureText, sshHostServerStatusLine } from './ssh-host-server-status-copy'
@@ -339,6 +339,42 @@ describe('SSH host server status line', () => {
expect(sshConnectFailureText(state, 'raw')).toContain('then connect again.')
})
it('says what stays on this computer when Move anyway clears the refusal (#29083)', () => {
const state = {
managedServer: {
kind: 'relay' as const,
reason: 'refused' as const,
canLeaveBehind: true as const,
detail: 'This SSH host cannot move yet: automations (1), automation runs (4).'
}
}
const line = sshHostServerStatusLine(plain, state)
expect(line).toMatchObject({ tone: 'warning' })
expect(line?.text).toBe(
'Some saved items for this host can’t move to its Orca server: automations (1), automation runs (4). Move anyway connects the host and keeps them on this computer.'
)
expect(sshConnectFailureText(state, 'raw')).toBe(line?.text)
})
it('keeps main’s reason on a web client, which can’t move the host', () => {
vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true })
try {
const line = sshHostServerStatusLine(plain, {
managedServer: {
kind: 'relay',
reason: 'refused',
canLeaveBehind: true,
detail: 'This SSH host cannot move yet: automations (1).'
}
})
expect(line?.text).toBe(
'Orca isn’t set up on this host yet. This SSH host cannot move yet: automations (1).'
)
} finally {
vi.unstubAllGlobals()
}
})
it('keeps the plain refusal line when main named no reason', () => {
expect(
sshHostServerStatusLine(plain, { managedServer: { kind: 'relay', reason: 'refused' } })?.text
@@ -7,12 +7,14 @@ import type {
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { compactIpcErrorMessage, extractIpcErrorMessage } from '@/lib/ipc-error'
import { isWebClientLocation } from '@/lib/web-client-location'
import { launchSessionEndedLine } from './ssh-host-launch-session-copy'
import { bridgedTerminalsLine } from './ssh-host-bridged-terminals-copy'
import { managedUpdateLine, withDetail } from './ssh-host-server-update-copy'
import { blockedNodeDownload, nodeDownloadBlockedText } from './ssh-host-node-download-copy'
import { settingUpLabel, sshHostServerReassurance } from './ssh-host-server-setup-copy'
import { hostFixableUnavailableText } from './ssh-host-fixable-unavailable-copy'
import { leaveBehindLine, REFUSAL_INLINE_CHARS, refusedLine } from './ssh-host-refused-move-copy'
export type SshHostServerStatusLine = {
text: string
@@ -243,33 +245,6 @@ function linkedProjectsLine(
}
}
// Why a cap: a refusal can list every blocking tab; the whole text stays behind "Details".
const REFUSAL_INLINE_CHARS = 280
/** Names what refuses the move; main writes that reason, with the way out, in English. */
function refusedLine(detail: string | undefined, inlineChars: number): SshHostServerStatusLine {
const reason = detail?.trim()
if (!reason) {
return {
tone: 'destructive',
text: translate(
'auto.components.settings.sshHostServer.refusedBlocked',
'Orca isn’t set up on this host yet: something on the host stops the move.'
)
}
}
const inline = reason.length > inlineChars ? `${reason.slice(0, inlineChars).trimEnd()}…` : reason
return {
tone: 'destructive',
text: translate(
'auto.components.settings.sshHostServer.refusedBlockedReason',
'Orca isn’t set up on this host yet. {{reason}}',
{ reason: inline }
),
detail: reason
}
}
function unservedLine(
status: Extract<NonNullable<SshConnectionState['managedServer']>, { kind: 'relay' }>,
refusalChars = REFUSAL_INLINE_CHARS
@@ -303,7 +278,10 @@ function unservedLine(
case 'source_changed':
return sourceChangedLine()
case 'refused':
return refusedLine(status.detail, refusalChars)
// Web clients can't run this desktop's Move anyway, so they keep main's reason.
return status.canLeaveBehind && !isWebClientLocation()
? leaveBehindLine(status.detail, refusalChars)
: refusedLine(status.detail, refusalChars)
case 'deferred':
case 'failed': {
const blocked = blockedNodeDownload(status)
@@ -113,7 +113,12 @@ test('a move an enabled SSH automation refuses offers Move anyway on the host ca
)
await card.hover()
await card.getByRole('button', { name: 'Connect', exact: true }).click()
await expect(card).toContainText('automations (1)', { timeout: 4 * 60_000 })
await expect(card).toContainText(
'Some saved items for this host can’t move to its Orca server: automations (1), automation runs (2). Move anyway connects the host and keeps them on this computer.',
{ timeout: 4 * 60_000 }
)
const toast = page.locator('[data-sonner-toast]').filter({ hasText: 'automations (1)' })
await expect(toast.getByRole('button', { name: 'Move anyway…' })).toBeVisible()
const moveAnyway = card.getByRole('button', { name: 'Move anyway…', exact: true })
await expect(moveAnyway).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('refused.png') })