From 8c4d63e656087015a3c194915769f19426b855a6 Mon Sep 17 00:00:00 2001 From: Wayn_Liu <115852642+Waynting@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:50:31 +0800 Subject: [PATCH] fix(terminal): show actionable copy for missing folder workspace paths Show the existing folder-path failure with concrete recovery instructions. Co-authored-by: Wayn_Liu Co-authored-by: Neil --- .../terminal-pane/TerminalErrorToast.test.ts | 103 +++++++++++++ .../terminal-pane/TerminalErrorToast.tsx | 30 +++- .../lib/folder-workspace-path-status.test.ts | 97 ++++++++++++ .../src/lib/folder-workspace-path-status.ts | 138 +++++++++++------- 4 files changed, 310 insertions(+), 58 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 47483c0538c..924c26230e1 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -12,6 +12,8 @@ vi.mock('@/lib/client-environment-info', () => ({ resolveClientEnvironmentFooter: environmentMocks.resolveFooter })) +import { i18n } from '@/i18n/i18n' + import { TerminalErrorToast, humanizeTerminalError, @@ -410,3 +412,104 @@ describe('TerminalErrorToast environment footer', () => { ) }) }) + +// The shape a missing folder workspace reaches the toast in when its terminal spawn is rejected. +const MISSING_FOLDER_ERROR = + "Error invoking remote method 'pty:spawn': Error: folder_workspace_path_missing:/Users/me/ara_company" + +describe('TerminalErrorToast folder workspace path errors', () => { + it('replaces the raw missing-folder code with actionable copy', () => { + const humanized = humanizeTerminalError(MISSING_FOLDER_ERROR) + + expect(humanized).not.toContain('folder_workspace_path_missing') + expect(humanized).toBe( + "Error invoking remote method 'pty:spawn': Error: Orca cannot find /Users/me/ara_company. Remove and re-import the folder." + ) + }) + + it('keeps unrelated lines when one line is a folder path error', () => { + const humanized = humanizeTerminalError( + ['folder_workspace_path_not_directory:/srv/app', 'Another error'].join('\n') + ) + + expect(humanized).toBe(['/srv/app exists, but it is not a folder.', 'Another error'].join('\n')) + }) + + it('humanizes the ambiguous-connection code thrown without a path', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: 'folder_workspace_connection_ambiguous', + onDismiss: vi.fn() + }) + ) + + const toast = view.container.querySelector('[data-terminal-error-toast]') + expect(toast?.textContent).toContain( + 'Orca cannot tell which SSH connection owns this folder scope.' + ) + expect(toast?.textContent).not.toContain('folder_workspace_connection_ambiguous') + expect(toast?.querySelector('a')).toBeNull() + }) + + it('keeps the issue link when an unrelated error shares the toast', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: [MISSING_FOLDER_ERROR, 'Failed to spawn shell "/bin/zsh": boom'].join('\n'), + onDismiss: vi.fn() + }) + ) + + const toast = view.container.querySelector('[data-terminal-error-toast]') + expect(toast?.textContent).toContain('Orca cannot find /Users/me/ara_company') + expect(toast?.querySelector('a')?.textContent).toBe('file an issue') + }) + + it('does not ask the user to file an issue for a folder they can fix', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: MISSING_FOLDER_ERROR, + onDismiss: vi.fn() + }) + ) + + const toast = view.container.querySelector('[data-terminal-error-toast]') + expect(toast?.textContent).toContain('Orca cannot find /Users/me/ara_company') + expect(toast?.textContent).not.toContain('folder_workspace_path_missing') + expect(toast?.textContent).not.toContain('If this persists') + expect(toast?.querySelector('a')).toBeNull() + }) +}) + +it('keeps an existing environment footer without asking to report an explained folder error', () => { + const footer = + '---\nOrca: 1.4.178-rc.2\nOS: win32 10.0 (x64)\nShell: C:\\Windows\\System32\\cmd.exe' + const error = `folder_workspace_path_not_directory:C:\\work\\folder:file\n\n${footer}` + const view = render(React.createElement(TerminalErrorToast, { error, onDismiss: vi.fn() })) + expect(view.container.textContent).toContain( + 'C:\\work\\folder:file exists, but it is not a folder.' + ) + expect(view.container.textContent).toContain(footer) + expect(view.container.querySelector('a')).toBeNull() + expect(environmentMocks.resolveFooter).not.toHaveBeenCalled() +}) + +it('uses existing Korean recovery copy and preserves the path', async () => { + await i18n.changeLanguage('ko') + try { + const path = '/tmp/project: folder' + const view = render( + React.createElement(TerminalErrorToast, { + error: `folder_workspace_path_missing:${path}`, + onDismiss: vi.fn() + }) + ) + expect(view.container.textContent).toContain( + i18n.t('auto.lib.folderWorkspacePathStatus.createError.description.missing', { path }) + ) + expect(view.container.textContent).toContain(path) + expect(view.container.textContent).not.toContain('folder_workspace_path_missing') + expect(view.container.querySelector('a')).toBeNull() + } finally { + await i18n.changeLanguage('en') + } +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index a31b0c37856..d0f97c8d291 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -1,8 +1,15 @@ import { useEffect, useState } from 'react' import { translate } from '@/i18n/i18n' import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' +import { + humanizeFolderWorkspacePathError, + isFolderWorkspacePathError +} from '@/lib/folder-workspace-path-status' import { Button } from '@/components/ui/button' -import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info' +import { + hasClientEnvironmentFooter, + stripClientEnvironmentFooter +} from '../../../../shared/client-environment-info' import { localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest @@ -114,9 +121,22 @@ function humanizeUnreattachableSession(error: string): string { ) } +// Why: only an all-folder toast drops the issue link; an unrelated line still needs it. +function isFolderWorkspacePathOnlyError(error: string): boolean { + const lines = stripClientEnvironmentFooter(error) + .split('\n') + .filter((line) => line.trim().length > 0) + 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') +} + /** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { - let humanized = localizeTerminalSpawnHints(error) + let humanized = humanizeFolderWorkspacePathErrors(localizeTerminalSpawnHints(error)) if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) { const explanation = isPaneOwnerUnverifiedError(humanized) ? translate( @@ -181,7 +201,11 @@ export function TerminalErrorToast({ const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) // Restart cannot recover a session after its owning daemon exits. const showIssueLink = - !ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error) + !ssh && + !paneOwnerUnverified && + !showDaemonRestart && + !isExplainedTerminalError(error) && + !isFolderWorkspacePathOnlyError(error) const humanizedError = humanizeTerminalError(error) // Why: the toast appends its own linked request, so the host's plain-text one would repeat it. const displayError = showIssueLink diff --git a/src/renderer/src/lib/folder-workspace-path-status.test.ts b/src/renderer/src/lib/folder-workspace-path-status.test.ts index 3464a6680da..26561ae755e 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.test.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' +import { i18n } from '@/i18n/i18n' import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' import { + formatFolderWorkspaceCreateError, + humanizeFolderWorkspacePathError, + getFolderWorkspacePathErrorCopy, getFolderWorkspacePathStatusDescription, getFolderWorkspacePathStatusTitle } from './folder-workspace-path-status' @@ -75,3 +79,96 @@ describe('getFolderWorkspacePathStatusDescription', () => { ) }) }) + +describe('getFolderWorkspacePathErrorCopy', () => { + it('maps each main-process path error code to its own copy', () => { + expect(getFolderWorkspacePathErrorCopy('folder_workspace_path_missing:/srv/scans')).toEqual({ + title: 'Folder not found', + description: 'Orca cannot find /srv/scans. Remove and re-import the folder.' + }) + expect( + getFolderWorkspacePathErrorCopy('folder_workspace_path_not_directory:/srv/scans')?.title + ).toBe('Path is not a folder') + expect( + getFolderWorkspacePathErrorCopy('folder_workspace_connection_ambiguous:/srv/scans')?.title + ).toBe('Cannot determine connection') + expect( + getFolderWorkspacePathErrorCopy('folder_workspace_path_unavailable:/srv/scans')?.title + ).toBe('Cannot check folder') + }) + + it('finds the code behind an Electron IPC prefix and keeps paths with spaces', () => { + const copy = getFolderWorkspacePathErrorCopy( + "Error invoking remote method 'pty:spawn': Error: folder_workspace_path_missing:/Users/me/My Project" + ) + + expect(copy?.description).toBe( + 'Orca cannot find /Users/me/My Project. Remove and re-import the folder.' + ) + }) + + it('maps the ambiguous-connection code the runtime throws without a path', () => { + expect( + getFolderWorkspacePathErrorCopy( + "Error invoking remote method 'pty:spawn': Error: folder_workspace_connection_ambiguous" + )?.title + ).toBe('Cannot determine connection') + }) + + it('returns null for unrelated errors', () => { + expect(getFolderWorkspacePathErrorCopy('folder_workspace_not_found')).toBeNull() + expect(getFolderWorkspacePathErrorCopy('folder_workspace_path_missing_extra:/x')).toBeNull() + expect(getFolderWorkspacePathErrorCopy('Failed to spawn shell')).toBeNull() + }) +}) + +describe('formatFolderWorkspaceCreateError', () => { + it('uses the path copy for path codes and the raw message otherwise', () => { + expect( + formatFolderWorkspaceCreateError(new Error('folder_workspace_path_missing:/srv/app')).title + ).toBe('Folder not found') + expect(formatFolderWorkspaceCreateError(new Error('disk full'))).toEqual({ + title: 'Failed to create folder workspace', + description: 'disk full' + }) + }) +}) + +it.each(['/srv/project: folder', 'C:\\work\\My Project:backup', '/tmp/$& folder'])( + 'preserves a literal path %s and IPC wrapper', + (path) => { + const prefix = "Error invoking remote method 'pty:spawn': Error: " + expect(humanizeFolderWorkspacePathError(`${prefix}folder_workspace_path_missing:${path}`)).toBe( + `${prefix}Orca cannot find ${path}. Remove and re-import the folder.` + ) + } +) + +it.each([ + 'not_folder_workspace_path_missing:/x', + 'folder_workspace_path_missing_extra:/x', + 'Failed to read /tmp/folder_workspace_path_missing:/x', + 'folder_workspace_path_missing-more', + 'disk full' +])('passes through unknown error %s', (error) => { + expect(humanizeFolderWorkspacePathError(error)).toBe(error) + expect(getFolderWorkspacePathErrorCopy(error)).toBeNull() +}) + +it('preserves multiline paths in the existing create-folder formatter', () => { + const path = '/tmp/folder\nwith\rline breaks' + expect( + formatFolderWorkspaceCreateError(`folder_workspace_path_missing:${path}`).description + ).toBe(`Orca cannot find ${path}. Remove and re-import the folder.`) +}) + +it('uses the existing English fallback when a language pack has no folder-path translation', async () => { + await i18n.changeLanguage('test-missing-folder-copy') + try { + expect(humanizeFolderWorkspacePathError('folder_workspace_path_missing:/tmp/project')).toBe( + 'Orca cannot find /tmp/project. Remove and re-import the folder.' + ) + } finally { + await i18n.changeLanguage('en') + } +}) diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 3e4b95e3e7b..6382a4900be 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -96,69 +96,97 @@ export function getFolderWorkspacePathStatusDescription( } } +// IPC wrappers precede the code; ambiguous-connection errors can omit the path. +const FOLDER_WORKSPACE_PATH_ERROR_PATTERN = + /^([ \t]*(?:Error invoking remote method '[^'\r\n]+': )?(?:Error: )*)folder_workspace_(path_missing|path_not_directory|connection_ambiguous|path_unavailable)(?::([\s\S]*))?$/ + +export function isFolderWorkspacePathError(message: string): boolean { + return FOLDER_WORKSPACE_PATH_ERROR_PATTERN.test(message) +} + +/** Maps a main-process folder path error code to user-facing copy, or null for anything else. */ +export function getFolderWorkspacePathErrorCopy(message: string): { + title: string + description: string +} | null { + const match = FOLDER_WORKSPACE_PATH_ERROR_PATTERN.exec(message) + if (!match) { + return null + } + const [, , code, path = ''] = match + switch (code) { + case 'path_missing': + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.missing', + 'Folder not found' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.missing', + 'Orca cannot find {{path}}. Remove and re-import the folder.', + { path } + ) + } + case 'path_not_directory': + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory', + 'Path is not a folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path } + ) + } + case 'connection_ambiguous': + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection', + 'Cannot determine connection' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection', + 'Orca cannot tell which SSH connection owns this folder scope.' + ) + } + default: + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.unavailable', + 'Cannot check folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.unavailable', + 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) + } + } +} + +export function humanizeFolderWorkspacePathError(message: string): string { + const copy = getFolderWorkspacePathErrorCopy(message) + return copy + ? message.replace(FOLDER_WORKSPACE_PATH_ERROR_PATTERN, (_match, prefix: string) => + prefix.concat(copy.description) + ) + : message +} + export function formatFolderWorkspaceCreateError(error: unknown): { title: string description: string } { const message = error instanceof Error ? error.message : String(error) - const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : '' - if (message.startsWith('folder_workspace_path_missing:')) { - return { + return ( + getFolderWorkspacePathErrorCopy(message) ?? { title: translate( - 'auto.lib.folderWorkspacePathStatus.createError.title.missing', - 'Folder not found' + 'auto.lib.folderWorkspacePathStatus.createError.title.generic', + 'Failed to create folder workspace' ), - description: translate( - 'auto.lib.folderWorkspacePathStatus.createError.description.missing', - 'Orca cannot find {{path}}. Remove and re-import the folder.', - { path } - ) + description: message } - } - if (message.startsWith('folder_workspace_path_not_directory:')) { - return { - title: translate( - 'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory', - 'Path is not a folder' - ), - description: translate( - 'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory', - '{{path}} exists, but it is not a folder.', - { path } - ) - } - } - if (message.startsWith('folder_workspace_connection_ambiguous:')) { - return { - title: translate( - 'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection', - 'Cannot determine connection' - ), - description: translate( - 'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection', - 'Orca cannot tell which SSH connection owns this folder scope.' - ) - } - } - if (message.startsWith('folder_workspace_path_unavailable:')) { - return { - title: translate( - 'auto.lib.folderWorkspacePathStatus.createError.title.unavailable', - 'Cannot check folder' - ), - description: translate( - 'auto.lib.folderWorkspacePathStatus.createError.description.unavailable', - 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' - ) - } - } - return { - title: translate( - 'auto.lib.folderWorkspacePathStatus.createError.title.generic', - 'Failed to create folder workspace' - ), - description: message - } + ) } export function folderWorkspaceActivationBlocked(