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:
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user