diff --git a/app/components/workbench/terminal/Terminal.tsx b/app/components/workbench/terminal/Terminal.tsx index d791363b33..719cc00188 100644 --- a/app/components/workbench/terminal/Terminal.tsx +++ b/app/components/workbench/terminal/Terminal.tsx @@ -27,12 +27,15 @@ export const Terminal = memo( ({ className, theme, readonly, id, onTerminalReady, onTerminalResize }, ref) => { const terminalElementRef = useRef(null); const terminalRef = useRef(); + const fitAddonRef = useRef(); + const resizeObserverRef = useRef(); useEffect(() => { const element = terminalElementRef.current!; const fitAddon = new FitAddon(); const webLinksAddon = new WebLinksAddon(); + fitAddonRef.current = fitAddon; const terminal = new XTerm({ cursorBlink: true, @@ -41,19 +44,47 @@ 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}]`); @@ -61,8 +92,12 @@ export const Terminal = memo( onTerminalReady?.(terminal); return () => { - resizeObserver.disconnect(); - terminal.dispose(); + try { + resizeObserver.disconnect(); + terminal.dispose(); + } catch (error) { + logger.error(`Cleanup error [${id}]:`, error); + } }; }, []); @@ -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
; }, diff --git a/app/components/workbench/terminal/TerminalManager.tsx b/app/components/workbench/terminal/TerminalManager.tsx new file mode 100644 index 0000000000..5154f054d6 --- /dev/null +++ b/app/components/workbench/terminal/TerminalManager.tsx @@ -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(); + 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'; diff --git a/app/components/workbench/terminal/TerminalTabs.tsx b/app/components/workbench/terminal/TerminalTabs.tsx index a503760f5f..893b181285 100644 --- a/app/components/workbench/terminal/TerminalTabs.tsx +++ b/app/components/workbench/terminal/TerminalTabs.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react'; -import React, { memo, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { Panel, type ImperativePanelHandle } from 'react-resizable-panels'; import { IconButton } from '~/components/ui/IconButton'; import { shortcutEventEmitter } from '~/lib/hooks'; @@ -7,6 +7,7 @@ import { themeStore } from '~/lib/stores/theme'; import { workbenchStore } from '~/lib/stores/workbench'; import { classNames } from '~/utils/classNames'; import { Terminal, type TerminalRef } from './Terminal'; +import { TerminalManager } from './TerminalManager'; import { createScopedLogger } from '~/utils/logger'; const logger = createScopedLogger('Terminal'); @@ -18,7 +19,7 @@ export const TerminalTabs = memo(() => { const showTerminal = useStore(workbenchStore.showTerminal); const theme = useStore(themeStore); - const terminalRefs = useRef>([]); + const terminalRefs = useRef>(new Map()); const terminalPanelRef = useRef(null); const terminalToggledByShortcut = useRef(false); @@ -32,33 +33,36 @@ export const TerminalTabs = memo(() => { } }; - const closeTerminal = (index: number) => { - if (index === 0) { - return; - } // Can't close bolt terminal + const closeTerminal = useCallback( + (index: number) => { + if (index === 0) { + return; + } // Can't close bolt terminal - const terminalRef = terminalRefs.current[index]; + const terminalRef = terminalRefs.current.get(index); - if (terminalRef?.getTerminal) { - const terminal = terminalRef.getTerminal(); + if (terminalRef?.getTerminal) { + const terminal = terminalRef.getTerminal(); - if (terminal) { - workbenchStore.detachTerminal(terminal); + if (terminal) { + workbenchStore.detachTerminal(terminal); + } } - } - // Remove the terminal from refs - terminalRefs.current.splice(index, 1); + // Remove the terminal from refs + terminalRefs.current.delete(index); - // Adjust terminal count and active terminal - setTerminalCount(terminalCount - 1); + // Adjust terminal count and active terminal + setTerminalCount(terminalCount - 1); - if (activeTerminal === index) { - setActiveTerminal(Math.max(0, index - 1)); - } else if (activeTerminal > index) { - setActiveTerminal(activeTerminal - 1); - } - }; + if (activeTerminal === index) { + setActiveTerminal(Math.max(0, index - 1)); + } else if (activeTerminal > index) { + setActiveTerminal(activeTerminal - 1); + } + }, + [activeTerminal, terminalCount], + ); useEffect(() => { return () => { @@ -98,9 +102,9 @@ export const TerminalTabs = memo(() => { }); const unsubscribeFromThemeStore = themeStore.subscribe(() => { - for (const ref of Object.values(terminalRefs.current)) { + terminalRefs.current.forEach((ref) => { ref?.reloadStyles(); - } + }); }); return () => { @@ -183,6 +187,26 @@ export const TerminalTabs = memo(() => { ); })} {terminalCount < MAX_TERMINALS && } + { + const ref = terminalRefs.current.get(activeTerminal); + + if (ref?.getTerminal()) { + const terminal = ref.getTerminal()!; + terminal.clear(); + terminal.focus(); + + if (activeTerminal === 0) { + workbenchStore.attachBoltTerminal(terminal); + } else { + workbenchStore.attachTerminal(terminal); + } + } + }} + /> { if (index == 0) { return ( - { - terminalRefs.current.push(ref); - }} - onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)} - onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} - theme={theme} - /> + + { + if (ref) { + terminalRefs.current.set(index, ref); + } + }} + onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)} + onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} + theme={theme} + /> + { + const ref = terminalRefs.current.get(index); + + if (ref?.getTerminal()) { + workbenchStore.attachBoltTerminal(ref.getTerminal()!); + } + }} + /> + ); } else { return ( - { - terminalRefs.current.push(ref); - }} - onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)} - onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} - theme={theme} - /> + + { + if (ref) { + terminalRefs.current.set(index, ref); + } + }} + onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)} + onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} + theme={theme} + /> + { + const ref = terminalRefs.current.get(index); + + if (ref?.getTerminal()) { + workbenchStore.attachTerminal(ref.getTerminal()!); + } + }} + /> + ); } })}