Files
headquarter/apps/web/src/components/terminal.tsx
T
Alex Blank 51a399c775 feat: open all sessions in new tabs; sidebar terminal links
Sidebar SessionItem was only opening web tool URLs in new tabs.
Terminal sessions linked to the project page in the same tab.
SessionCard 'Open' buttons for terminal sessions navigated in-place.

Changes:
- app-shell.tsx: SessionItem now builds terminal URLs
  (/instances/:id/terminal) and always uses target=_blank
- session-card.tsx: compute openHref for both web and terminal sessions,
  render <a> links with target=_blank instead of callback buttons
- use-instance-actions.ts: handleOpen now uses window.open(..., '_blank')
  for terminal sessions and project fallback

All session opening (sidebar, cards, callbacks) now consistently opens
in a new tab.

Quality gates: tsc clean
2026-06-02 15:54:03 +02:00

867 lines
24 KiB
TypeScript

import React, {
useEffect,
useImperativeHandle,
useRef,
useState,
useCallback,
} from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import { WebglAddon } from "xterm-addon-webgl";
import "xterm/css/xterm.css";
import {
applyModifierToChar,
type ModifierKey,
} from "../hooks/use-special-keys";
export interface TerminalProps {
instanceId: string;
sessionId?: string;
onClose?: () => void;
isMobile?: boolean;
showControls?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
export interface TerminalRef {
fit: () => void;
focus: () => void;
reset: () => void;
}
const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 4;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
(
{
instanceId,
sessionId,
onClose,
isMobile = false,
showControls = true,
activeModifier,
onModifierChange,
onTerminalReady,
},
ref,
) => {
const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error" | "resetting"
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 8 : 8;
const stored = localStorage.getItem(FONT_SIZE_KEY);
if (stored) {
const parsed = parseInt(stored, 10);
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
}
return isMobile ? 8 : 8;
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const isUnmountingRef = useRef(false);
const permanentErrorRef = useRef<string | null>(null);
const calculateFontSize = useCallback(() => {
return fontSize;
}, [fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsPath = sessionId
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
: `/ws/tool-instances/${instanceId}/terminal`;
const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`;
// WebSocket connection established
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
lastPingRef.current = Date.now();
// Send current terminal size immediately on connect
if (termRef.current) {
const { cols, rows } = termRef.current;
// Only send if we have valid dimensions
if (cols > 0 && rows > 0) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
// Start heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
}
heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current;
if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead
ws.close(4000, "Heartbeat timeout");
}
}, 30000);
};
// Flow control: accumulate processed bytes and send ack
let ackAccumulator = 0;
const ACK_THRESHOLD = 4096;
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
const flushAck = () => {
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
ackAccumulator = 0;
}
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
termRef.current.write(data);
// Flow control: accumulate processed bytes
ackAccumulator += data.length;
if (ackAccumulator >= ACK_THRESHOLD) {
flushAck();
} else if (!ackTimeout) {
ackTimeout = setTimeout(() => {
ackTimeout = null;
flushAck();
}, 100);
}
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status") {
if (msg.status === "connected") {
setStatus("connected");
setError(null);
// Clear terminal and refit after reset/reconnect
if (termRef.current) {
termRef.current.clear();
requestAnimationFrame(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(
JSON.stringify({ type: "resize", cols, rows }),
);
}
}
});
}
} else if (msg.status === "resetting") {
setStatus("resetting");
}
} else if (msg.type === "ping") {
// Respond with pong and update last ping time
lastPingRef.current = Date.now();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
// Permanent errors: do not retry
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
const reason = event.reason || `Instance error (code: ${event.code})`;
setStatus("error");
setError(reason);
permanentErrorRef.current = reason;
return;
}
if (event.code === 1000) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay =
RECONNECT_DELAY_BASE *
Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
connectWebSocket();
}
}, delay);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId, sessionId]);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({
cursorBlink: true,
fontSize: currentFontSize,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
lineHeight: 1.2,
letterSpacing: 0,
allowTransparency: false,
scrollback: 10000,
ignoreBracketedPasteMode: false,
fastScrollSensitivity: 5,
scrollSensitivity: 1,
smoothScrollDuration: 0,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
termRef.current = term;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
// Load WebGL renderer for GPU acceleration, fall back to DOM
let webglAddon: WebglAddon | null = null;
try {
webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
webglAddon.onContextLoss(() => {
console.warn("WebGL context lost, falling back to DOM renderer");
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Trigger a refit since cell dimensions may differ
requestAnimationFrame(() => fitTerminal());
});
} catch (e) {
console.warn("WebGL renderer failed to load, using DOM renderer", e);
}
const container = terminalRef.current;
// Define fitTerminal before connectWebSocket so it's available in onmessage
let lastSentCols = 0;
let lastSentRows = 0;
const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return;
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors during initialization
return;
}
const { cols, rows } = termRef.current;
// Only send resize when dimensions actually changed
if (
cols > 0 &&
rows > 0 &&
(cols !== lastSentCols || rows !== lastSentRows)
) {
lastSentCols = cols;
lastSentRows = rows;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
};
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
// Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
let startX = 0;
let isScrolling = false;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
isScrolling = true;
}
}
if (isScrolling) {
e.preventDefault();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
startY = touch.clientY;
}
};
const onTouchEnd = () => {
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, {
capture: true,
});
touchCleanup = () => {
container.removeEventListener("touchstart", onTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", onTouchMove, {
capture: true,
});
container.removeEventListener("touchend", onTouchEnd, {
capture: true,
});
};
}
// Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0;
const doInitialFit = () => {
if (!container.isConnected) return;
fitAttempts++;
// Ensure container has dimensions before fitting
if (container.clientWidth > 0 && container.clientHeight > 0) {
fitTerminal();
} else if (fitAttempts < 50) {
// Container not ready yet, try again (max 50 attempts ~ 1s)
requestAnimationFrame(doInitialFit);
}
};
requestAnimationFrame(doInitialFit);
// Refit after font load (metrics may change)
document.fonts.ready.then(() => {
requestAnimationFrame(() => fitTerminal());
});
// Handle terminal input
term.onData((data) => {
const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return;
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
currentWs.send(modified);
onModifierChange?.(null);
return;
}
}
currentWs.send(data);
});
// Handle container resize with ResizeObserver for accurate dimension tracking
let resizeTimeout: ReturnType<typeof setTimeout>;
let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// Only trigger if dimensions actually changed
if (width === lastWidth && height === lastHeight) return;
lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
requestAnimationFrame(() => {
if (!container.isConnected) return;
fitTerminal();
});
}, 50);
});
resizeObserver.observe(container);
// Window resize fallback (for viewport changes that don't affect container dimensions)
let windowResizeTimeout: ReturnType<typeof setTimeout>;
const handleWindowResize = () => {
clearTimeout(windowResizeTimeout);
windowResizeTimeout = setTimeout(() => {
requestAnimationFrame(() => fitTerminal());
}, 250);
};
window.addEventListener("resize", handleWindowResize);
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
const headerHideTimeout = setTimeout(() => {
fitTerminal();
}, 4000);
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChangeRef.current(delta);
};
onTerminalReadyRef.current(
sendData,
status,
focusInput,
changeFontSize,
);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (
document.visibilityState === "visible" &&
ws &&
ws.readyState !== WebSocket.OPEN
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
isUnmountingRef.current = true;
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
resizeObserver.disconnect();
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
if (ws) {
ws.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
// Dispose WebGL addon BEFORE the terminal to avoid race with
// RenderService.setRenderer accessing a disposed renderer
if (webglAddon) {
try {
webglAddon.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
webglAddon = null;
}
try {
term.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
};
}, [instanceId, connectWebSocket]);
useImperativeHandle(ref, () => ({
fit: () => {
if (fitAddonRef.current && termRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (
wsRef.current?.readyState === WebSocket.OPEN &&
cols > 0 &&
rows > 0
) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows }),
);
}
} catch {
// Ignore fit errors
}
}
},
focus: () => {
termRef.current?.focus();
},
reset: () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
},
}));
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChangeRef.current(delta);
};
onTerminalReady(sendData, status, focusInput, changeFontSize);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(
MIN_FONT_SIZE,
Math.min(MAX_FONT_SIZE, fontSize + delta),
);
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current && fitAddonRef.current) {
termRef.current.options.fontSize = newSize;
requestAnimationFrame(() => {
if (termRef.current && fitAddonRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
} catch {
// Ignore fit errors during re-initialization
}
}
});
}
};
handleFontSizeChangeRef.current = handleFontSizeChange;
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && termRef.current) {
termRef.current.focus();
}
};
return (
<div
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
>
{showControls && (
<div className="terminal-header">
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
<div className="terminal-header-right">
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button
className="terminal-close"
onClick={onClose}
type="button"
>
Close
</button>
)}
</div>
</div>
)}
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
{error && (
<div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
</div>
);
},
);
TerminalComponent.displayName = "TerminalComponent";