Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions app/components/workbench/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ export const Terminal = memo(
({ className, theme, readonly, id, onTerminalReady, onTerminalResize }, ref) => {
const terminalElementRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm>();
const fitAddonRef = useRef<FitAddon>();
const resizeObserverRef = useRef<ResizeObserver>();

useEffect(() => {
const element = terminalElementRef.current!;

const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;

const terminal = new XTerm({
cursorBlink: true,
Expand All @@ -41,28 +44,60 @@ export const Terminal = memo(
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
fontSize: 12,
fontFamily: 'Menlo, courier-new, courier, monospace',
allowProposedApi: true,
scrollback: 1000,

// Enable better clipboard handling
rightClickSelectsWord: true,
});

terminalRef.current = terminal;

terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(element);

const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
onTerminalResize?.(terminal.cols, terminal.rows);
// Error handling for addon loading
try {
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(element);
} catch (error) {
logger.error(`Failed to initialize terminal [${id}]:`, error);

// Attempt recovery
setTimeout(() => {
try {
terminal.open(element);
fitAddon.fit();
} catch (retryError) {
logger.error(`Terminal recovery failed [${id}]:`, retryError);
}
}, 100);
}

const resizeObserver = new ResizeObserver((entries) => {
// Debounce resize events
if (entries.length > 0) {
try {
fitAddon.fit();
onTerminalResize?.(terminal.cols, terminal.rows);
} catch (error) {
logger.error(`Resize error [${id}]:`, error);
}
}
});

resizeObserverRef.current = resizeObserver;
resizeObserver.observe(element);

logger.debug(`Attach [${id}]`);

onTerminalReady?.(terminal);

return () => {
resizeObserver.disconnect();
terminal.dispose();
try {
resizeObserver.disconnect();
terminal.dispose();
} catch (error) {
logger.error(`Cleanup error [${id}]:`, error);
}
};
}, []);

Expand All @@ -78,14 +113,17 @@ export const Terminal = memo(
useImperativeHandle(ref, () => {
return {
reloadStyles: () => {
const terminal = terminalRef.current!;
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
const terminal = terminalRef.current;

if (terminal) {
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
}
},
getTerminal: () => {
return terminalRef.current;
},
};
}, []);
}, [readonly]);

return <div className={className} ref={terminalElementRef} />;
},
Expand Down
185 changes: 185 additions & 0 deletions app/components/workbench/terminal/TerminalManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { createScopedLogger } from '~/utils/logger';

const logger = createScopedLogger('TerminalManager');

interface TerminalManagerProps {
terminal: XTerm | null;
isActive: boolean;
onReconnect?: () => void;
}

export const TerminalManager = memo(({ terminal, isActive, onReconnect }: TerminalManagerProps) => {
const [isHealthy, setIsHealthy] = useState(true);
const [lastActivity, setLastActivity] = useState(Date.now());
const healthCheckIntervalRef = useRef<NodeJS.Timeout>();
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
const HEALTH_CHECK_INTERVAL = 5000; // 5 seconds
const INACTIVITY_THRESHOLD = 30000; // 30 seconds

// Monitor terminal health
const checkTerminalHealth = useCallback(() => {
if (!terminal || !isActive) {
return;
}

try {
// Check if terminal is still responsive
const currentTime = Date.now();
const inactivityDuration = currentTime - lastActivity;

// If terminal has been inactive for too long, attempt recovery
if (inactivityDuration > INACTIVITY_THRESHOLD) {
logger.warn(`Terminal inactive for ${inactivityDuration}ms, attempting recovery`);
handleTerminalRecovery();
}

// Test if terminal can write - check if terminal buffer exists
try {
// Try to access terminal buffer to check if it's still valid
const buffer = terminal.buffer;

if (!buffer || !buffer.active) {
logger.error('Terminal buffer invalid');
setIsHealthy(false);
handleTerminalRecovery();
}
} catch {
logger.error('Terminal buffer check failed');
setIsHealthy(false);
handleTerminalRecovery();
}
} catch (error) {
logger.error('Terminal health check failed:', error);
setIsHealthy(false);
handleTerminalRecovery();
}
}, [terminal, isActive, lastActivity]);

// Handle terminal recovery
const handleTerminalRecovery = useCallback(() => {
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
logger.error('Max reconnection attempts reached');
terminal?.write('\x1b[31m\n⚠️ Terminal connection lost. Please refresh the page.\n\x1b[0m');

return;
}

reconnectAttemptsRef.current++;
logger.info(`Attempting terminal recovery (attempt ${reconnectAttemptsRef.current})`);

try {
// Clear any stuck event listeners
if (terminal) {
// Force focus back to terminal
terminal.focus();

// Clear selection if any
terminal.clearSelection();

// Reset cursor position
terminal.scrollToBottom();

// Write recovery message
terminal.write('\x1b[33m\nπŸ”„ Reconnecting terminal...\n\x1b[0m');

// Trigger reconnection callback
onReconnect?.();

// Reset health status
setIsHealthy(true);
setLastActivity(Date.now());
reconnectAttemptsRef.current = 0;

terminal.write('\x1b[32mβœ“ Terminal reconnected successfully\n\x1b[0m');
}
} catch (error) {
logger.error('Terminal recovery failed:', error);
setIsHealthy(false);
}
}, [terminal, onReconnect]);

// Monitor terminal input/output
useEffect(() => {
if (!terminal) {
return undefined;
}

const disposables: Array<{ dispose: () => void }> = [];

// Track terminal activity
const onDataDisposable = terminal.onData(() => {
setLastActivity(Date.now());
setIsHealthy(true);
reconnectAttemptsRef.current = 0;
});

const onKeyDisposable = terminal.onKey(() => {
setLastActivity(Date.now());
setIsHealthy(true);
});

disposables.push(onDataDisposable);
disposables.push(onKeyDisposable);

// Set up paste handler via terminal's onKey
const onPasteKeyDisposable = terminal.onKey((e) => {
// Detect Ctrl+V or Cmd+V
if ((e.domEvent.ctrlKey || e.domEvent.metaKey) && e.domEvent.key === 'v') {
if (!isActive) {
return;
}

// Read from clipboard if available
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard
.readText()
.then((text) => {
if (text && terminal) {
terminal.paste(text);
setLastActivity(Date.now());
}
})
.catch((err) => {
logger.warn('Failed to read clipboard:', err);
});
}
}
});

disposables.push(onPasteKeyDisposable);

return () => {
disposables.forEach((d) => d.dispose());
};
}, [terminal, isActive, isHealthy, handleTerminalRecovery]);

// Set up health check interval
useEffect(() => {
if (isActive) {
healthCheckIntervalRef.current = setInterval(checkTerminalHealth, HEALTH_CHECK_INTERVAL);
}

return () => {
if (healthCheckIntervalRef.current) {
clearInterval(healthCheckIntervalRef.current);
}
};
}, [isActive, checkTerminalHealth]);

// Auto-focus terminal when it becomes active
useEffect(() => {
if (isActive && terminal && isHealthy) {
// Small delay to ensure DOM is ready
setTimeout(() => {
terminal.focus();
}, 100);
}
}, [isActive, terminal, isHealthy]);

return null; // This is a utility component, no UI
});

TerminalManager.displayName = 'TerminalManager';
Loading