mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
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:
co-authored by
Wayn_Liu
Neil
parent
6565a3fd23
commit
8c4d63e656
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user