feat: responsive web terminal with auto-reconnect, heartbeat, and local echo
Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/
This commit is contained in:
@@ -1,158 +1,274 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { SerializeAddon } from "xterm-addon-serialize";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import { useTerminalConnection } from "../hooks/use-terminal-connection";
|
||||
import type {
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Initialize terminal
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Build WebSocket URL
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
// Connect WebSocket
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
term.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status" && msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setStatus("disconnected");
|
||||
if (event.code !== 1000) {
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Initial resize
|
||||
setTimeout(handleResize, 100);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [instanceId]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">{status}</span>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="terminal-error">{error}</div>}
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
</div>
|
||||
);
|
||||
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
|
||||
connecting: "var(--warning)",
|
||||
connected: "var(--success)",
|
||||
reconnecting: "var(--warning)",
|
||||
disconnected: "var(--muted)",
|
||||
};
|
||||
|
||||
function getStatusText(state: TerminalConnectionState): string {
|
||||
switch (state.status) {
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "connected": {
|
||||
if (state.latency !== null && state.latency >= 100) {
|
||||
return `Slow (${state.latency}ms)`;
|
||||
}
|
||||
return "Connected";
|
||||
}
|
||||
case "reconnecting":
|
||||
return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`;
|
||||
case "disconnected":
|
||||
return state.error || "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [sessionEnded, setSessionEnded] = useState<{
|
||||
reason: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Determine dark mode from document theme
|
||||
const isDarkMode =
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
||||
(document.documentElement.getAttribute("data-theme") === null &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const handleData = useCallback((data: Uint8Array) => {
|
||||
// Data is already written by onLocalEcho or deduplication
|
||||
// This callback is mainly for external consumers
|
||||
void data;
|
||||
}, []);
|
||||
|
||||
const handleLocalEcho = useCallback((data: string) => {
|
||||
xtermRef.current?.write(data);
|
||||
}, []);
|
||||
|
||||
const serializeFn = useCallback((): string | null => {
|
||||
return serializeAddonRef.current?.serialize() ?? null;
|
||||
}, []);
|
||||
|
||||
const handleRestoreScrollback = useCallback((content: string) => {
|
||||
xtermRef.current?.write(content);
|
||||
xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n");
|
||||
}, []);
|
||||
|
||||
const handleControl = useCallback((msg: ServerControlMessage) => {
|
||||
if (msg.type === "session_ended") {
|
||||
const messages: Record<string, string> = {
|
||||
process_exit: "The container process has exited.",
|
||||
container_stop: "The container was stopped.",
|
||||
timeout: "The session timed out due to inactivity.",
|
||||
};
|
||||
setSessionEnded({
|
||||
reason: msg.reason,
|
||||
message: messages[msg.reason] || "The session has ended.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { state, sendInput, sendResize, reconnect } = useTerminalConnection({
|
||||
instanceId,
|
||||
onData: handleData,
|
||||
onControl: handleControl,
|
||||
onLocalEcho: handleLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback: handleRestoreScrollback,
|
||||
});
|
||||
|
||||
// Initialize xterm
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: isDarkMode
|
||||
? {
|
||||
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",
|
||||
}
|
||||
: {
|
||||
background: "#fafafa",
|
||||
foreground: "#333333",
|
||||
cursor: "#333333",
|
||||
selectionBackground: "#b4d7ff",
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const serializeAddon = new SerializeAddon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(serializeAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
serializeAddonRef.current = serializeAddon;
|
||||
|
||||
// Handle terminal input
|
||||
const disposable = term.onData((data) => {
|
||||
sendInput(data);
|
||||
});
|
||||
|
||||
// Resize observer for container-level resize detection
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
sendResize(cols, rows);
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
term.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
};
|
||||
}, [instanceId, isDarkMode, sendInput, sendResize]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{
|
||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||
}}
|
||||
aria-label={`Terminal status: ${state.status}`}
|
||||
title={
|
||||
state.latency !== null
|
||||
? `Latency: ${state.latency}ms`
|
||||
: getStatusText(state)
|
||||
}
|
||||
/>
|
||||
<span className="status-text">{getStatusText(state)}</span>
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
{state.status === "disconnected" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={reconnect}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionEnded && (
|
||||
<div className="terminal-overlay">
|
||||
<div className="terminal-overlay-content">
|
||||
<h3>Session Ended</h3>
|
||||
<p>{sessionEnded.message}</p>
|
||||
<div className="terminal-overlay-actions">
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setSessionEnded(null);
|
||||
reconnect();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "reconnecting" && (
|
||||
<div className="terminal-reconnect-banner">
|
||||
<span className="spinner" />
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user