diff --git a/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx b/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx index 5b20a29f..cbc528c9 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { useDashboardState } from 'state/useDashboardState'; +import { useGlobal } from 'hooks/useGlobal'; +import type { TerminalConnectionState } from './Terminal'; import { TerminalView } from './Terminal'; const EMPTY_TERMINALS: Record = {}; @@ -36,10 +38,22 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR }; }, [panelId]); + const [, setConnState] = useGlobal(`terminal-conn-${panelId}`, 'disconnected'); + const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]); + if (!sessionId) return null; const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null; const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command; - return ; + return ( + + ); }; diff --git a/src/workspaces/officerdev/src/apps/Terminal/Headers.tsx b/src/workspaces/officerdev/src/apps/Terminal/Headers.tsx index 949ff3c0..3a1e23fc 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Headers.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Headers.tsx @@ -1,57 +1,78 @@ -import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles } from 'lucide-react'; +import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, Circle, Loader2 } from 'lucide-react'; import { useWorkspace } from '../../components/Workspace'; +import { useGlobal } from 'hooks/useGlobal'; +import type { TerminalConnectionState } from './Terminal'; -export const TerminalHeader = () => { +const ConnectionIndicator = ({ panelId }: { panelId: string }) => { + const [state] = useGlobal(`terminal-conn-${panelId}`, 'disconnected'); + + if (state === 'connected') { + return ; + } + + if (state === 'reconnecting') { + return ; + } + + return ; +}; + +export const TerminalHeader = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); return ( <> Terminal + {cwd} ); }; -export const HostTerminalHeader = () => { +export const HostTerminalHeader = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); return ( <> Terminal + {cwd} ); }; -export const TmuxHeader = () => { +export const TmuxHeader = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); return ( <> Tmux + {cwd} ); }; -export const NvimHeader = () => { +export const NvimHeader = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); return ( <> Neovim + {cwd} ); }; -export const ClaudeCodeHeader = () => { +export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); return ( <> Claude Code + {cwd} ); diff --git a/src/workspaces/officerdev/src/apps/Terminal/HostTerminalWrapper.tsx b/src/workspaces/officerdev/src/apps/Terminal/HostTerminalWrapper.tsx index 7f5a3481..c706fe95 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/HostTerminalWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/HostTerminalWrapper.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { useAuth } from 'hooks/useAuth'; import { useDashboardState } from 'state/useDashboardState'; +import { useGlobal } from 'hooks/useGlobal'; +import type { TerminalConnectionState } from './Terminal'; import { TerminalView } from './Terminal'; const EMPTY_TERMINALS: Record = {}; @@ -39,7 +41,18 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { ); } + const [, setConnState] = useGlobal(`terminal-conn-${panelId}`, 'disconnected'); + const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]); + if (!sessionId) return null; - return ; + return ( + + ); }; diff --git a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx index 9b37fab3..827ae65a 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx @@ -12,6 +12,8 @@ type TerminalTheme = { selectionBackground?: string; }; +export type TerminalConnectionState = 'connected' | 'disconnected' | 'reconnecting'; + export type TerminalViewProps = { className?: string; style?: CSSProperties; @@ -30,6 +32,7 @@ export type TerminalViewProps = { onCommandDone?: (exitCode: number, output: string) => void; onDisconnect?: () => void; onPanelRefresh?: () => void; + onConnectionChange?: (state: TerminalConnectionState) => void; }; const DEFAULT_THEME: Required = { @@ -71,6 +74,7 @@ export const TerminalView = ({ onCommandDone, onDisconnect, onPanelRefresh, + onConnectionChange, }: TerminalViewProps) => { const containerRef = useRef(null); const termRef = useRef(null); @@ -81,6 +85,7 @@ export const TerminalView = ({ const onCommandDoneRef = useRef(onCommandDone); const onDisconnectRef = useRef(onDisconnect); const onPanelRefreshRef = useRef(onPanelRefresh); + const onConnectionChangeRef = useRef(onConnectionChange); const commandRef = useRef(command); const initialInputRef = useRef(initialInput); @@ -89,6 +94,7 @@ export const TerminalView = ({ onCommandDoneRef.current = onCommandDone; onDisconnectRef.current = onDisconnect; onPanelRefreshRef.current = onPanelRefresh; + onConnectionChangeRef.current = onConnectionChange; commandRef.current = command; initialInputRef.current = initialInput; @@ -127,108 +133,164 @@ export const TerminalView = ({ termRef.current = term; onReadyRef.current?.(term); + // Reconnect state + let reconnectAttempts = 0; + let reconnectTimer: ReturnType | null = null; + let processExited = false; + const MAX_RECONNECT_ATTEMPTS = 5; + const RECONNECT_DELAYS = [1000, 2000, 3000, 5000, 5000]; + + let commandSent = false; + let commandDone = false; + let initialInputSent = false; + let commandOutput = ''; + const EXIT_MARKER = '__OFFICER_EXIT_'; + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); + + const connect = () => { + if (disposed) return; + + fitAddon.fit(); + const cols = term.cols; + const rows = term.rows; + + const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows)); + wsRef.current = ws; + + const cleanupWs = () => { + ws.removeEventListener('open', handleOpen); + ws.removeEventListener('message', handleMessage); + ws.removeEventListener('close', handleClose); + ws.close(); + }; + + const handleOpen = () => { + reconnectAttempts = 0; + onConnectionChangeRef.current?.('connected'); + ws.send(JSON.stringify({ type: 'resize', cols, rows })); + }; + + const handleMessage = (ev: MessageEvent) => { + try { + const msg = JSON.parse(ev.data as string); + if (msg.type === 'output') { + term.write(msg.data); + if (commandRef.current && !commandSent) { + commandSent = true; + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + const wrapped = onCommandDoneRef.current + ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` + : commandRef.current; + ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); + } + }, 100); + } + if (!commandRef.current && initialInputRef.current && !initialInputSent) { + initialInputSent = true; + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' })); + } + }, 500); + } + if (commandSent && !commandDone && onCommandDoneRef.current) { + commandOutput += msg.data as string; + const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/); + if (markerMatch) { + const exitCode = Number(markerMatch[1]); + const raw = stripAnsi(commandOutput).slice(0, markerMatch.index); + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20))); + const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim(); + commandDone = true; + onCommandDoneRef.current(exitCode, output); + } + } + } else if (msg.type === 'exit') { + processExited = true; + term.write('\r\n[Process exited]\r\n'); + onExitRef.current?.(); + } else if (msg.type === 'detached') { + term.write('\r\n[Session taken over]\r\n'); + } else if (msg.type === 'panel-refresh') { + onPanelRefreshRef.current?.(); + } + } catch { + // ignore + } + }; + + const handleClose = () => { + cleanupWs(); + if (disposed || processExited) { + onConnectionChangeRef.current?.('disconnected'); + term.write('\r\n[Disconnected]\r\n'); + onDisconnectRef.current?.(); + return; + } + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const delay = RECONNECT_DELAYS[reconnectAttempts] ?? 5000; + reconnectAttempts++; + onConnectionChangeRef.current?.('reconnecting'); + term.write(`\r\n[Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...]\r\n`); + reconnectTimer = setTimeout(connect, delay); + } else { + onConnectionChangeRef.current?.('disconnected'); + term.write('\r\n[Disconnected]\r\n'); + onDisconnectRef.current?.(); + } + }; + + ws.addEventListener('open', handleOpen); + ws.addEventListener('message', handleMessage); + ws.addEventListener('close', handleClose); + + (container as any).__terminalCleanup = () => { + cleanupWs(); + }; + }; + + const dataDisposable = term.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'input', data })); + } + }); + + // Reconnect when tab becomes visible again + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && !disposed && !processExited) { + if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) { + reconnectAttempts = 0; + connect(); + } + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + // Wait for layout to fully settle (double rAF), then fit + connect requestAnimationFrame(() => { requestAnimationFrame(() => { - if (disposed) return; - - fitAddon.fit(); - const cols = term.cols; - const rows = term.rows; - - const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows)); - wsRef.current = ws; - let commandSent = false; - let commandDone = false; - let initialInputSent = false; - let commandOutput = ''; - const EXIT_MARKER = '__OFFICER_EXIT_'; - // eslint-disable-next-line no-control-regex - const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); - - const handleOpen = () => { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - }; - - const handleMessage = (ev: MessageEvent) => { - try { - const msg = JSON.parse(ev.data as string); - if (msg.type === 'output') { - term.write(msg.data); - if (commandRef.current && !commandSent) { - commandSent = true; - setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - const wrapped = onCommandDoneRef.current - ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` - : commandRef.current; - ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); - } - }, 100); - } - if (!commandRef.current && initialInputRef.current && !initialInputSent) { - initialInputSent = true; - setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' })); - } - }, 500); - } - if (commandSent && !commandDone && onCommandDoneRef.current) { - commandOutput += msg.data as string; - const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/); - if (markerMatch) { - const exitCode = Number(markerMatch[1]); - const raw = stripAnsi(commandOutput).slice(0, markerMatch.index); - const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); - const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20))); - const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim(); - commandDone = true; - onCommandDoneRef.current(exitCode, output); - } - } - } else if (msg.type === 'exit') { - term.write('\r\n[Process exited]\r\n'); - onExitRef.current?.(); - } else if (msg.type === 'detached') { - term.write('\r\n[Session taken over]\r\n'); - } else if (msg.type === 'panel-refresh') { - onPanelRefreshRef.current?.(); - } - } catch { - // ignore - } - }; - - const handleClose = () => { - term.write('\r\n[Disconnected]\r\n'); - onDisconnectRef.current?.(); - }; - - ws.addEventListener('open', handleOpen); - ws.addEventListener('message', handleMessage); - ws.addEventListener('close', handleClose); - - const dataDisposable = term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'input', data })); - } - }); - - (container as any).__terminalCleanup = () => { - dataDisposable.dispose(); - ws.removeEventListener('open', handleOpen); - ws.removeEventListener('message', handleMessage); - ws.removeEventListener('close', handleClose); - ws.close(); - }; + connect(); }); }); + const originalCleanup = () => { + dataDisposable.dispose(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (reconnectTimer) clearTimeout(reconnectTimer); + const wsCleanup = (container as any).__terminalCleanup as (() => void) | undefined; + wsCleanup?.(); + }; + + (container as any).__terminalFullCleanup = originalCleanup; + return () => { disposed = true; - const cleanup = (container as any).__terminalCleanup as (() => void) | undefined; + const cleanup = (container as any).__terminalFullCleanup as (() => void) | undefined; cleanup?.(); + delete (container as any).__terminalFullCleanup; delete (container as any).__terminalCleanup; wsRef.current = null; termRef.current?.dispose(); diff --git a/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx b/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx index b49d5ac3..0c7309e6 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { useDashboardState } from 'state/useDashboardState'; +import { useGlobal } from 'hooks/useGlobal'; +import type { TerminalConnectionState } from './Terminal'; import { TerminalView } from './Terminal'; import { useTerminalMode } from './useTerminalMode'; @@ -34,7 +36,18 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => { }; }, [panelId]); + const [, setConnState] = useGlobal(`terminal-conn-${panelId}`, 'disconnected'); + const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]); + if (!sessionId) return null; - return ; + return ( + + ); }; diff --git a/src/workspaces/officerdev/src/apps/Terminal/index.tsx b/src/workspaces/officerdev/src/apps/Terminal/index.tsx index f63415fd..bc2be2e0 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/index.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/index.tsx @@ -7,7 +7,7 @@ import { HostTerminalWrapper } from './HostTerminalWrapper'; import { CommandTerminalWrapper } from './CommandTerminalWrapper'; import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers'; -export { TerminalView, type TerminalViewProps } from './Terminal'; +export { TerminalView, type TerminalViewProps, type TerminalConnectionState } from './Terminal'; const TmuxWrapper = ({ panelId }: { panelId: string }) => (