import { useState, useEffect, useRef, useCallback } from "react"; import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react"; import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; import type { FitAddon } from "@xterm/addon-fit"; /** Timeout for xterm.js dynamic imports + terminal.open() setup. */ const XTERM_INIT_TIMEOUT_MS = 10000; const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const; function isRetryableDynamicImportError(error: unknown): boolean { const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error); return ( message.includes("MIME type") || message.includes("Failed to fetch dynamically imported module") ); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function retryDynamicImport( importFactory: () => Promise, retryDelaysMs: readonly number[] = XTERM_IMPORT_RETRY_DELAYS_MS, ): Promise { let originalError: unknown; for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { try { return await importFactory(); } catch (error) { if (!isRetryableDynamicImportError(error)) { throw error; } if (originalError === undefined) { originalError = error; } const delayMs = retryDelaysMs[attempt]; if (delayMs === undefined) { throw originalError ?? error; } const message = error instanceof Error ? error.message : String(error); console.warn( `[TerminalModal] Dynamic xterm import failed (attempt ${attempt + 1}/${retryDelaysMs.length + 1}). Retrying in ${delayMs}ms...`, message, ); await sleep(delayMs); } } throw originalError ?? new Error("Dynamic import failed"); } /** Whether the current device is likely mobile (touch-primary, small viewport). */ function isMobileDevice(): boolean { if (typeof window === "undefined") return false; const hasTouchScreen = "ontouchstart" in window || (navigator as any).maxTouchPoints > 0; const isNarrow = window.innerWidth <= 768; return hasTouchScreen && isNarrow; } /** * Compute how many CSS pixels the virtual keyboard covers from the bottom * of the layout viewport. Returns 0 on desktop or when visualViewport is * unavailable. * * Strategy: * - Primary: window.innerHeight - vv.offsetTop - vv.height * Works on Chrome Android where window.innerHeight stays at full height. * - Fallback: initial viewport height - vv.height - vv.offsetTop * Works on iOS Safari where window.innerHeight shrinks with the keyboard. */ function getKeyboardOverlap(): number { if (typeof window === "undefined" || !window.visualViewport) return 0; const vv = window.visualViewport; const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height); if (chromeOverlap > 0) return chromeOverlap; // On iOS Safari, window.innerHeight shrinks to match visualViewport. // Detect keyboard by checking if visual viewport is shorter than initial // height by more than 80px (with a 30px noise filter). const initialHeight = getInitialViewportHeight(); const gap = initialHeight - vv.offsetTop - vv.height; // Minimum 30px gap required to filter noise (address bar, toolbar changes). // Threshold of 80px: only consider keyboard present when gap exceeds this. return gap >= 30 && gap > 80 ? gap : 0; } /** Cached initial viewport height before any keyboard opened. */ let _initialViewportHeight: number | null = null; /** * Returns the viewport height at page load (before any keyboard opens). * Cached after first read. */ function getInitialViewportHeight(): number { if (_initialViewportHeight === null) { _initialViewportHeight = window.innerHeight; } return _initialViewportHeight; } /** Reset the cached initial viewport height. Exported for tests only. */ export function _resetInitialViewportHeight(): void { _initialViewportHeight = null; } interface TerminalModalProps { isOpen: boolean; onClose: () => void; initialCommand?: string; projectId?: string; } /** * Interactive terminal modal component using xterm.js and node-pty. * * Provides a fully functional PTY terminal where users can execute commands * in the project's working directory. Features include: * - Real-time bidirectional communication via WebSocket * - Multiple terminal tabs with session persistence * - xterm.js for proper terminal emulation * - Copy/paste support * - Terminal zoom (Ctrl++/Ctrl+-/Ctrl+0) * - Auto-resizing to container * - Reconnection support * * The terminal spawns a real shell (bash/zsh/powershell based on platform). */ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: TerminalModalProps) { const [error, setError] = useState(null); const [exitCode, setExitCode] = useState(null); const [xtermReady, setXtermReady] = useState(false); const [xtermInitError, setXtermInitError] = useState(null); const [openGeneration, setOpenGeneration] = useState(0); const [keyboardOverlap, setKeyboardOverlap] = useState(0); const [viewportHeight, setViewportHeight] = useState(null); const terminalRef = useRef(null); const modalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const hasInitialCommandRun = useRef(false); const xtermInitializedRef = useRef(false); const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null); const keyboardOverlapRef = useRef(0); /** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */ const pendingFitRef = useRef(null); /** Tracks the previous projectId to detect project switches and invalidate xterm. */ const previousProjectIdRef = useRef(projectId); // Keep the latest keyboard overlap in a ref so async xterm setup can read // current mobile keyboard state without forcing the init effect to re-run. keyboardOverlapRef.current = keyboardOverlap; /** * Fit xterm and publish cols/rows for a specific terminal session. * * FN-1234 root cause: mobile visualViewport rAF callbacks can fire while * tab switching is still re-initializing xterm asynchronously. Without a * session guard, stale deferred work can mutate whichever xterm instance is * currently in refs, causing the newly active tab to display stale output. */ const fitAndResizeForSession = useCallback((expectedSessionId?: string) => { if (expectedSessionId && xtermInitializedRef.current !== expectedSessionId) { return; } const currentFitAddon = fitAddonRef.current; const currentXterm = xtermRef.current; const currentResize = resizeRef.current; if (!currentFitAddon || !currentXterm) { return; } if (expectedSessionId && xtermInitializedRef.current !== expectedSessionId) { return; } try { const fitAddon = currentFitAddon as InstanceType; fitAddon.fit(); if (currentResize) { currentResize(currentXterm.cols, currentXterm.rows); } } catch { // Ignore fit errors during viewport transitions } }, []); // Bump open generation whenever the modal opens so the initialCommand // effect re-evaluates after a close/reopen cycle (deps may be identical). useEffect(() => { if (isOpen) { setOpenGeneration((g) => g + 1); } }, [isOpen]); // Track virtual keyboard overlap on mobile so the terminal entry area // stays visible above the keyboard. On desktop this is a no-op. useEffect(() => { if (!isOpen || !isMobileDevice()) return; const vv = window.visualViewport; if (!vv) return; const update = () => { const overlap = getKeyboardOverlap(); setKeyboardOverlap(overlap); // Track the actual visual viewport height for modal sizing. // This is more reliable than 100dvh on iOS Safari where // the dynamic viewport height behavior varies by browser version. setViewportHeight(vv.height); // Scroll the modal so the status bar (bottom edge) stays visible // when the virtual keyboard pushes the viewport up. if (overlap > 0 && modalRef.current?.scrollIntoView) { modalRef.current.scrollIntoView({ block: "end", behavior: "smooth" }); } // Re-fit xterm when viewport changes affect available height. // The keyboard opening/closing changes the modal's max-height via // CSS --keyboard-overlap, so xterm needs to recalculate rows/cols. // // IMPORTANT: We must defer fitAddon.fit() until AFTER React has // committed the state changes above (setKeyboardOverlap, setViewportHeight) // and the browser has repainted the new modal dimensions. Without this // deferral, fit() measures the OLD (pre-keyboard) container dimensions // because React state updates are asynchronous — the inline style with // the new --keyboard-overlap / --vv-height values hasn't been applied yet. // // requestAnimationFrame ensures we run after the next paint, at which // point the DOM reflects the updated CSS variables and the modal has // its correct constrained height. // // Coalesce rapid events (keyboard animating open) by cancelling any // previously scheduled rAF before scheduling a new one. if (pendingFitRef.current !== null) { cancelAnimationFrame(pendingFitRef.current); pendingFitRef.current = null; } const scheduledSessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined; pendingFitRef.current = requestAnimationFrame(() => { pendingFitRef.current = null; fitAndResizeForSession(scheduledSessionId); }); }; update(); // initial measurement vv.addEventListener("resize", update); vv.addEventListener("scroll", update); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); // Cancel any pending deferred fit if (pendingFitRef.current !== null) { cancelAnimationFrame(pendingFitRef.current); pendingFitRef.current = null; } setKeyboardOverlap(0); setViewportHeight(null); }; }, [fitAndResizeForSession, isOpen]); // Use the session management hook const { tabs, activeTab, isReady, bootstrapError, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab, retryBootstrap, replaceActiveTabSession, } = useTerminalSessions(projectId); // Get the WebSocket connection for the active session const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect, onSessionInvalid } = useTerminal(activeTab?.sessionId ?? null, projectId); // Keep a ref to resize so the viewport-change effect can call it // without needing resize as a dependency (avoids ordering issues). resizeRef.current = resize; // Initialize xterm.js when session is ready // Depends on `isReady`, `activeTab`, and xtermReady to properly reinitialize on tab switch useEffect(() => { if (!isOpen || !isReady || !activeTab) return; const currentSessionId = activeTab.sessionId; // Detect project switch: if projectId changed, invalidate xterm even if sessionId is the same. // This ensures xterm content from the previous project is not displayed in the new project. const projectChanged = previousProjectIdRef.current !== projectId; if (projectChanged) { previousProjectIdRef.current = projectId; } // If already initialized for this session AND project hasn't changed, skip if (xtermInitializedRef.current === currentSessionId && xtermRef.current && !projectChanged) { return; } // Clean up existing xterm if switching sessions/projects or if DOM was cleared if (xtermRef.current && (xtermInitializedRef.current !== currentSessionId || projectChanged)) { xtermRef.current.dispose(); xtermRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; setXtermReady(false); setXtermInitError(null); } let mounted = true; let watchdogTimer: ReturnType; const initTerminal = async () => { // Dynamically import xterm modules with watchdog timeout const importsPromise = retryDynamicImport(() => Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-web-links"), ]), ); // Watchdog: reject if imports + setup take too long const timeoutPromise = new Promise((_resolve, reject) => { watchdogTimer = setTimeout(() => { reject(new Error("xterm initialization timed out")); }, XTERM_INIT_TIMEOUT_MS); }); let terminal: InstanceType; let fitAddon: InstanceType; try { const [{ Terminal: TerminalCtor }, { FitAddon: FitAddonCtor }, { WebLinksAddon }] = await Promise.race([importsPromise, timeoutPromise]); if (!mounted || !terminalRef.current || xtermRef.current) return; // Create terminal instance terminal = new TerminalCtor({ cursorBlink: true, cursorStyle: "block", fontSize: 14, fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', theme: { background: "#1e1e1e", foreground: "#d4d4d4", cursor: "#d4d4d4", selectionBackground: "#264f78", black: "#1e1e1e", red: "#f48771", green: "#4ec9b0", yellow: "#dcdcaa", blue: "#569cd6", magenta: "#c586c0", cyan: "#9cdcfe", white: "#d4d4d4", }, allowProposedApi: true, scrollback: 5000, }); // Load addons fitAddon = new FitAddonCtor(); terminal.loadAddon(fitAddon); const webLinksAddon = new WebLinksAddon(); terminal.loadAddon(webLinksAddon); // Try to load WebGL addon for better performance // Skip WebGL on mobile devices to avoid rendering artifacts (e.g., garbled // Unicode characters in powerline prompt symbols on iOS Safari/WebKit). if (!isMobileDevice()) { try { const { WebglAddon } = await import("@xterm/addon-webgl"); const webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { webglAddon.dispose(); }); terminal.loadAddon(webglAddon); } catch { // WebGL not available, fallback to canvas } } // Open terminal in container terminal.open(terminalRef.current); // Clear watchdog — imports and open() succeeded within deadline clearTimeout(watchdogTimer); // Ensure xterm's textarea receives focus for keyboard input. // xterm.js creates a hidden textarea that captures keyboard events. // We focus the textarea directly and dispatch a synthetic click on // the container to trigger xterm's internal focus tracking. const helperTextarea = terminalRef.current?.querySelector( ".xterm-helper-textarea", ) as HTMLTextAreaElement | undefined; if (helperTextarea) { helperTextarea.focus(); } // Dispatch a click event on the xterm container to ensure xterm's // internal focus tracking is properly initialized. This is necessary // because xterm relies on canvas click events for full focus setup. if (terminalRef.current) { try { terminalRef.current.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, })); } catch { // Ignore event dispatch errors in non-browser environments } } // Initial fit setTimeout(() => { fitAddon.fit(); // Re-focus after fit in case the DOM changed const textarea = terminalRef.current?.querySelector( ".xterm-helper-textarea", ) as HTMLTextAreaElement | undefined; if (textarea) { textarea.focus(); } }, 50); xtermRef.current = terminal; fitAddonRef.current = fitAddon; xtermInitializedRef.current = currentSessionId; // If the virtual keyboard opened while xterm was still in async // initialization for this tab, force a post-init fit so this new // session uses the already-constrained mobile modal height. if (keyboardOverlapRef.current > 0) { if (pendingFitRef.current !== null) { cancelAnimationFrame(pendingFitRef.current); pendingFitRef.current = null; } pendingFitRef.current = requestAnimationFrame(() => { pendingFitRef.current = null; fitAndResizeForSession(currentSessionId); }); } // Signal that xterm is ready so the subscription effect re-runs setXtermReady(true); // Clear any prior xterm init error setXtermInitError(null); // Handle data from terminal (user input) const dataHandler = terminal.onData((data) => { sendInput(data); }); // Handle resize const resizeHandler = () => { if (fitAddonRef.current && xtermRef.current) { try { (fitAddonRef.current as InstanceType).fit(); const { cols, rows } = xtermRef.current; resize(cols, rows); } catch { // Ignore fit errors } } }; window.addEventListener("resize", resizeHandler); return () => { dataHandler.dispose(); window.removeEventListener("resize", resizeHandler); }; } catch (err) { clearTimeout(watchdogTimer); if (!mounted) return; const message = err instanceof Error ? err.message : "xterm initialization failed"; setXtermInitError(message); } }; const cleanupPromise = initTerminal(); return () => { mounted = false; clearTimeout(watchdogTimer); cleanupPromise.then((cleanup) => cleanup?.()); // Don't dispose xterm here - it should persist across tab switches // Only dispose when the modal is fully closed }; }, [fitAndResizeForSession, isOpen, isReady, activeTab, activeTab?.sessionId, sendInput, resize]); // Cleanup xterm when modal closes useEffect(() => { if (isOpen) return; // Modal is closed - cleanup xterm if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; xtermInitializedRef.current = false; setXtermReady(false); setXtermInitError(null); hasInitialCommandRun.current = false; setError(null); setExitCode(null); }, [isOpen]); // Subscribe to terminal data. // Depends on `xtermReady` so subscriptions are established after the // async xterm initialization completes and xtermRef.current is set. // Depends on `activeTab?.sessionId` (not just `activeTab?.id`) so that // creating a new tab triggers rebinding to the new session's WebSocket // callbacks. Without sessionId, the effect would miss session switches // that happen within the same modal session. useEffect(() => { if (!xtermReady || !xtermRef.current || !activeTab) return; const expectedSessionId = activeTab.sessionId; const writeToExpectedSession = (data: string) => { if (xtermInitializedRef.current !== expectedSessionId) { return; } xtermRef.current?.write(data); }; const unsubData = onData((data) => { writeToExpectedSession(data); }); const unsubScrollback = onScrollback((data) => { writeToExpectedSession(data); }); const unsubConnect = onConnect((info) => { // Update tab title with shell name updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell); }); const unsubExit = onExit((code) => { if (xtermInitializedRef.current !== expectedSessionId) { return; } setExitCode(code); xtermRef.current?.write(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m\r\n`); }); return () => { unsubData(); unsubScrollback(); unsubConnect(); unsubExit(); }; }, [xtermReady, activeTab?.sessionId, activeTab?.id, activeTab, connectionStatus, onData, onScrollback, onConnect, onExit, updateTabTitle]); // Run initial command when connected. // Tracks the last command that was sent so that a new command provided // while the terminal is already open (e.g., running a different script) // will be executed immediately without requiring a modal close/reopen. // Depends on openGeneration so the command re-fires after close/reopen. useEffect(() => { if (connectionStatus === "connected" && initialCommand && hasInitialCommandRun.current !== initialCommand && activeTab) { hasInitialCommandRun.current = initialCommand; // Small delay to let shell initialize setTimeout(() => { sendInput(initialCommand + "\n"); }, 500); } }, [connectionStatus, initialCommand, sendInput, activeTab, openGeneration]); // Handle keyboard shortcuts (zoom) useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return; // Zoom in: Ctrl/Cmd + Plus if (e.code === "Equal" || e.code === "NumpadAdd") { e.preventDefault(); if (xtermRef.current) { const currentSize = xtermRef.current.options.fontSize || 14; xtermRef.current.options.fontSize = Math.min(currentSize + 1, 32); fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } // Zoom out: Ctrl/Cmd + Minus if (e.code === "Minus" || e.code === "NumpadSubtract") { e.preventDefault(); if (xtermRef.current) { const currentSize = xtermRef.current.options.fontSize || 14; xtermRef.current.options.fontSize = Math.max(currentSize - 1, 8); fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } // Reset zoom: Ctrl/Cmd + 0 if (e.code === "Digit0" || e.code === "Numpad0") { e.preventDefault(); if (xtermRef.current) { xtermRef.current.options.fontSize = 14; fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Focus terminal when connected useEffect(() => { if (connectionStatus === "connected" && xtermRef.current) { setTimeout(() => { if (!xtermRef.current || !terminalRef.current) return; // Focus the xterm textarea directly for keyboard input const helperTextarea = terminalRef.current.querySelector( ".xterm-helper-textarea", ) as HTMLTextAreaElement | undefined; if (helperTextarea) { helperTextarea.focus(); } // Also dispatch a click to trigger xterm's internal focus tracking try { terminalRef.current.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, })); } catch { // Ignore event dispatch errors in non-browser environments } }, 100); } }, [connectionStatus]); /** * On mobile browsers, opening the soft keyboard requires focus to happen * within a real user gesture. Programmatic focus in async effects is often * ignored even though xterm stays connected and receives output. */ const handleTerminalGestureFocus = useCallback(() => { if (!terminalRef.current) return; // Ensure xterm updates its own focus state first. xtermRef.current?.focus(); const helperTextarea = terminalRef.current.querySelector( ".xterm-helper-textarea", ) as HTMLTextAreaElement | undefined; if (!helperTextarea) return; // Mobile Safari/Chrome soft keyboard heuristics are stricter than desktop: // keep attributes explicit and focus from a direct user gesture. helperTextarea.autocapitalize = "off"; helperTextarea.autocomplete = "off"; (helperTextarea as unknown as { autocorrect: string }).autocorrect = "off"; helperTextarea.spellcheck = false; helperTextarea.setAttribute("inputmode", "text"); try { helperTextarea.focus({ preventScroll: true }); } catch { helperTextarea.focus(); } // Keep caret at end so subsequent key presses append naturally. const caretPos = helperTextarea.value.length; helperTextarea.setSelectionRange(caretPos, caretPos); }, []); /** * Auto-recover when the server reports the session is invalid (code 4004). * * Without this handler the user sees "Disconnected" with a reconnect button * that retries the same stale session forever — the only fix was a full page * reload. Now we silently create a fresh session on the active tab and let * the normal connect effect (useTerminal's sessionId dep) open a new * WebSocket to the replacement session. */ useEffect(() => { const unsub = onSessionInvalid(() => { // Clear terminal display for the fresh session xtermRef.current?.clear(); setExitCode(null); hasInitialCommandRun.current = false; // Dispose current xterm so the init effect re-runs with the new session if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; xtermInitializedRef.current = false; setXtermReady(false); setXtermInitError(null); replaceActiveTabSession().catch((err) => { console.error("Failed to replace invalid terminal session:", err); }); }); return unsub; }, [onSessionInvalid, replaceActiveTabSession]); // Handle overlay click to close const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }, [onClose] ); // Handle clear button const handleClear = useCallback(() => { xtermRef.current?.clear(); }, []); // Handle restart - create new session in the current tab const handleRestart = useCallback(async () => { // Clear terminal display xtermRef.current?.clear(); setExitCode(null); hasInitialCommandRun.current = false; // Restart the active tab's session try { await restartActiveTab(); } catch (err: any) { setError(err.message || "Failed to restart terminal session"); } }, [restartActiveTab]); // Reinitialize xterm UI without recreating the session. // Used when xterm initialization fails/stalls but the backend session is fine. const handleReinitialize = useCallback(() => { // Dispose any partially-initialized xterm if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; xtermInitializedRef.current = false; // Clear error state and reset readiness so the init effect re-runs setXtermInitError(null); setXtermReady(false); }, []); if (!isOpen) return null; const getStatusIndicator = () => { switch (connectionStatus) { case "connected": return ; case "connecting": case "reconnecting": return ; case "disconnected": return ; default: return null; } }; // Determine loading state — when bootstrapError or xtermInitError is set, we are NOT loading // (we have a definitive error to show instead of an indefinite spinner). const isLoading = !isReady || (!activeTab && !bootstrapError) || (!xtermReady && !xtermInitError); return (
0 ? { "--overlay-padding-top": "0px", } as React.CSSProperties : undefined } >
0 ? { "--keyboard-overlap": `${keyboardOverlap}px`, // On mobile with keyboard open, constrain to visualViewport height // so the modal (including status bar) fits entirely above the keyboard. // This is more reliable than 100dvh which behaves differently // across Chrome Android vs iOS Safari. "--vv-height": viewportHeight ? `${viewportHeight}px` : undefined, } as React.CSSProperties : undefined } > {/* Header — on mobile (≤768px) flex-wrap stacks tabs and actions on separate rows; .terminal-title is hidden; action button labels are hidden (icons only) */}
{/* Tab Bar */}
{tabs.map((tab) => (
setActiveTab(tab.id)} title={tab.title} role="tab" aria-selected={tab.isActive} > {tab.title} {tabs.length > 1 && ( )}
))}
{/* Status indicator */}
{getStatusIndicator()}
{/* Actions — labels hidden on mobile via .terminal-action-label */}
{connectionStatus === "disconnected" && activeTab && ( )} {exitCode !== null && ( )}
{/* Error message */} {error && (
{error}
)} {/* Terminal container */}
{isLoading && !bootstrapError && (
Starting terminal...
)} {bootstrapError && !activeTab && (
Failed to start terminal: {bootstrapError}
)} {xtermInitError && activeTab && (
Terminal UI failed to initialize: {xtermInitError}
)} {/* Always render the xterm container (no display:none) so that terminal.open() can measure its dimensions even during a tab switch. The loading overlay (position: absolute) visually covers it until xterm is ready. Use key={sessionId} to force a clean DOM remount when switching tabs — this prevents stale xterm state from the previous session. */}
{/* Connection status bar */}
{connectionStatus === "connected" && "Connected"} {connectionStatus === "connecting" && "Connecting..."} {connectionStatus === "reconnecting" && "Reconnecting..."} {connectionStatus === "disconnected" && "Disconnected"} {exitCode !== null && ( Exit: {exitCode} )} Ctrl++/- zoom • Ctrl+L clear • Esc close
); }