diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts index 975fb2fd17e..af4fa1b241b 100644 --- a/src/cli/cli-error.ts +++ b/src/cli/cli-error.ts @@ -8,6 +8,7 @@ import { worktreeSelectorRecovery } from './worktree-selector-recovery' import { ambiguousWorkspaceChoices, selectorAmbiguityRecovery } from './selector-ambiguity-recovery' import type { RuntimeRpcFailure } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' +import { readableWslDistroCliRefusal } from './wsl-distro-cli-refusal' export type CliErrorContext = { commandPath?: readonly string[] @@ -66,7 +67,9 @@ export function cliErrorCode(error: unknown): string | undefined { } export function formatCliError(error: unknown, context: CliErrorContext = {}): string { - const message = error instanceof Error ? error.message : String(error) + const message = readableWslDistroCliRefusal( + error instanceof Error ? error.message : String(error) + ) const selector = contextRecovery(cliErrorCode(error), context) const workspaceChoices = error instanceof RuntimeClientError && diff --git a/src/cli/wsl-distro-cli-refusal.test.ts b/src/cli/wsl-distro-cli-refusal.test.ts new file mode 100644 index 00000000000..900aa794235 --- /dev/null +++ b/src/cli/wsl-distro-cli-refusal.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { formatCliError } from './cli-error' +import { RuntimeClientError } from './runtime/types' +import { readableWslDistroCliRefusal } from './wsl-distro-cli-refusal' + +describe('readableWslDistroCliRefusal', () => { + it("names the distro in an older server's raw hold code", () => { + expect(readableWslDistroCliRefusal('terminal_held_wsl_distro_stopped:Ubuntu')).toBe( + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + }) + + it.each(['\\\\wsl.localhost\\Ubuntu\\home\\me\\notes', '\\\\wsl$\\Ubuntu\\home\\me\\notes'])( + 'names the distro behind an unavailable WSL folder (%s)', + (path) => { + expect(readableWslDistroCliRefusal(`folder_workspace_path_unavailable:${path}`)).toBe( + `Orca couldn't reach ${path}. The WSL distro Ubuntu may be stopped, and Orca doesn't start a stopped distro for this. Start Ubuntu (for example, open a terminal in it), then try again.` + ) + } + ) + + it('leaves other refusals as they were', () => { + for (const message of ['folder_workspace_path_unavailable:/srv/notes', 'terminal_not_found']) { + expect(readableWslDistroCliRefusal(message)).toBe(message) + } + }) + + it('reads in the human CLI error output', () => { + const error = new RuntimeClientError( + 'runtime_error', + 'folder_workspace_path_unavailable:\\\\wsl.localhost\\Debian\\w' + ) + expect(formatCliError(error)).toContain('The WSL distro Debian may be stopped') + }) +}) diff --git a/src/cli/wsl-distro-cli-refusal.ts b/src/cli/wsl-distro-cli-refusal.ts new file mode 100644 index 00000000000..cfdd17abffe --- /dev/null +++ b/src/cli/wsl-distro-cli-refusal.ts @@ -0,0 +1,22 @@ +import { readableWslDistroHoldText } from '../shared/wsl-distro-stopped-hold' +import { parseWslUncPath } from '../shared/wsl-paths' + +const FOLDER_UNAVAILABLE_PREFIX = 'folder_workspace_path_unavailable:' + +/** + * A refusal over a WSL distro, named: older servers send the raw hold code, and a folder on a + * distro Orca won't start in the background reads only as "path unavailable". + */ +export function readableWslDistroCliRefusal(message: string): string { + const held = readableWslDistroHoldText(message) + if (held !== message) { + return held + } + const start = message.indexOf(FOLDER_UNAVAILABLE_PREFIX) + const path = start === -1 ? '' : message.slice(start + FOLDER_UNAVAILABLE_PREFIX.length).trim() + const distro = parseWslUncPath(path)?.distro + if (!distro) { + return message + } + return `${message.slice(0, start)}Orca couldn't reach ${path}. The WSL distro ${distro} may be stopped, and Orca doesn't start a stopped distro for this. Start ${distro} (for example, open a terminal in it), then try again.` +} diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index b8f76771b62..804ff45e79b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react' import { unwrapHostRefusalIpcLines } from './terminal-error-ipc-unwrap' import { translate } from '@/i18n/i18n' -import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { - humanizeFolderWorkspacePathError, + humanizeFolderWorkspacePathErrors, isFolderWorkspacePathError } from '@/lib/folder-workspace-path-status' import { Button } from '@/components/ui/button' @@ -13,9 +12,11 @@ import { stripClientEnvironmentFooter } from '../../../../shared/client-environment-info' import { + failedSpawnEnvironmentFooter, localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest } from './terminal-spawn-error-display' +import { isWslDistroHoldText, localizeWslDistroHoldText } from '@/lib/wsl-distro-hold-copy' import { describeSshSignInRequiredTerminalError, isSshSignInRequiredTerminalError @@ -142,11 +143,6 @@ function isFolderWorkspacePathOnlyError(error: string): boolean { return lines.length > 0 && lines.every(isFolderWorkspacePathError) } -// Why: a moved or deleted folder is the user's to fix, so it gets actionable copy, not an issue link. -function humanizeFolderWorkspacePathErrors(error: string): string { - return error.split('\n').map(humanizeFolderWorkspacePathError).join('\n') -} - /** An SSH host state (reconnecting, or waiting for the user to sign in), not a bug to report. */ function isSshHostStateTerminalError(error: string): boolean { return isSshSignInRequiredTerminalError(error) || isSshReconnectOwnedTerminalError(error) @@ -157,7 +153,8 @@ export function humanizeTerminalError(error: string, agentId?: string | null): s if (isSshSignInRequiredTerminalError(error)) { return describeSshSignInRequiredTerminalError(error) } - const unwrapped = unwrapHostRefusalIpcLines(error) + // Hosts send a held start as its code (older) or English sentence (newer); either reads in the user's language. + const unwrapped = localizeWslDistroHoldText(unwrapHostRefusalIpcLines(error)) let humanized = humanizeFolderWorkspacePathErrors( localizeTerminalSpawnHints(agentLaunchErrorText(unwrapped, agentId) ?? unwrapped) ) @@ -255,7 +252,8 @@ export function TerminalErrorToast({ !showDaemonRestart && !isAgentLaunchOnlyError(error) && !isExplainedTerminalError(error) && - !isFolderWorkspacePathOnlyError(error) + !isFolderWorkspacePathOnlyError(error) && + !isWslDistroHoldText(error) const humanizedError = humanizeTerminalError(error, agentId) // Why: the toast appends its own linked request, so the host's plain-text one would repeat it. const displayError = showIssueLink @@ -279,7 +277,7 @@ export function TerminalErrorToast({ return } let cancelled = false - void resolveClientEnvironmentFooter().then((footer) => { + void failedSpawnEnvironmentFooter(displayError).then((footer) => { if (!cancelled) { setEnvironmentFooter({ error: displayError, footer }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts index 5e7420561cb..b68aae6afbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts @@ -156,4 +156,41 @@ describe('waitsForMainPaneStart', () => { }) expect(pane.deps.onPtyErrorRef?.current).not.toHaveBeenCalled() }) + + it('shows the Start card when the refusal carries the readable sentence, not the code', async () => { + const { showPaneStartFailure } = await import('./main-pane-start-wait') + const { wslDistroStoppedHoldStore } = await import('../wsl-distro-stopped-hold-state') + const pane = session() + + showPaneStartFailure( + pane, + "The WSL distro Debian is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + + expect(wslDistroStoppedHoldStore.getState().panes[PANE_KEY]).toMatchObject({ + distro: 'Debian', + state: 'stopped' + }) + expect(pane.deps.onPtyErrorRef?.current).not.toHaveBeenCalled() + }) + + it.each([ + ['terminal_held_wsl_distro_stopped:Ubuntu'], + [ + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ] + ])( + "names the distro on a paired host's held pane instead of the raw code (%s)", + async (reason) => { + const { showPaneStartFailure } = await import('./main-pane-start-wait') + const pane = { ...session(), runtimeEnvironmentId: 'env-1' } + + showPaneStartFailure(pane, reason) + + expect(pane.deps.onPtyErrorRef?.current).toHaveBeenCalledWith( + 1, + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts index 0900b762f69..1459313b00e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts @@ -6,7 +6,10 @@ import { recordPtyConnectDiagnostic } from './pty-connect-limits' import type { PaneStartStatus } from '../../../../../shared/workspace-layout/workspace-layout-published' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { resolveTerminalTabId } from './terminal-tab-id' -import { parseWslDistroHold } from '../../../../../shared/wsl-distro-stopped-hold' +import { + findWslDistroHoldInText, + wslDistroHoldUserMessage +} from '../../../../../shared/wsl-distro-stopped-hold' import { holdPaneForStoppedWslDistro } from '../wsl-distro-stopped-hold-state' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -110,7 +113,8 @@ export function showPaneStartFailure( session: ConnectPanePtySession, reason: string | undefined ): void { - const hold = reason ? parseWslDistroHold(reason) : null + // Main publishes the code; an RPC refusal carries the sentence instead. + const hold = reason ? (findWslDistroHoldInText(reason)?.hold ?? null) : null if (hold && !session.disposed && !session.runtimeEnvironmentId) { // Main held background work on a stopped (or unchecked) distro: the card's Start asks main to start it. const state = useAppStore.getState() @@ -132,7 +136,10 @@ export function showPaneStartFailure( if (!session.disposed) { session.deps.onPtyErrorRef?.current?.( session.pane.id, - `The terminal could not start: ${reason ?? 'unknown error'}` + // A paired host's held pane has no local Start: the sentence, which the banner localizes. + hold + ? wslDistroHoldUserMessage(hold) + : `The terminal could not start: ${reason ?? 'unknown error'}` ) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts b/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts new file mode 100644 index 00000000000..dc2c7cea329 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import React from 'react' +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const environmentMocks = vi.hoisted(() => ({ resolveFooter: vi.fn() })) + +vi.mock('@/lib/client-environment-info', () => ({ + resolveClientEnvironmentFooter: environmentMocks.resolveFooter +})) + +import { TerminalErrorToast, humanizeTerminalError } from './TerminalErrorToast' + +const STOPPED_SENTENCE = + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + +beforeEach(() => { + environmentMocks.resolveFooter.mockReset() + environmentMocks.resolveFooter.mockImplementation(async (overrides: { shell?: string } = {}) => + ['---', 'Orca: 1.0.0', 'OS: win32 10.0 (x64)', `Shell: ${overrides.shell ?? 'cmd.exe'}`].join( + '\n' + ) + ) +}) + +afterEach(() => { + cleanup() +}) + +describe('a held WSL terminal in the error banner', () => { + it.each([ + ['terminal_held_wsl_distro_stopped:Ubuntu'], + ["Error invoking remote method 'pty:spawn': Error: terminal_held_wsl_distro_stopped:Ubuntu"], + ['Failed to create the startup terminal for C:\\w: terminal_held_wsl_distro_stopped:Ubuntu'] + ])('names the distro instead of the raw code (%s)', (error) => { + const humanized = humanizeTerminalError(error) + expect(humanized).toContain(STOPPED_SENTENCE) + expect(humanized).not.toContain('terminal_held_wsl_distro') + }) + + it('reads an unchecked distro as unverifiable, not stopped', () => { + expect(humanizeTerminalError('terminal_held_wsl_distro_unverifiable:Debian')).toBe( + "Orca couldn't check whether the WSL distro Debian is running, so it didn't start this terminal. Start the distro, then try again." + ) + }) + + it('does not ask the user to file an issue for a held terminal', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: 'terminal_held_wsl_distro_stopped:Ubuntu', + onDismiss: vi.fn() + }) + ) + expect(view.container.textContent).toContain(STOPPED_SENTENCE) + expect(view.container.textContent).not.toContain('file an issue') + }) +}) + +describe('the error banner footer', () => { + it('reports the shell whose spawn failed, not the default shell', async () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: + 'Daemon failed to spawn shell "C:\\WINDOWS\\system32\\wsl.exe" with cwd "C:\\Users\\me": spawn failed', + onDismiss: vi.fn() + }) + ) + await waitFor(() => + expect(view.container.textContent).toContain('Shell: C:\\WINDOWS\\system32\\wsl.exe') + ) + expect(view.container.textContent).not.toContain('Shell: cmd.exe') + }) + + it('keeps the default shell when the error names none', async () => { + const view = render( + React.createElement(TerminalErrorToast, { error: 'Paste failed.', onDismiss: vi.fn() }) + ) + await waitFor(() => expect(view.container.textContent).toContain('Shell: cmd.exe')) + expect(environmentMocks.resolveFooter).toHaveBeenCalledWith({}) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts index e61504a2d71..abc8b3c7e8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts @@ -18,6 +18,7 @@ vi.mock('@/i18n/i18n', () => ({ })) import { + failedSpawnShell, localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest } from './terminal-spawn-error-display' @@ -48,3 +49,15 @@ describe('withoutTerminalSpawnIssueRequest', () => { expect(withoutTerminalSpawnIssueRequest(error)).toBe(error) }) }) + +describe('failedSpawnShell', () => { + it('reads the shell from daemon and local spawn failures', () => { + expect( + failedSpawnShell( + 'Daemon failed to spawn shell "C:\\Windows\\System32\\wsl.exe" with cwd "C:\\"' + ) + ).toBe('C:\\Windows\\System32\\wsl.exe') + expect(failedSpawnShell(RAW)).toBe('/bin/zsh') + expect(failedSpawnShell('Paste failed.')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts index 33682a95233..311bf4e0919 100644 --- a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { LEGACY_PTY_ALLOCATION_HINT, LEGACY_TERMINAL_PROCESS_LIMIT_HINT, @@ -43,3 +44,16 @@ export function withoutTerminalSpawnIssueRequest(error: string): string { ) .join('\n') } + +const FAILED_SPAWN_SHELL = /failed to spawn shell "([^"\r\n]+)"/i + +/** The shell a failed spawn names, which the footer reports instead of the client's default shell. */ +export function failedSpawnShell(error: string): string | null { + return FAILED_SPAWN_SHELL.exec(error)?.[1] ?? null +} + +/** The client environment footer, naming the failed spawn's own shell (wsl.exe, say) over the default one. */ +export function failedSpawnEnvironmentFooter(error: string): Promise { + const shell = failedSpawnShell(error) + return resolveClientEnvironmentFooter(shell ? { shell } : {}) +} diff --git a/src/renderer/src/lib/client-environment-info.ts b/src/renderer/src/lib/client-environment-info.ts index 1749e6d1ac5..fda7a1a295b 100644 --- a/src/renderer/src/lib/client-environment-info.ts +++ b/src/renderer/src/lib/client-environment-info.ts @@ -32,8 +32,10 @@ function resolveFallbackPlatform(): NodeJS.Platform | 'unknown' { } } -export async function resolveClientEnvironmentFooter(): Promise { - return formatClientEnvironmentFooter(await resolveClientEnvironmentInfo()) +export async function resolveClientEnvironmentFooter( + overrides: { shell?: string } = {} +): Promise { + return formatClientEnvironmentFooter({ ...(await resolveClientEnvironmentInfo()), ...overrides }) } async function resolveAppVersion(): Promise { diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 86b9cab2793..aec5a3dde48 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -171,6 +171,11 @@ export function humanizeFolderWorkspacePathError(message: string): string { : message } +// Why: a moved or deleted folder is the user's to fix, so it gets actionable copy, not an issue link. +export function humanizeFolderWorkspacePathErrors(error: string): string { + return error.split('\n').map(humanizeFolderWorkspacePathError).join('\n') +} + export function formatFolderWorkspaceCreateError(error: unknown): { title: string description: string diff --git a/src/renderer/src/lib/wsl-distro-hold-copy.ts b/src/renderer/src/lib/wsl-distro-hold-copy.ts index 41669f11f8f..6ebbdfcaf67 100644 --- a/src/renderer/src/lib/wsl-distro-hold-copy.ts +++ b/src/renderer/src/lib/wsl-distro-hold-copy.ts @@ -26,3 +26,8 @@ export function localizeWslDistroHoldText(text: string): string { const found = findWslDistroHoldInText(text) return found ? text.split(found.matched).join(localizedWslDistroHoldMessage(found.hold)) : text } + +/** Whether text names a held terminal, in either form. */ +export function isWslDistroHoldText(text: string): boolean { + return findWslDistroHoldInText(text) !== null +}