diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8fd261178..a9616bd4e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 31242072b..e8aa46b64 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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({ query: '', caseSensitive: false, regex: false }) const [closeConfirmPaneId, setCloseConfirmPaneId] = useState(null) const [terminalError, setTerminalError] = useState(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 && ( setTerminalError(null)} /> )} + {isActive && ( + setSessionStateSaveFailureOpen(false)} + onOpenSpaceAnalyzer={openDiskSpaceAnalyzer} + /> + )} {activePane?.container && createPortal( void + onOpenSpaceAnalyzer: () => void +}): React.JSX.Element { + return ( + { + if (!isOpen) { + onDismiss() + } + }} + > + + +
+
+ +
+ Disk space is unavailable +
+ + 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. + +
+ +
+ The analyzer opens directly from here. You can also open it later from the lower-left + toolbox menu by choosing Space Analyzer. +
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 1164b4392..11f52ab32 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 8ccc6e8a3..98d88b580 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -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, diff --git a/src/shared/terminal-session-state-save-failure.ts b/src/shared/terminal-session-state-save-failure.ts new file mode 100644 index 000000000..309a98b65 --- /dev/null +++ b/src/shared/terminal-session-state-save-failure.ts @@ -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') + ) +}