Terminals: unify on a shared resilient-terminal core + deploy script for agent images
- frontend/.../terminal/core.ts: `useResilientTerminal` owns the xterm lifecycle (init, fit, debounced resize, reconnect, refit-on-visible) with a pluggable transport. Two connectors: `wsConnector` (agent terminal → container PTY over WS) and `nodeWebrtcConnector` (node terminal → host PTY, direct WebRTC DataChannel with WS-relay fallback, input buffered during the race). The agent terminal now gets the node terminal's robust reconnect for free; both share one xterm setup. - TerminalApp (agent) + NodeTerminalApp (node) reduced to thin wrappers over the core — net ~330 lines of duplicated transport/reconnect/xterm code removed. - scripts/deploy.sh: the deploys were manual, so the locally-built agent images (agent-base/browser/terminal — not in any registry) were never shipped and 404'd on provision. The script always (re)builds + loads them onto gw-04 AND every fleet node, with a skip-if-identical guard so unchanged images aren't re-transferred. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a3bb16d838
commit
1072ec159a
@@ -13,6 +13,7 @@ import type { Agent } from "@/lib/api/schemas";
|
||||
import { panelParsers } from "@/lib/url/panel-params";
|
||||
|
||||
import { useSubHeader } from "./AppShell";
|
||||
import { useResilientTerminal, wsConnector } from "./terminal/core";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
@@ -261,8 +262,9 @@ export default function TerminalApp({ agent }: { agent: Agent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One tab: an xterm bridged to a tmux session over a WebSocket. Kept mounted
|
||||
* (hidden when inactive) so its session + processes keep running. */
|
||||
/** One tab: an xterm bridged to a tmux session over a WebSocket (the shared
|
||||
* resilient-terminal core). Kept mounted (hidden when inactive) so its session +
|
||||
* processes keep running. */
|
||||
function TerminalTab({
|
||||
agent,
|
||||
session,
|
||||
@@ -272,157 +274,32 @@ function TerminalTab({
|
||||
session: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
// Latest `active` for callbacks without re-running the connect effect.
|
||||
// Latest `active` for the core's callbacks without re-running the connect.
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
(async () => {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
]);
|
||||
if (disposed || !hostRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: "#0b0b0e",
|
||||
foreground: "#e6e6ea",
|
||||
cursor: "#ff8a7a",
|
||||
cursorAccent: "#0b0b0e",
|
||||
selectionBackground: "rgba(255,138,122,.28)",
|
||||
black: "#1c1c22",
|
||||
red: "#ff6f61",
|
||||
green: "#5fd08a",
|
||||
yellow: "#e8b465",
|
||||
blue: "#5ec8d8",
|
||||
magenta: "#c98af0",
|
||||
cyan: "#6fd0c0",
|
||||
white: "#cfcfd5",
|
||||
brightBlack: "#5a5a62",
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(hostRef.current);
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
// Only fit when actually visible (a hidden tab has zero size).
|
||||
if (hostRef.current.clientWidth > 0) {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* not laid out */
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
term.onData((d) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
|
||||
});
|
||||
|
||||
const sendResize = () => {
|
||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
};
|
||||
ro = new ResizeObserver(() => sendResize());
|
||||
ro.observe(hostRef.current);
|
||||
|
||||
const connect = async () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
|
||||
const { ticket } = (await res.json()) as { ticket: string };
|
||||
if (disposed) return;
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
|
||||
ticket,
|
||||
)}&session=${encodeURIComponent(session)}`,
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
sendResize();
|
||||
if (activeRef.current) term.focus();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") term.write(ev.data);
|
||||
else term.write(new Uint8Array(ev.data as ArrayBuffer));
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
if (disposed) return;
|
||||
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
|
||||
retry = setTimeout(connect, 1500);
|
||||
};
|
||||
} catch (e) {
|
||||
if (disposed) return;
|
||||
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
|
||||
retry = setTimeout(connect, 2500);
|
||||
}
|
||||
};
|
||||
void connect();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retry) clearTimeout(retry);
|
||||
ro?.disconnect();
|
||||
wsRef.current?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [agent.id, session]);
|
||||
const { hostRef, refit } = useResilientTerminal(
|
||||
{
|
||||
connect: wsConnector(async () => {
|
||||
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
|
||||
if (!res.ok) return null;
|
||||
const { ticket } = (await res.json()) as { ticket: string };
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(ticket)}&session=${encodeURIComponent(session)}`;
|
||||
}),
|
||||
visible: () => activeRef.current,
|
||||
autoFocus: true,
|
||||
},
|
||||
[agent.id, session],
|
||||
);
|
||||
|
||||
// Re-fit + focus when this tab becomes visible (it may have had zero size).
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setTimeout(() => {
|
||||
const term = termRef.current;
|
||||
const fit = fitRef.current;
|
||||
const ws = wsRef.current;
|
||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
||||
try {
|
||||
fit?.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (term && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
term?.focus();
|
||||
}, 40);
|
||||
const id = setTimeout(refit, 40);
|
||||
return () => clearTimeout(id);
|
||||
}, [active]);
|
||||
}, [active, refit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user