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:
2026-05-27 21:27:49 +02:00
parent 48fa858090
commit 6c8cfe9157
18 changed files with 2776 additions and 350 deletions
+11
View File
@@ -19,6 +19,7 @@
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-serialize": "^0.11.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
@@ -6372,6 +6373,16 @@
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-serialize": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz",
"integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
+1
View File
@@ -22,6 +22,7 @@
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-serialize": "^0.11.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
+265 -149
View File
@@ -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>
);
};
@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTerminalConnection } from "./use-terminal-connection";
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState: number = WebSocket.CONNECTING;
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sent: (string | ArrayBuffer | Blob)[] = [];
url = "";
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send(data: string | ArrayBuffer | Blob) {
this.sent.push(data);
}
close(code?: number, reason?: string) {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose(new CloseEvent("close", { code: code ?? 1000, reason }));
}
}
simulateOpen() {
this.readyState = WebSocket.OPEN;
if (this.onopen) this.onopen(new Event("open"));
}
simulateMessage(data: string | ArrayBuffer | Blob) {
if (this.onmessage) {
this.onmessage(new MessageEvent("message", { data }));
}
}
simulateError() {
if (this.onerror) this.onerror(new Event("error"));
}
}
describe("useTerminalConnection", () => {
let originalWebSocket: typeof WebSocket;
beforeEach(() => {
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
MockWebSocket.instances = [];
vi.useFakeTimers();
vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } });
});
afterEach(() => {
globalThis.WebSocket = originalWebSocket;
MockWebSocket.instances = [];
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts in connecting state", () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
expect(result.current.state.status).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
});
it("transitions to connected on websocket open", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
expect(result.current.state.status).toBe("connected");
});
it("sends ping after interval", async () => {
renderHook(() => useTerminalConnection({ instanceId: "inst-1" }));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
vi.advanceTimersByTime(15000);
});
const pings = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("ping") : false,
);
expect(pings.length).toBeGreaterThanOrEqual(1);
});
it("handles pong and updates latency", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
vi.advanceTimersByTime(15000);
});
act(() => {
MockWebSocket.instances[0].simulateMessage(
JSON.stringify({ type: "pong", id: 1 }),
);
});
expect(result.current.state.latency).not.toBeNull();
expect(result.current.state.latency).toBeGreaterThanOrEqual(0);
});
it("reconnects with exponential backoff on close", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].close(1006, "Abnormal closure");
});
expect(result.current.state.status).toBe("reconnecting");
expect(result.current.state.attempt).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(MockWebSocket.instances).toHaveLength(2);
});
it("max reconnect attempts leads to disconnected", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
for (let i = 0; i < 11; i++) {
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.close(1006, "Abnormal closure");
});
const delay = Math.min(1000 * 2 ** i, 30000);
act(() => {
vi.advanceTimersByTime(delay);
});
}
expect(result.current.state.status).toBe("disconnected");
expect(result.current.state.error).toContain("Max reconnection");
}, 30000);
it("sends resize message with debounce", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendResize(120, 40);
});
// Before debounce
expect(
MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
),
).toHaveLength(0);
act(() => {
vi.advanceTimersByTime(250);
});
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
);
expect(resizes.length).toBeGreaterThanOrEqual(1);
});
it("throttles resize messages", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendResize(100, 30);
});
act(() => {
vi.advanceTimersByTime(250);
});
act(() => {
result.current.sendResize(101, 31);
});
act(() => {
vi.advanceTimersByTime(250);
});
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
);
// Second resize throttled (within 500ms)
expect(resizes.length).toBe(1);
});
it("sendInput sends data over websocket", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendInput("a");
});
expect(MockWebSocket.instances[0].sent).toContain("a");
});
it("triggers manual reconnect on reconnect()", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.reconnect();
});
expect(MockWebSocket.instances).toHaveLength(2);
});
it("calls onData callback with binary data", async () => {
const onData = vi.fn();
renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData }));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
const buffer = new ArrayBuffer(3);
act(() => {
MockWebSocket.instances[0].simulateMessage(buffer);
});
expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array));
});
it("calls onControl callback with control messages", async () => {
const onControl = vi.fn();
renderHook(() =>
useTerminalConnection({ instanceId: "inst-1", onControl }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].simulateMessage(
JSON.stringify({ type: "set_echo_state", enabled: false }),
);
});
expect(onControl).toHaveBeenCalledWith(
expect.objectContaining({ type: "set_echo_state", enabled: false }),
);
});
it("serializes and restores scrollback", async () => {
const serializeFn = vi.fn(() => "scrollback-content");
const onRestoreScrollback = vi.fn();
renderHook(
() =>
useTerminalConnection({
instanceId: "inst-1",
serializeFn,
onRestoreScrollback,
}),
{ initialProps: {} },
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
// Disconnect
act(() => {
MockWebSocket.instances[0].close(1006, "gone");
});
expect(serializeFn).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1000);
});
// New connection opens
act(() => {
MockWebSocket.instances[
MockWebSocket.instances.length - 1
].simulateOpen();
});
expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content");
});
});
@@ -0,0 +1,439 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
ClientControlMessage,
ServerControlMessage,
TerminalConnectionState,
} from "../types/terminal";
import {
decodeControlMessage,
encodeControlMessage,
isControlMessage,
} from "../utils/terminal-protocol";
const PING_INTERVAL_MS = 15_000;
const PONG_TIMEOUT_MS = 5_000;
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
const MAX_RECONNECT_ATTEMPTS = 10;
const RESIZE_DEBOUNCE_MS = 200;
const RESIZE_THROTTLE_MS = 500;
const PENDING_ECHO_FLUSH_LIMIT = 100;
const SCROLLBACK_STORAGE_KEY = "hq-terminal";
interface UseTerminalConnectionOptions {
instanceId: string;
onData?: (data: Uint8Array) => void;
onControl?: (msg: ServerControlMessage) => void;
/** Called with characters that should be locally echoed. */
onLocalEcho?: (data: string) => void;
/** Called to serialize scrollback before disconnect. Should return terminal content. */
serializeFn?: () => string | null;
/** Called with restored scrollback content on reconnect. */
onRestoreScrollback?: (content: string) => void;
}
export function useTerminalConnection({
instanceId,
onData,
onControl,
onLocalEcho,
serializeFn,
onRestoreScrollback,
}: UseTerminalConnectionOptions) {
const [state, setState] = useState<TerminalConnectionState>({
status: "connecting",
attempt: 0,
latency: null,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pongTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastResizeRef = useRef<number>(0);
const pendingEchoRef = useRef<string>("");
const echoEnabledRef = useRef<boolean>(true);
const pingIdRef = useRef<number>(0);
const pingSentAtRef = useRef<number>(0);
const reconnectAttemptRef = useRef<number>(0);
const isConnectingRef = useRef<boolean>(false);
const lastStatusRef = useRef<string>("connecting");
const setStableState = useCallback(
(updater: (prev: TerminalConnectionState) => TerminalConnectionState) => {
setState((prev) => {
const next = updater(prev);
if (next.status !== lastStatusRef.current) {
lastStatusRef.current = next.status;
}
return next;
});
},
[],
);
const clearTimers = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (pingTimerRef.current) {
clearTimeout(pingTimerRef.current);
pingTimerRef.current = null;
}
if (pongTimerRef.current) {
clearTimeout(pongTimerRef.current);
pongTimerRef.current = null;
}
}, []);
const flushPendingEcho = useCallback(() => {
if (pendingEchoRef.current.length > 0 && onLocalEcho) {
onLocalEcho(pendingEchoRef.current);
pendingEchoRef.current = "";
}
}, [onLocalEcho]);
const deduplicateServerData = useCallback((data: string): string => {
if (!echoEnabledRef.current || pendingEchoRef.current.length === 0) {
return data;
}
let serverIndex = 0;
let echoIndex = 0;
while (
serverIndex < data.length &&
echoIndex < pendingEchoRef.current.length &&
data[serverIndex] === pendingEchoRef.current[echoIndex]
) {
serverIndex++;
echoIndex++;
}
if (echoIndex > 0) {
pendingEchoRef.current = pendingEchoRef.current.slice(echoIndex);
}
return data.slice(serverIndex);
}, []);
const handleBinaryMessage = useCallback(
(buffer: ArrayBuffer) => {
const bytes = new Uint8Array(buffer);
const text = new TextDecoder().decode(bytes);
if (onData) {
onData(bytes);
}
// Deduplicate local echo if active
if (echoEnabledRef.current && pendingEchoRef.current.length > 0) {
const remaining = deduplicateServerData(text);
if (remaining.length > 0 && onLocalEcho) {
onLocalEcho(remaining);
}
} else if (onLocalEcho) {
onLocalEcho(text);
}
// Flush stale pending echo buffer
if (pendingEchoRef.current.length > PENDING_ECHO_FLUSH_LIMIT) {
flushPendingEcho();
}
},
[onData, onLocalEcho, deduplicateServerData, flushPendingEcho],
);
const handleControlMessage = useCallback(
(msg: ServerControlMessage) => {
if (onControl) {
onControl(msg);
}
switch (msg.type) {
case "pong": {
const elapsed = Date.now() - pingSentAtRef.current;
setStableState((prev) => ({
...prev,
latency: elapsed,
status: prev.status === "reconnecting" ? "connected" : prev.status,
}));
break;
}
case "status": {
setStableState((prev) => ({
...prev,
status: "connected",
attempt: 0,
error: null,
}));
reconnectAttemptRef.current = 0;
break;
}
case "set_echo_state": {
echoEnabledRef.current = msg.enabled;
if (!msg.enabled) {
// Server disabled echo — flush any pending local echo
flushPendingEcho();
}
break;
}
case "session_ended": {
setStableState((prev) => ({
...prev,
status: "disconnected",
error: `Session ended: ${msg.reason}`,
}));
clearTimers();
wsRef.current?.close(1000);
break;
}
}
},
[onControl, setStableState, clearTimers, flushPendingEcho],
);
const schedulePing = useCallback(() => {
pingTimerRef.current = setTimeout(() => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const id = ++pingIdRef.current;
pingSentAtRef.current = Date.now();
const pingMsg: ClientControlMessage = { type: "ping", id };
ws.send(encodeControlMessage(pingMsg));
// Set pong timeout
pongTimerRef.current = setTimeout(() => {
// Pong not received — connection is dead
ws.close(1001, "Ping timeout");
}, PONG_TIMEOUT_MS);
}, PING_INTERVAL_MS);
}, []);
const serializeScrollback = useCallback(() => {
if (!serializeFn) return;
try {
const serialized = serializeFn();
if (serialized) {
sessionStorage.setItem(
`${SCROLLBACK_STORAGE_KEY}-${instanceId}`,
serialized,
);
}
} catch {
// Ignore serialization errors
}
}, [serializeFn, instanceId]);
const restoreScrollback = useCallback(() => {
if (!onRestoreScrollback) return;
try {
const key = `${SCROLLBACK_STORAGE_KEY}-${instanceId}`;
const serialized = sessionStorage.getItem(key);
if (serialized) {
onRestoreScrollback(serialized);
sessionStorage.removeItem(key);
}
} catch {
// Ignore restoration errors
}
}, [onRestoreScrollback, instanceId]);
const connect = useCallback(() => {
if (isConnectingRef.current) return;
isConnectingRef.current = true;
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`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
isConnectingRef.current = false;
reconnectAttemptRef.current = 0;
setStableState((prev) => ({
...prev,
status: "connected",
attempt: 0,
error: null,
}));
restoreScrollback();
schedulePing();
};
ws.onmessage = (event: MessageEvent) => {
if (isControlMessage(event)) {
const msg = decodeControlMessage(event.data as string);
if (msg) {
handleControlMessage(msg);
}
} else if (event.data instanceof ArrayBuffer) {
handleBinaryMessage(event.data);
} else if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
handleBinaryMessage(buffer);
});
}
};
ws.onclose = (event: CloseEvent) => {
wsRef.current = null;
clearTimers();
if (event.code === 1000 || event.code === 1001) {
// Normal or going-away close
setStableState(() => ({
status: "disconnected",
attempt: 0,
latency: null,
error: event.reason || null,
}));
return;
}
// Unexpected close — attempt reconnect
const attempt = ++reconnectAttemptRef.current;
if (attempt > MAX_RECONNECT_ATTEMPTS) {
setStableState(() => ({
status: "disconnected",
attempt,
latency: null,
error: "Max reconnection attempts exceeded",
}));
return;
}
serializeScrollback();
const delay = Math.min(
RECONNECT_BASE_MS * 2 ** (attempt - 1),
RECONNECT_MAX_MS,
);
setStableState((prev) => ({
...prev,
status: "reconnecting",
attempt,
error: `Reconnecting in ${Math.round(delay / 1000)}s...`,
}));
reconnectTimerRef.current = setTimeout(() => {
connect();
}, delay);
};
ws.onerror = () => {
isConnectingRef.current = false;
// Let onclose handle reconnection
};
}, [
instanceId,
setStableState,
clearTimers,
schedulePing,
handleBinaryMessage,
handleControlMessage,
serializeScrollback,
restoreScrollback,
]);
const sendInput = useCallback(
(data: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Local echo for printable ASCII characters
if (
echoEnabledRef.current &&
data.length === 1 &&
data.charCodeAt(0) >= 32 &&
data.charCodeAt(0) <= 126
) {
pendingEchoRef.current += data;
if (onLocalEcho) {
onLocalEcho(data);
}
}
ws.send(data);
},
[onLocalEcho],
);
const sendResize = useCallback((cols: number, rows: number) => {
if (resizeTimerRef.current) {
clearTimeout(resizeTimerRef.current);
}
resizeTimerRef.current = setTimeout(() => {
const now = Date.now();
if (now - lastResizeRef.current < RESIZE_THROTTLE_MS) {
return;
}
lastResizeRef.current = now;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const msg: ClientControlMessage = { type: "resize", cols, rows };
ws.send(encodeControlMessage(msg));
}, RESIZE_DEBOUNCE_MS);
}, []);
const reconnect = useCallback(() => {
clearTimers();
if (wsRef.current) {
wsRef.current.close(1000, "Manual reconnect");
wsRef.current = null;
}
reconnectAttemptRef.current = 0;
setStableState(() => ({
status: "connecting",
attempt: 0,
latency: null,
error: null,
}));
connect();
}, [clearTimers, connect, setStableState]);
// Initial connection
useEffect(() => {
connect();
return () => {
clearTimers();
if (resizeTimerRef.current) {
clearTimeout(resizeTimerRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, "Component unmount");
wsRef.current = null;
}
};
}, [instanceId, connect, clearTimers]);
// Keyboard shortcut for manual reconnect
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === "R") {
e.preventDefault();
reconnect();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [reconnect]);
return {
state,
sendInput,
sendResize,
reconnect,
};
}
+164 -131
View File
@@ -2479,137 +2479,6 @@ a.nav-item,
Terminal Styles
============================================ */
.terminal-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--space-4);
gap: var(--space-4);
}
.terminal-page-header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.terminal-page-header h1 {
margin: 0;
}
.terminal-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.terminal-status {
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.status-dot.connected {
background: #0dbc79;
}
.status-dot.disconnected,
.status-dot.error {
background: #cd3131;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
color: #d4d4d4;
text-transform: capitalize;
}
.terminal-close {
padding: var(--space-1) var(--space-3);
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-error {
padding: var(--space-3) var(--space-4);
background: #cd3131;
color: white;
font-size: 0.875rem;
flex-shrink: 0;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
}
/* ============================================
Sessions Page Styles
============================================ */
@@ -2805,3 +2674,167 @@ a.nav-item,
background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626);
}
/* ============================================
Responsive Terminal — Updated
============================================ */
.terminal-wrapper {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
gap: 0.5rem;
}
.terminal-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.terminal-status .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.terminal-status .status-text {
font-size: 0.8rem;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.terminal-close {
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.8rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: 0.25rem;
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Terminal overlay for session ended */
.terminal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: grid;
place-content: center;
z-index: 10;
}
.terminal-overlay-content {
background: #2d2d2d;
border: 1px solid #3e3e3e;
border-radius: 10px;
padding: 1.5rem;
text-align: center;
max-width: 400px;
color: #d4d4d4;
}
.terminal-overlay-content h3 {
margin: 0 0 0.5rem;
color: #f14c4c;
}
.terminal-overlay-content p {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.terminal-overlay-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
/* Reconnect banner */
.terminal-reconnect-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: #3e3e3e;
color: #f5f543;
font-size: 0.8rem;
flex-shrink: 0;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
.terminal-overlay-content {
margin: 0 1rem;
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* WebSocket protocol types for the responsive terminal.
*
* Binary frames carry raw terminal I/O.
* Text (JSON) frames carry control messages.
*/
// ── Client → Server ──
export interface PingMessage {
type: "ping";
id: number;
}
export interface PongMessage {
type: "pong";
id: number;
}
export interface ResizeMessage {
type: "resize";
cols: number;
rows: number;
}
export interface InputMessage {
type: "input";
data: string; // base64-encoded bytes
}
export type ClientControlMessage =
| PingMessage
| PongMessage
| ResizeMessage
| InputMessage;
// ── Server → Client ──
export interface ServerPongMessage {
type: "pong";
id: number;
}
export type ConnectionStatus = "connected" | "reconnected";
export interface StatusMessage {
type: "status";
status: ConnectionStatus;
}
export interface SetEchoStateMessage {
type: "set_echo_state";
enabled: boolean;
}
export type SessionEndReason = "process_exit" | "container_stop" | "timeout";
export interface SessionEndedMessage {
type: "session_ended";
reason: SessionEndReason;
}
export type ServerControlMessage =
| ServerPongMessage
| StatusMessage
| SetEchoStateMessage
| SessionEndedMessage;
// ── Connection state ──
export type TerminalConnectionStatus =
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export interface TerminalConnectionState {
status: TerminalConnectionStatus;
attempt: number;
latency: number | null;
error: string | null;
}
+76
View File
@@ -0,0 +1,76 @@
import type {
ClientControlMessage,
ServerControlMessage,
} from "../types/terminal";
/**
* Encode a client control message to a JSON string for sending over WebSocket.
*/
export function encodeControlMessage(msg: ClientControlMessage): string {
return JSON.stringify(msg);
}
/**
* Decode a server control message from a JSON string.
* Returns null if the data is not valid JSON or not a recognized control message.
*/
export function decodeControlMessage(
data: string,
): ServerControlMessage | null {
try {
const parsed = JSON.parse(data) as unknown;
if (!isServerControlMessage(parsed)) {
return null;
}
return parsed;
} catch {
return null;
}
}
/**
* Check whether a WebSocket message is a control message (JSON text frame)
* or raw binary data.
*/
export function isControlMessage(event: MessageEvent): boolean {
return typeof event.data === "string";
}
/**
* Encode raw input bytes to a base64 string for the `input` control message.
*/
export function encodeInputData(data: string): string {
return btoa(unescape(encodeURIComponent(data)));
}
/**
* Decode base64 input data back to a string.
*/
export function decodeInputData(data: string): string {
return decodeURIComponent(escape(atob(data)));
}
// ── Type guards ──
function isServerControlMessage(value: unknown): value is ServerControlMessage {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
if (typeof obj.type !== "string") return false;
switch (obj.type) {
case "pong":
return typeof obj.id === "number";
case "status":
return obj.status === "connected" || obj.status === "reconnected";
case "set_echo_state":
return typeof obj.enabled === "boolean";
case "session_ended":
return (
obj.reason === "process_exit" ||
obj.reason === "container_stop" ||
obj.reason === "timeout"
);
default:
return false;
}
}