fix: improve terminal session save failure handling (#2192)

This commit is contained in:
Jinjing 2026-05-17 18:48:58 -07:00 committed by GitHub
parent caabf741fd
commit 3ce8ff9893
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 138 additions and 4 deletions

View File

@ -37,6 +37,7 @@ import {
requestKindSchema
} from '../../shared/telemetry-events'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import {
isTerminalLeafId,
@ -1392,9 +1393,7 @@ export function registerPtyHandlers(
if (!result.isReattach && args.connectionId && store) {
store.removeSshRemotePtyLease(args.connectionId, result.id)
}
throw new Error(
'Failed to save terminal session state. Check disk space and Orca data directory permissions, then try again.'
)
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
}
// Why: pre-signal cooperation gate — when the renderer has declared it

View File

@ -29,6 +29,7 @@ import { useTerminalFontZoom } from './useTerminalFontZoom'
import CloseTerminalDialog from './CloseTerminalDialog'
import { MobileDriverOverlay } from './MobileDriverOverlay'
import { TerminalErrorToast } from './TerminalErrorToast'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import TerminalContextMenu from './TerminalContextMenu'
import { useSystemPrefersDark } from './use-system-prefers-dark'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
@ -49,6 +50,7 @@ import {
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@ -133,6 +135,7 @@ export default function TerminalPane({
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState<number | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(null)
const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false)
// Why: override state lives in a plain Map for perf (safeFit reads it on
// every resize). This counter forces a re-render when overrides change so
// the mobile-fit banner appears/disappears. When an override is cleared
@ -235,6 +238,11 @@ export default function TerminalPane({
// then call handleRenameSubmit, saving the title the user wanted to discard.
const renameSubmittedRef = useRef(false)
const onPtyErrorRef = useRef((_paneId: number, message: string) => {
if (isTerminalSessionStateSaveFailure(message)) {
setTerminalError(null)
setSessionStateSaveFailureOpen(true)
return
}
setTerminalError((prev) => (prev ? `${prev}\n${message}` : message))
})
@ -258,6 +266,8 @@ export default function TerminalPane({
const markTerminalTabUnread = useAppStore((store) => store.markTerminalTabUnread)
const clearWorktreeUnread = useAppStore((store) => store.clearWorktreeUnread)
const clearTerminalTabUnread = useAppStore((store) => store.clearTerminalTabUnread)
const openSpacePage = useAppStore((store) => store.openSpacePage)
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
const settings = useAppStore((store) => store.settings)
// Why: Windows is the only platform where bare right-click is repurposed as
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
@ -279,6 +289,14 @@ export default function TerminalPane({
}
}, [startup, tabId, consumeTabStartupCommand])
const openDiskSpaceAnalyzer = useCallback(() => {
setSessionStateSaveFailureOpen(false)
openSpacePage()
void refreshWorkspaceSpace().catch((err: unknown) => {
console.warn('Failed to refresh Space Analyzer after terminal session save failure:', err)
})
}, [openSpacePage, refreshWorkspaceSpace])
useEffect(() => {
if (setupSplit) {
consumeTabSetupSplit(tabId)
@ -1219,6 +1237,13 @@ export default function TerminalPane({
{terminalError && isActive && (
<TerminalErrorToast error={terminalError} onDismiss={() => setTerminalError(null)} />
)}
{isActive && (
<TerminalSessionStateSaveFailureDialog
open={sessionStateSaveFailureOpen}
onDismiss={() => setSessionStateSaveFailureOpen(false)}
onOpenSpaceAnalyzer={openDiskSpaceAnalyzer}
/>
)}
{activePane?.container &&
createPortal(
<TerminalSearch

View File

@ -0,0 +1,60 @@
import { HardDrive } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
export function TerminalSessionStateSaveFailureDialog({
open,
onDismiss,
onOpenSpaceAnalyzer
}: {
open: boolean
onDismiss: () => void
onOpenSpaceAnalyzer: () => void
}): React.JSX.Element {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onDismiss()
}
}}
>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader className="gap-3">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40">
<HardDrive className="size-4 text-muted-foreground" />
</div>
<DialogTitle className="text-base">Disk space is unavailable</DialogTitle>
</div>
<DialogDescription className="text-xs leading-5">
Orca could not save this terminal session because local storage is full or not writable.
Open the disk space analyzer to find workspace storage you can clean up.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-border bg-muted/35 px-3 py-2.5 text-xs leading-5 text-muted-foreground">
The analyzer opens directly from here. You can also open it later from the lower-left
toolbox menu by choosing Space Analyzer.
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
<Button type="button" size="sm" autoFocus onClick={onOpenSpaceAnalyzer}>
Open Disk Space Analyzer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -8,6 +8,7 @@ import {
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../../shared/terminal-stream-protocol'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
describe('createIpcPtyTransport', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
@ -532,6 +533,39 @@ describe('createIpcPtyTransport', () => {
expect(onError).toHaveBeenCalledWith('ENOENT: spawn /bin/nope not found')
})
it('surfaces terminal session state save failures without the Electron IPC wrapper', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const wrappedMessage = `Error invoking remote method 'pty:spawn': Error: ${createTerminalSessionStateSaveFailureMessage()}`
const spawnMock = vi.fn().mockRejectedValue(new Error(wrappedMessage))
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
spawn: spawnMock,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {})
}
}
} as unknown as typeof window
const transport = createIpcPtyTransport()
const onError = vi.fn()
await transport.connect({
url: '',
callbacks: { onError }
})
expect(onError).toHaveBeenCalledWith(createTerminalSessionStateSaveFailureMessage())
})
it('keeps the exit observer alive after detach so remounts do not reuse dead PTYs', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const onPtyExit = vi.fn()

View File

@ -20,6 +20,7 @@ import {
import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher'
import { createBellDetector } from './bell-detector'
import { createAgentStatusOscProcessor } from './agent-status-osc'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
// Re-export public API so existing consumers keep working.
export {
@ -364,7 +365,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
}
return spawnResult.id
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err))
if (connectionId && options.sessionId && msg.includes(SSH_SESSION_EXPIRED_ERROR)) {
return {
id: options.sessionId,

View File

@ -0,0 +1,15 @@
export const TERMINAL_SESSION_STATE_SAVE_FAILED_CODE = 'ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED'
export const TERMINAL_SESSION_STATE_SAVE_FAILED_MESSAGE =
'Orca could not save this terminal session because local storage is unavailable.'
export function createTerminalSessionStateSaveFailureMessage(): string {
return `${TERMINAL_SESSION_STATE_SAVE_FAILED_CODE}: ${TERMINAL_SESSION_STATE_SAVE_FAILED_MESSAGE}`
}
export function isTerminalSessionStateSaveFailure(message: string): boolean {
return (
message.includes(TERMINAL_SESSION_STATE_SAVE_FAILED_CODE) ||
message.includes('Failed to save terminal session state')
)
}