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 <wayntingliu@gmail.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Wayn_Liu
2026-10-03 00:50:31 -07:00
committed by GitHub
co-authored by Wayn_Liu Neil
parent 6565a3fd23
commit 8c4d63e656
4 changed files with 310 additions and 58 deletions
@@ -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')
}
})
@@ -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
@@ -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')
}
})
@@ -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(