terminal auto-reconnect and connection indicator in panel header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,17 +133,13 @@ export const TerminalView = ({
|
||||
termRef.current = term;
|
||||
onReadyRef.current?.(term);
|
||||
|
||||
// Wait for layout to fully settle (double rAF), then fit + connect
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (disposed) return;
|
||||
// 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];
|
||||
|
||||
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;
|
||||
@@ -146,7 +148,26 @@ export const TerminalView = ({
|
||||
// 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 }));
|
||||
};
|
||||
|
||||
@@ -188,6 +209,7 @@ export const TerminalView = ({
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'exit') {
|
||||
processExited = true;
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
onExitRef.current?.();
|
||||
} else if (msg.type === 'detached') {
|
||||
@@ -201,34 +223,74 @@ export const TerminalView = ({
|
||||
};
|
||||
|
||||
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 (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.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();
|
||||
// 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(() => {
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user