Files
fusion/packages/dashboard/app/components/TerminalModal.tsx
Fusion fc36fe6f91 feat(FN-2629): stabilize terminal modal font-size refit
- Enforce terminal xterm root height fill so inline fit heights do not collapse the modal terminal area
- Defer font-size-driven fit to the next animation frame and coalesce pending fits via pendingFitRef
- Remove eager refit calls from zoom keyboard/button handlers and rely on shared font-size effect scheduling
- Expand TerminalModal tests to assert font-size controls and keyboard zoom continue to trigger xterm refits
2026-04-26 22:14:03 -07:00

1364 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./TerminalModal.css";
import { useState, useEffect, useRef, useCallback } from "react";
import { getErrorMessage } from "@fusion/core";
import {
X,
Trash2,
Terminal as TerminalIcon,
RefreshCw,
Minus,
Plus,
Keyboard,
} 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;
const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
const DEFAULT_FONT_SIZE = 14;
const MIN_TERMINAL_FONT_SIZE = 8;
const MAX_TERMINAL_FONT_SIZE = 32;
export function ctrlChar(key: string): string {
if (!key) {
return "";
}
const normalized = key.slice(0, 1).toUpperCase();
if (normalized === "[") {
return "\x1b";
}
if (normalized >= "A" && normalized <= "Z") {
return String.fromCharCode(normalized.charCodeAt(0) - 64);
}
return key;
}
export function altChar(key: string): string {
return `\x1b${key}`;
}
interface ShortcutKey {
label: string;
key: string;
description?: string;
}
export const SHORTCUT_KEYS: ShortcutKey[] = [
{ label: "C", key: "c", description: "SigInt" },
{ label: "D", key: "d", description: "EOF" },
{ label: "Z", key: "z", description: "Suspend" },
{ label: "L", key: "l", description: "Clear" },
{ label: "R", key: "r", description: "Reverse search" },
{ label: "A", key: "a", description: "Home" },
{ label: "E", key: "e", description: "End" },
{ label: "U", key: "u", description: "Kill line" },
{ label: "K", key: "k", description: "Kill to EOL" },
{ label: "W", key: "w", description: "Del word" },
{ label: ".", key: ".", description: "Last argument" },
];
function clampTerminalFontSize(value: number): number {
return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value));
}
function readInitialTerminalFontSize(): number {
if (typeof window === "undefined") {
return DEFAULT_FONT_SIZE;
}
try {
const savedFontSize = window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY);
if (!savedFontSize) {
return DEFAULT_FONT_SIZE;
}
const parsed = Number.parseInt(savedFontSize, 10);
if (!Number.isFinite(parsed)) {
return DEFAULT_FONT_SIZE;
}
return clampTerminalFontSize(parsed);
} catch {
return DEFAULT_FONT_SIZE;
}
}
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function retryDynamicImport<T>(
importFactory: () => Promise<T>,
retryDelaysMs: readonly number[] = XTERM_IMPORT_RETRY_DELAYS_MS,
): Promise<T> {
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.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<string | null>(null);
const [exitCode, setExitCode] = useState<number | null>(null);
const [xtermReady, setXtermReady] = useState(false);
const [xtermInitError, setXtermInitError] = useState<string | null>(null);
const [openGeneration, setOpenGeneration] = useState(0);
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
const [fontSize, setFontSize] = useState<number>(() => readInitialTerminalFontSize());
const [showShortcuts, setShowShortcuts] = useState(false);
const [stickyModifier, setStickyModifier] = useState<null | "ctrl" | "alt">(null);
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const hasInitialCommandRun = useRef<string | false>(false);
const xtermInitializedRef = useRef<string | false>(false);
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
// Latest sendInput, kept in a ref so the xterm.onData listener bound at
// init time always calls the current function without needing to re-bind
// (which under StrictMode/Vite Fast Refresh could leak a stale listener
// on the same xterm instance and cause per-character input doubling).
const sendInputRef = useRef<(data: string) => void>(() => {});
// Window resize listener tied to the live xterm instance — tracked here so
// it can be removed in step with xterm disposal (modal close, tab switch).
const windowResizeListenerRef = useRef<(() => void) | null>(null);
const keyboardOverlapRef = useRef(0);
const fontSizeRef = useRef(fontSize);
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
const pendingFitRef = useRef<number | null>(null);
/** Tracks the previous projectId to detect project switches and invalidate xterm. */
const previousProjectIdRef = useRef<string | undefined>(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;
fontSizeRef.current = fontSize;
/**
* 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<typeof import("@xterm/addon-fit").FitAddon>;
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;
sendInputRef.current = sendInput;
useEffect(() => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(fontSize));
} catch {
// Ignore localStorage persistence errors.
}
}, [fontSize]);
const refitTerminal = useCallback(() => {
const terminal = xtermRef.current;
if (!terminal) {
return;
}
try {
(fitAddonRef.current as InstanceType<typeof FitAddon> | null)?.fit();
resize(terminal.cols, terminal.rows);
} catch {
// Ignore fit errors during viewport transitions.
}
}, [resize]);
// Initialize xterm.js when session is ready.
// Keying this effect by active session id (not full activeTab object) avoids
// tearing down xterm lifecycle wiring during unrelated tab metadata updates
// such as title changes.
useEffect(() => {
if (!isOpen || !isReady) return;
const currentSessionId = activeTab?.sessionId;
if (!currentSessionId) return;
// 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;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
setXtermReady(false);
setXtermInitError(null);
}
let mounted = true;
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
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<never>((_resolve, reject) => {
watchdogTimer = setTimeout(() => {
reject(new Error("xterm initialization timed out"));
}, XTERM_INIT_TIMEOUT_MS);
});
let terminal: InstanceType<typeof import("@xterm/xterm").Terminal>;
let fitAddon: InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
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: fontSizeRef.current,
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
if (watchdogTimer) {
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);
});
}
// Wire user input forwarding (xterm → server) once, here, while we
// still hold a live reference to the freshly-created xterm. Doing
// this in a separate effect is fragile under StrictMode/Vite Fast
// Refresh: the effect can re-run and attach a second listener to the
// same xterm instance, which produces per-character input doubling
// (every keystroke calls sendInput twice → server pty.write twice →
// shell echoes the doubled byte → "aabbcc" on screen). Binding here
// ties the listener's lifetime to the xterm; xterm.dispose() removes
// it. The handler reads sendInput via a ref so updates to that
// function don't require re-binding.
terminal.onData((data) => {
if (xtermInitializedRef.current !== currentSessionId) return;
sendInputRef.current(data);
});
// Window resize listener bound to this xterm. Tracked in a ref so it
// can be removed when xterm is disposed (modal close, tab switch).
const resizeHandler = () => {
if (xtermInitializedRef.current !== currentSessionId) return;
if (fitAddonRef.current && xtermRef.current) {
try {
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
const { cols, rows } = xtermRef.current;
resizeRef.current?.(cols, rows);
} catch {
// Ignore fit errors during viewport transitions.
}
}
};
window.addEventListener("resize", resizeHandler);
// Replace any stale listener (defensive: e.g. a previous xterm whose
// disposal path didn't clear this ref). Removes before re-registering.
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
}
windowResizeListenerRef.current = resizeHandler;
// Signal that xterm is ready so lifecycle effects can subscribe.
setXtermReady(true);
// Clear any prior xterm init error
setXtermInitError(null);
} catch (err) {
if (watchdogTimer) {
clearTimeout(watchdogTimer);
}
if (!mounted) return;
const message = err instanceof Error ? err.message : "xterm initialization failed";
setXtermInitError(message);
}
};
void initTerminal();
return () => {
mounted = false;
if (watchdogTimer) {
clearTimeout(watchdogTimer);
}
// Don't dispose xterm here - it should persist across tab switches
// Only dispose when the modal is fully closed
};
}, [fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId]);
// (Input forwarding + window resize listener are wired inside initTerminal
// so they share the xterm instance's lifetime — see comment there.)
// 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;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
setXtermReady(false);
setXtermInitError(null);
hasInitialCommandRun.current = false;
setError(null);
setExitCode(null);
setShowShortcuts(false);
setStickyModifier(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]);
useEffect(() => {
if (!xtermReady || !xtermRef.current) {
return;
}
xtermRef.current.options.fontSize = fontSize;
// Defer fit until the next frame so layout reflects the new font metrics
// before FitAddon measures rows/cols. Reuse pendingFitRef so font-size and
// visualViewport-triggered fits are coalesced into a single scheduled fit.
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
pendingFitRef.current = null;
}
const frame = requestAnimationFrame(() => {
pendingFitRef.current = null;
refitTerminal();
});
pendingFitRef.current = frame;
return () => {
if (pendingFitRef.current === frame) {
cancelAnimationFrame(frame);
pendingFitRef.current = null;
}
};
}, [fontSize, xtermReady, refitTerminal]);
// 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();
setFontSize((current) => clampTerminalFontSize(current + 1));
return;
}
// Zoom out: Ctrl/Cmd + Minus
if (e.code === "Minus" || e.code === "NumpadSubtract") {
e.preventDefault();
setFontSize((current) => clampTerminalFontSize(current - 1));
return;
}
// Reset zoom: Ctrl/Cmd + 0
if (e.code === "Digit0" || e.code === "Numpad0") {
e.preventDefault();
setFontSize(DEFAULT_FONT_SIZE);
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, refitTerminal]);
// 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.
*
* On touch-primary devices, the CSS sizes `.xterm-helper-textarea` to cover
* the whole terminal surface (see styles.css @media (hover: none) and
* (pointer: coarse)), so iOS focuses it natively on tap. Re-focusing and
* calling setSelectionRange inside the touchstart/pointerdown handler
* disrupts iOS's input-event attribution (same class of bug the prior
* capture-phase handlers caused — see commit c7266b7f), and subsequent
* keystrokes are silently dropped. Early-return on touch-primary so iOS
* handles focus with no JS interference.
*/
const handleTerminalGestureFocus = useCallback(() => {
if (!terminalRef.current) return;
const isTouchPrimary =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(hover: none) and (pointer: coarse)")?.matches === true;
if (isTouchPrimary) 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;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
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) {
setError(getErrorMessage(err) || "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;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
// Clear error state and reset readiness so the init effect re-runs
setXtermInitError(null);
setXtermReady(false);
}, []);
const handleRefreshPage = useCallback(() => {
window.location.reload();
}, []);
const handleIncreaseFontSize = useCallback(() => {
setFontSize((current) => clampTerminalFontSize(current + 1));
}, []);
const handleDecreaseFontSize = useCallback(() => {
setFontSize((current) => clampTerminalFontSize(current - 1));
}, []);
const toggleModifier = useCallback((modifier: "ctrl" | "alt") => {
setStickyModifier((current) => (current === modifier ? null : modifier));
}, []);
const sendShortcutKey = useCallback(
(key: string) => {
if (stickyModifier === "ctrl") {
sendInput(ctrlChar(key));
setStickyModifier(null);
return;
}
if (stickyModifier === "alt") {
sendInput(altChar(key));
setStickyModifier(null);
return;
}
sendInput(key);
},
[sendInput, stickyModifier],
);
const sendLiteralShortcut = useCallback(
(value: string) => {
sendInput(value);
setStickyModifier(null);
},
[sendInput],
);
if (!isOpen) return null;
const getStatusIndicator = () => {
switch (connectionStatus) {
case "connected":
return <span className="terminal-status connected" title="Connected" />;
case "connecting":
case "reconnecting":
return <span className="terminal-status connecting" title="Connecting..." />;
case "disconnected":
return <span className="terminal-status disconnected" title="Disconnected" />;
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 (
<div
className="modal-overlay open"
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
data-testid="terminal-modal-overlay"
style={
keyboardOverlap > 0
? {
"--overlay-padding-top": "0px",
} as React.CSSProperties
: undefined
}
>
<div
ref={modalRef}
className="modal terminal-modal"
data-testid="terminal-modal"
style={
keyboardOverlap > 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) */}
<div className="terminal-header">
{/* Tab Bar */}
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
role="tab"
aria-selected={tab.isActive}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
title="Close tab"
>
×
</button>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
onClick={createTab}
title="New terminal"
>
+
</button>
</div>
{/* Status indicator */}
<div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} />
{getStatusIndicator()}
</div>
{/* Actions — labels hidden on mobile via .terminal-action-label */}
<div className="terminal-actions" data-testid="terminal-actions">
{connectionStatus === "disconnected" && activeTab && (
<button
className="terminal-reconnect-btn"
onClick={reconnect}
title="Reconnect"
data-testid="terminal-reconnect-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">Reconnect</span>
</button>
)}
{exitCode !== null && (
<button
className="terminal-restart-btn"
onClick={handleRestart}
title="New Session"
data-testid="terminal-restart-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">New Session</span>
</button>
)}
<button
className="terminal-clear-btn"
onClick={handleClear}
data-testid="terminal-clear-btn"
title="Clear terminal"
>
<Trash2 size={14} />
<span className="terminal-action-label">Clear</span>
</button>
<button
className="terminal-clear-btn"
onClick={() => setShowShortcuts((current) => !current)}
data-testid="terminal-shortcut-toggle"
title="Shortcuts"
aria-pressed={showShortcuts}
>
<Keyboard size={14} />
<span className="terminal-action-label">Shortcuts</span>
</button>
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title="Close terminal"
>
<X size={20} />
</button>
</div>
</div>
{/* Error message */}
{error && (
<div className="terminal-error" data-testid="terminal-error">
{error}
</div>
)}
{/* Terminal container */}
<div className="terminal-container" data-testid="terminal-container">
{isLoading && !bootstrapError && (
<div className="terminal-loading" data-testid="terminal-loading">
<div className="terminal-spinner" />
<span>Starting terminal...</span>
</div>
)}
{bootstrapError && !activeTab && (
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
<div className="terminal-error-content">
<span>Failed to start terminal: {bootstrapError}</span>
<div className="terminal-error-actions">
<button
className="terminal-retry-btn"
onClick={retryBootstrap}
data-testid="terminal-retry-btn"
>
<RefreshCw size={14} />
Retry
</button>
<button
className="terminal-retry-btn"
onClick={handleRefreshPage}
data-testid="terminal-bootstrap-refresh-btn"
>
<RefreshCw size={14} />
Refresh page
</button>
</div>
</div>
</div>
)}
{xtermInitError && activeTab && (
<div className="terminal-loading" data-testid="terminal-xterm-init-error">
<div className="terminal-error-content">
<span>Terminal UI failed to initialize: {xtermInitError}</span>
<div className="terminal-error-actions">
<button
className="terminal-retry-btn"
onClick={handleReinitialize}
data-testid="terminal-reinit-btn"
>
<RefreshCw size={14} />
Reinitialize
</button>
<button
className="terminal-retry-btn"
onClick={handleRefreshPage}
data-testid="terminal-xterm-refresh-btn"
>
<RefreshCw size={14} />
Refresh page
</button>
</div>
</div>
</div>
)}
{/*
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.
*/}
<div
key={activeTab?.sessionId}
ref={terminalRef}
className="terminal-xterm"
data-testid="terminal-xterm"
onPointerDown={handleTerminalGestureFocus}
onTouchStart={handleTerminalGestureFocus}
/>
</div>
{showShortcuts && (
<div className="terminal-shortcut-panel" data-testid="terminal-shortcut-panel">
<div className="terminal-shortcut-modifier-row">
<button
type="button"
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
stickyModifier === "ctrl" ? "is-active" : ""
}`}
data-testid="terminal-modifier-ctrl"
onClick={() => toggleModifier("ctrl")}
aria-pressed={stickyModifier === "ctrl"}
>
Ctrl
</button>
<button
type="button"
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
stickyModifier === "alt" ? "is-active" : ""
}`}
data-testid="terminal-modifier-alt"
onClick={() => toggleModifier("alt")}
aria-pressed={stickyModifier === "alt"}
>
Alt
</button>
<button
type="button"
className="terminal-shortcut-btn"
onClick={() => sendLiteralShortcut("\x1b")}
>
ESC
</button>
<button
type="button"
className="terminal-shortcut-btn"
onClick={() => sendLiteralShortcut("\t")}
>
Tab
</button>
</div>
{SHORTCUT_KEYS.map((shortcut) => (
<button
key={shortcut.label}
type="button"
className="terminal-shortcut-btn"
onClick={() => sendShortcutKey(shortcut.key)}
title={shortcut.description}
>
{shortcut.label}
</button>
))}
</div>
)}
{/* Connection status bar */}
<div className="terminal-status-bar" data-testid="terminal-status-bar">
<span className={`terminal-connection-status ${connectionStatus}`}>
{connectionStatus === "connected" && "Connected"}
{connectionStatus === "connecting" && "Connecting..."}
{connectionStatus === "reconnecting" && "Reconnecting..."}
{connectionStatus === "disconnected" && "Disconnected"}
</span>
{exitCode !== null && (
<span className="terminal-exit-code" data-testid="terminal-exit-code">
Exit: {exitCode}
</span>
)}
<span className="terminal-font-size-controls">
<button
type="button"
className="terminal-font-size-btn"
onClick={handleDecreaseFontSize}
data-testid="terminal-font-size-decrease"
aria-label="Decrease terminal font size"
>
<Minus size={14} />
</button>
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">
{fontSize}px
</span>
<button
type="button"
className="terminal-font-size-btn"
onClick={handleIncreaseFontSize}
data-testid="terminal-font-size-increase"
aria-label="Increase terminal font size"
>
<Plus size={14} />
</button>
</span>
<span className="terminal-shortcuts">
Ctrl++/- zoom Shortcuts panel Esc close
</span>
</div>
</div>
</div>
);
}