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 { useWorkspace } from '../../components/Workspace';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
import { TerminalView } from './Terminal';
|
import { TerminalView } from './Terminal';
|
||||||
|
|
||||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||||
@@ -36,10 +38,22 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR
|
|||||||
};
|
};
|
||||||
}, [panelId]);
|
}, [panelId]);
|
||||||
|
|
||||||
|
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||||
|
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||||
|
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null;
|
const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null;
|
||||||
const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command;
|
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 { 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();
|
const { cwd } = useWorkspace();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||||
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HostTerminalHeader = () => {
|
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||||
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TmuxHeader = () => {
|
export const TmuxHeader = ({ panelId }: { panelId: string }) => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Tmux</span>
|
<span className="text-xs font-medium shrink-0">Tmux</span>
|
||||||
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NvimHeader = () => {
|
export const NvimHeader = ({ panelId }: { panelId: string }) => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Neovim</span>
|
<span className="text-xs font-medium shrink-0">Neovim</span>
|
||||||
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ClaudeCodeHeader = () => {
|
export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Claude Code</span>
|
<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>
|
<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 { useWorkspace } from '../../components/Workspace';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
import { TerminalView } from './Terminal';
|
import { TerminalView } from './Terminal';
|
||||||
|
|
||||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
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;
|
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;
|
selectionBackground?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TerminalConnectionState = 'connected' | 'disconnected' | 'reconnecting';
|
||||||
|
|
||||||
export type TerminalViewProps = {
|
export type TerminalViewProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
@@ -30,6 +32,7 @@ export type TerminalViewProps = {
|
|||||||
onCommandDone?: (exitCode: number, output: string) => void;
|
onCommandDone?: (exitCode: number, output: string) => void;
|
||||||
onDisconnect?: () => void;
|
onDisconnect?: () => void;
|
||||||
onPanelRefresh?: () => void;
|
onPanelRefresh?: () => void;
|
||||||
|
onConnectionChange?: (state: TerminalConnectionState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_THEME: Required<TerminalTheme> = {
|
const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||||
@@ -71,6 +74,7 @@ export const TerminalView = ({
|
|||||||
onCommandDone,
|
onCommandDone,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onPanelRefresh,
|
onPanelRefresh,
|
||||||
|
onConnectionChange,
|
||||||
}: TerminalViewProps) => {
|
}: TerminalViewProps) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<XTerm | null>(null);
|
const termRef = useRef<XTerm | null>(null);
|
||||||
@@ -81,6 +85,7 @@ export const TerminalView = ({
|
|||||||
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
||||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||||
const onPanelRefreshRef = useRef<TerminalViewProps['onPanelRefresh']>(onPanelRefresh);
|
const onPanelRefreshRef = useRef<TerminalViewProps['onPanelRefresh']>(onPanelRefresh);
|
||||||
|
const onConnectionChangeRef = useRef<TerminalViewProps['onConnectionChange']>(onConnectionChange);
|
||||||
const commandRef = useRef(command);
|
const commandRef = useRef(command);
|
||||||
const initialInputRef = useRef(initialInput);
|
const initialInputRef = useRef(initialInput);
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ export const TerminalView = ({
|
|||||||
onCommandDoneRef.current = onCommandDone;
|
onCommandDoneRef.current = onCommandDone;
|
||||||
onDisconnectRef.current = onDisconnect;
|
onDisconnectRef.current = onDisconnect;
|
||||||
onPanelRefreshRef.current = onPanelRefresh;
|
onPanelRefreshRef.current = onPanelRefresh;
|
||||||
|
onConnectionChangeRef.current = onConnectionChange;
|
||||||
commandRef.current = command;
|
commandRef.current = command;
|
||||||
initialInputRef.current = initialInput;
|
initialInputRef.current = initialInput;
|
||||||
|
|
||||||
@@ -127,108 +133,164 @@ export const TerminalView = ({
|
|||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
onReadyRef.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
|
// Wait for layout to fully settle (double rAF), then fit + connect
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (disposed) return;
|
connect();
|
||||||
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
|
const cleanup = (container as any).__terminalFullCleanup as (() => void) | undefined;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
|
delete (container as any).__terminalFullCleanup;
|
||||||
delete (container as any).__terminalCleanup;
|
delete (container as any).__terminalCleanup;
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
termRef.current?.dispose();
|
termRef.current?.dispose();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
import { TerminalView } from './Terminal';
|
import { TerminalView } from './Terminal';
|
||||||
import { useTerminalMode } from './useTerminalMode';
|
import { useTerminalMode } from './useTerminalMode';
|
||||||
|
|
||||||
@@ -34,7 +36,18 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
};
|
};
|
||||||
}, [panelId]);
|
}, [panelId]);
|
||||||
|
|
||||||
|
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||||
|
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||||
|
|
||||||
if (!sessionId) return null;
|
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 { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
||||||
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers';
|
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 }) => (
|
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
||||||
|
|||||||
Reference in New Issue
Block a user