feat(web): implement multi-session terminal tab management (#520)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #520.
This commit is contained in:
2026-02-26 03:18:35 +00:00
committed by jason.woltje
parent 13aa52aa53
commit 859dcfc4b7
7 changed files with 1913 additions and 300 deletions

View File

@@ -3,29 +3,55 @@
/**
* XTerminal component
*
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
* Handles resize, copy/paste, theme, and session lifecycle.
* Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
* owns the WebSocket connection and session lifecycle. This component receives the
* sessionId and control functions as props and registers for output data specific
* to its session.
*
* Handles resize, copy/paste, theme, exit overlay, and reconnect.
*/
import { useEffect, useRef, useCallback, useState } from "react";
import { useEffect, useRef, useCallback } from "react";
import type { ReactElement, CSSProperties } from "react";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
import { useTerminal } from "@/hooks/useTerminal";
import type { SessionStatus } from "@/hooks/useTerminalSessions";
// ==========================================
// Types
// ==========================================
export interface XTerminalProps {
/** Authentication token for the WebSocket connection */
token: string;
/** Session identifier (provided by useTerminalSessions) */
sessionId: string;
/** Send keyboard input to this session */
sendInput: (sessionId: string, data: string) => void;
/** Notify the server of a terminal resize */
resize: (sessionId: string, cols: number, rows: number) => void;
/** Close this PTY session */
closeSession: (sessionId: string) => void;
/**
* Register a callback to receive output for this session.
* Returns an unsubscribe function.
*/
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
/** Whether the WebSocket is currently connected */
isConnected: boolean;
/** Current PTY process status */
sessionStatus: SessionStatus;
/** Exit code, populated when sessionStatus === 'exited' */
exitCode?: number;
/**
* Called when the user clicks the restart button after the session has exited.
* The parent is responsible for closing the old session and creating a new one.
*/
onRestart?: () => void;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline styles for the outer container */
style?: CSSProperties;
/** Whether the terminal is visible (used to trigger re-fit) */
/** Whether the terminal is visible (used to trigger re-fit on tab switch) */
isVisible?: boolean;
}
@@ -45,8 +71,6 @@ function getCssVar(varName: string, fallback: string): string {
/**
* Build an xterm.js ITheme object from the current design system CSS variables.
* All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives
* which flow through to the semantic aliases we read here.
*/
function buildXtermTheme(): Record<string, string> {
return {
@@ -82,11 +106,20 @@ function buildXtermTheme(): Record<string, string> {
// ==========================================
/**
* XTerminal renders a real PTY terminal powered by xterm.js,
* connected to the backend /terminal WebSocket namespace.
* XTerminal renders a real PTY terminal powered by xterm.js.
* The parent provides the sessionId and control functions; this component
* registers for output data and manages the xterm.js instance lifecycle.
*/
export function XTerminal({
token,
sessionId,
sendInput,
resize,
closeSession: _closeSession,
registerOutputCallback,
isConnected,
sessionStatus,
exitCode,
onRestart,
className = "",
style,
isVisible = true,
@@ -97,41 +130,7 @@ export function XTerminal({
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const isTerminalMountedRef = useRef(false);
const [hasExited, setHasExited] = useState(false);
const [exitCode, setExitCode] = useState<number | null>(null);
// ==========================================
// Terminal session callbacks
// ==========================================
const handleOutput = useCallback((sessionId: string, data: string): void => {
void sessionId; // sessionId is single-session in this component
terminalRef.current?.write(data);
}, []);
const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => {
void event.sessionId;
setHasExited(true);
setExitCode(event.exitCode);
const term = terminalRef.current;
if (term) {
term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`);
}
}, []);
const handleError = useCallback((message: string): void => {
const term = terminalRef.current;
if (term) {
term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`);
}
}, []);
const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({
token,
onOutput: handleOutput,
onExit: handleExit,
onError: handleError,
});
const hasExited = sessionStatus === "exited";
// ==========================================
// Fit helper
@@ -144,11 +143,11 @@ export function XTerminal({
try {
fitAddon.fit();
resize(terminal.cols, terminal.rows);
resize(sessionId, terminal.cols, terminal.rows);
} catch {
// Ignore fit errors (e.g., when container has zero dimensions)
}
}, [resize]);
}, [resize, sessionId]);
// ==========================================
// Mount xterm.js terminal (client-only)
@@ -217,8 +216,20 @@ export function XTerminal({
return (): void => {
cancelled = true;
};
// Intentionally empty dep array — mount once only
}, []);
// ==========================================
// Register output callback for this session
// ==========================================
useEffect(() => {
const unregister = registerOutputCallback(sessionId, (data: string) => {
terminalRef.current?.write(data);
});
return unregister;
}, [sessionId, registerOutputCallback]);
// ==========================================
// Re-fit when visibility changes
// ==========================================
@@ -243,39 +254,13 @@ export function XTerminal({
if (!terminal) return;
const disposable = terminal.onData((data: string): void => {
sendInput(data);
sendInput(sessionId, data);
});
return (): void => {
disposable.dispose();
};
}, [sendInput]);
// ==========================================
// Create PTY session when connected
// ==========================================
useEffect(() => {
if (!isConnected || sessionId !== null) return;
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
let cols = 80;
let rows = 24;
if (terminal && fitAddon) {
try {
fitAddon.fit();
cols = terminal.cols;
rows = terminal.rows;
} catch {
// Use defaults
}
}
createSession({ cols, rows });
}, [isConnected, sessionId, createSession]);
}, [sendInput, sessionId]);
// ==========================================
// Update xterm theme when data-theme attribute changes
@@ -309,16 +294,26 @@ export function XTerminal({
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
// Close PTY session
closeSession();
// Dispose xterm terminal
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
isTerminalMountedRef.current = false;
};
}, [closeSession]);
}, []);
// ==========================================
// Restart handler
// ==========================================
const handleRestart = useCallback((): void => {
const terminal = terminalRef.current;
if (terminal) {
terminal.clear();
}
// Notify parent to close old session and create a new one
onRestart?.();
}, [onRestart]);
// ==========================================
// Render
@@ -339,8 +334,9 @@ export function XTerminal({
role="region"
aria-label="Terminal"
data-testid="xterminal-container"
data-session-id={sessionId}
>
{/* Status bar */}
{/* Status bar — show when not connected and not exited */}
{!isConnected && !hasExited && (
<div
style={{
@@ -385,28 +381,9 @@ export function XTerminal({
border: "1px solid var(--border)",
cursor: "pointer",
}}
onClick={(): void => {
setHasExited(false);
setExitCode(null);
if (isConnected) {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
let cols = 80;
let rows = 24;
if (terminal && fitAddon) {
try {
cols = terminal.cols;
rows = terminal.rows;
} catch {
// Use defaults
}
}
terminal?.clear();
createSession({ cols, rows });
}
}}
onClick={handleRestart}
>
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
</button>
</div>
)}