terminal auto-reconnect and connection indicator in panel header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 04:58:14 +00:00
co-authored by Claude Opus 4.6
parent c54ff9da28
commit ed67cfa8e9
6 changed files with 229 additions and 106 deletions
@@ -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<string, string> = {};
@@ -36,10 +38,22 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR
};
}, [panelId]);
const [, setConnState] = useGlobal<TerminalConnectionState>(`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 <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} onPanelRefresh={onPanelRefresh} />;
return (
<TerminalView
className="h-full w-full p-2"
sessionId={sessionId}
cwd={cwd}
initialInput={fullCommand}
onPanelRefresh={onPanelRefresh}
onConnectionChange={onConnectionChange}
/>
);
};
@@ -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<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
if (state === 'connected') {
return <Circle className="h-2 w-2 shrink-0 fill-emerald-500 text-emerald-500" />;
}
if (state === 'reconnecting') {
return <Loader2 className="h-3 w-3 shrink-0 text-yellow-500 animate-spin" />;
}
return <Circle className="h-2 w-2 shrink-0 fill-red-500 text-red-500" />;
};
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
return (
<>
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const HostTerminalHeader = () => {
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
return (
<>
<Monitor className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const TmuxHeader = () => {
export const TmuxHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
return (
<>
<Columns2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Tmux</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const NvimHeader = () => {
export const NvimHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
return (
<>
<PenLine className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Neovim</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const ClaudeCodeHeader = () => {
export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
return (
<>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Claude Code</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
@@ -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<string, string> = {};
@@ -39,7 +41,18 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
);
}
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
return (
<TerminalView
className="h-full w-full p-2"
sessionId={sessionId}
sandboxed={false}
cwd={cwd}
onConnectionChange={onConnectionChange}
/>
);
};
@@ -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<TerminalTheme> = {
@@ -71,6 +74,7 @@ export const TerminalView = ({
onCommandDone,
onDisconnect,
onPanelRefresh,
onConnectionChange,
}: TerminalViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
@@ -81,6 +85,7 @@ export const TerminalView = ({
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
const onPanelRefreshRef = useRef<TerminalViewProps['onPanelRefresh']>(onPanelRefresh);
const onConnectionChangeRef = useRef<TerminalViewProps['onConnectionChange']>(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<typeof setTimeout> | 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();
@@ -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<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={sandboxed} cwd={cwd} />;
return (
<TerminalView
className="h-full w-full p-2"
sessionId={sessionId}
sandboxed={sandboxed}
cwd={cwd}
onConnectionChange={onConnectionChange}
/>
);
};
@@ -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 }) => (
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />