fix: improve terminal session save failure handling (#2192)
This commit is contained in:
parent
caabf741fd
commit
3ce8ff9893
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue