From 0ba83c3d8fb7e61303b0cd6fd07af5f2faf4ac62 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 9 Sep 2026 00:24:08 +0100 Subject: [PATCH] Closes #245: port terminal pane (xterm attach) to React TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a single mount effect keyed on sessionId, tearing the socket and terminal down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context budget (acquire on mount, release on unmount, re-acquire on context loss while still mounted) and reconnect.ts's exponential 500ms->10s backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx. Pane chrome (title, disconnect, sessions drawer) stays out of scope; composition happens one level up. --- web/src/components/terminal/KeyBar.tsx | 134 +++++++ .../components/terminal/TerminalPane.test.tsx | 363 ++++++++++++++++++ web/src/components/terminal/TerminalPane.tsx | 362 +++++++++++++++++ 3 files changed, 859 insertions(+) create mode 100644 web/src/components/terminal/KeyBar.tsx create mode 100644 web/src/components/terminal/TerminalPane.test.tsx create mode 100644 web/src/components/terminal/TerminalPane.tsx diff --git a/web/src/components/terminal/KeyBar.tsx b/web/src/components/terminal/KeyBar.tsx new file mode 100644 index 0000000..219ece8 --- /dev/null +++ b/web/src/components/terminal/KeyBar.tsx @@ -0,0 +1,134 @@ +// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key +// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides +// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one +// folds into the very next key, tapped here or typed on the soft keyboard, +// then disarms. +import { useCallback, useRef, useState } from "react"; + +import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js"; + +type Modifier = "ctrl" | "alt"; +/** CSI final bytes: arrows A-D, Home `H`, End `F`. */ +type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; + +type KeyDef = + | { label: string; mod: Modifier } + | { label: string; cursor: CursorFinal } + | { label: string; tilde: number } + | { label: string; char: string } + | { label: string; seq: string }; + +/** + * Row-major into an 8-column / 2-row grid (#7): + * + * Esc Tab Home ↑ End PgUp - / + * Ctrl Alt ← ↓ → PgDn | ~ + */ +const KEY_DEFS: KeyDef[] = [ + { label: "Esc", seq: "\x1b" }, + { label: "Tab", seq: "\t" }, + { label: "Home", cursor: "H" }, + { label: "↑", cursor: "A" }, + { label: "End", cursor: "F" }, + { label: "PgUp", tilde: 5 }, + { label: "-", char: "-" }, + { label: "/", char: "/" }, + { label: "Ctrl", mod: "ctrl" }, + { label: "Alt", mod: "alt" }, + { label: "←", cursor: "D" }, + { label: "↓", cursor: "B" }, + { label: "→", cursor: "C" }, + { label: "PgDn", tilde: 6 }, + { label: "|", char: "|" }, + { label: "~", char: "~" }, +]; + +/** + * Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard + * character before it reaches the PTY — used by the terminal's own `onData` + * handler, not by this component's own button clicks. + */ +export function useKeyBarModifiers() { + const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false }); + const [, setTick] = useState(0); + const rerender = useCallback(() => setTick((n) => n + 1), []); + + const setMod = useCallback( + (mod: Modifier, on: boolean) => { + armedRef.current[mod] = on; + rerender(); + }, + [rerender], + ); + + const clearMods = useCallback(() => { + if (!armedRef.current.ctrl && !armedRef.current.alt) return; + armedRef.current = { ctrl: false, alt: false }; + rerender(); + }, [rerender]); + + const consume = useCallback((data: string): string => { + const { ctrl, alt } = armedRef.current; + if ((ctrl || alt) && data.length === 1) { + const out = applyModsToChar(data, ctrl, alt); + armedRef.current = { ctrl: false, alt: false }; + return out; + } + return data; + }, []); + + return { armed: armedRef.current, setMod, clearMods, consume }; +} + +export interface KeyBarProps { + armed: { ctrl: boolean; alt: boolean }; + onToggleMod(mod: Modifier): void; + onClearMods(): void; + onSend(data: string): void; +} + +export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) { + return ( +
+ {KEY_DEFS.map((def) => { + if ("mod" in def) { + return ( + + ); + } + const bytes = () => { + if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt); + if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt); + if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt); + return def.seq; + }; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/terminal/TerminalPane.test.tsx b/web/src/components/terminal/TerminalPane.test.tsx new file mode 100644 index 0000000..0c14f37 --- /dev/null +++ b/web/src/components/terminal/TerminalPane.test.tsx @@ -0,0 +1,363 @@ +// @vitest-environment happy-dom +import { cleanup, render } from "@testing-library/react"; +import { createRef } from "react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js"; +import { attachWsUrl } from "../../routes.js"; +import type { ClientControl } from "../../types.js"; + +class FakeTerminal { + static instances: FakeTerminal[] = []; + cols = 80; + rows = 24; + element: HTMLElement | undefined = undefined; + textarea: HTMLTextAreaElement | null = null; + disposed = false; + focus = vi.fn(); + private dataCb: ((d: string) => void) | null = null; + + constructor(public opts: unknown) { + FakeTerminal.instances.push(this); + } + loadAddon(): void {} + open(container: HTMLElement): void { + container.appendChild(document.createElement("div")); + } + onData(cb: (d: string) => void) { + this.dataCb = cb; + return { dispose: vi.fn() }; + } + emitData(d: string): void { + this.dataCb?.(d); + } + onResize() { + return { dispose: vi.fn() }; + } + onSelectionChange() { + return { dispose: vi.fn() }; + } + write(_data: Uint8Array, cb?: () => void): void { + cb?.(); + } + scrollToBottom(): void {} + resize(cols: number, rows: number): void { + this.cols = cols; + this.rows = rows; + } + clearTextureAtlas(): void {} + getSelection(): string { + return ""; + } + dispose(): void { + this.disposed = true; + } +} + +class FakeFitAddon { + fit = vi.fn(); +} + +class FakeWebglAddon { + static instances: FakeWebglAddon[] = []; + disposed = false; + private lossCb: (() => void) | null = null; + constructor() { + FakeWebglAddon.instances.push(this); + } + onContextLoss(cb: () => void): void { + this.lossCb = cb; + } + triggerLoss(): void { + this.lossCb?.(); + } + dispose(): void { + this.disposed = true; + } +} + +const policyEnabled = vi.fn(() => true); +const policyNoteLoss = vi.fn(); + +vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal })); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon })); +vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon })); +vi.mock("../../webglRenderer.js", () => ({ + WebglPolicy: vi.fn().mockImplementation(() => ({ + enabled: policyEnabled, + noteContextLoss: policyNoteLoss, + })), +})); + +class FakeWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + readyState = FakeWebSocket.CONNECTING; + binaryType = ""; + sent: (string | ArrayBufferView)[] = []; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((ev: { code: number; reason: string }) => void) | null = null; + + constructor(public url: string) { + FakeWebSocket.instances.push(this); + } + send(data: string | ArrayBufferView): void { + this.sent.push(data); + } + close(): void { + this.readyState = FakeWebSocket.CLOSED; + } + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + drop(code = 1006, reason = ""): void { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.({ code, reason }); + } + lastControl(): ClientControl { + return JSON.parse(this.sent.at(-1) as string) as ClientControl; + } +} + +beforeAll(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe(): void {} + disconnect(): void {} + unobserve(): void {} + }, + ); +}); + +beforeEach(() => { + FakeTerminal.instances = []; + FakeWebglAddon.instances = []; + FakeWebSocket.instances = []; + policyEnabled.mockReturnValue(true); + policyEnabled.mockClear(); + policyNoteLoss.mockClear(); + vi.stubGlobal("WebSocket", FakeWebSocket); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +// Imported after the mocks above so it picks up the mocked xterm/webgl modules. +const { TerminalPane } = await import("./TerminalPane.js"); + +describe("TerminalPane lifecycle", () => { + it("opens an xterm terminal and connects to the session's attach socket on mount", () => { + render(); + + expect(FakeTerminal.instances).toHaveLength(1); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1")); + }); + + it("sends the terminal size once the socket opens", () => { + render(); + const sock = FakeWebSocket.instances[0]; + + sock.open(); + + expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 }); + }); + + it("disposes the terminal and closes the socket on unmount", () => { + const { unmount } = render(); + const term = FakeTerminal.instances[0]; + const sock = FakeWebSocket.instances[0]; + + unmount(); + + expect(term.disposed).toBe(true); + expect(sock.readyState).toBe(FakeWebSocket.CLOSED); + }); + + it("reconnects to a fresh socket for a new sessionId", () => { + const { rerender } = render(); + rerender(); + + expect(FakeWebSocket.instances).toHaveLength(2); + expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2")); + expect(FakeTerminal.instances[0].disposed).toBe(true); + }); +}); + +describe("TerminalPane reconnect backoff", () => { + it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => { + vi.useFakeTimers(); + render(); + + const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, ""); + const expectStillWaiting = (ms: number, countBefore: number) => { + vi.advanceTimersByTime(ms - 1); + expect(FakeWebSocket.instances).toHaveLength(countBefore); + vi.advanceTimersByTime(1); + expect(FakeWebSocket.instances).toHaveLength(countBefore + 1); + }; + + expect(FakeWebSocket.instances).toHaveLength(1); + drop(); + expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2 + drop(); + expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3 + drop(); + expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4 + drop(); + expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5 + drop(); + expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6 + drop(); + expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7 + drop(); + expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8 + }); + + it("resets the backoff once a reconnected socket opens", () => { + vi.useFakeTimers(); + render(); + + FakeWebSocket.instances[0].drop(1006, ""); + vi.advanceTimersByTime(INITIAL_RECONNECT_MS); + FakeWebSocket.instances[1].open(); + FakeWebSocket.instances[1].drop(1006, ""); + + vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1); + expect(FakeWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(FakeWebSocket.instances).toHaveLength(3); + }); + + it("does not retry a clean close, and reports it closed", () => { + vi.useFakeTimers(); + const onClosed = vi.fn(); + render(); + + FakeWebSocket.instances[0].drop(1000, ""); + vi.advanceTimersByTime(MAX_RECONNECT_MS * 2); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onClosed).toHaveBeenCalledOnce(); + }); + + it("does not retry an application-level rejection, and writes it into the terminal", () => { + vi.useFakeTimers(); + const onClosed = vi.fn(); + render(); + const term = FakeTerminal.instances[0]; + const writeSpy = vi.spyOn(term, "write"); + + FakeWebSocket.instances[0].drop(4004, "unknown session"); + vi.advanceTimersByTime(MAX_RECONNECT_MS * 2); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onClosed).not.toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session")); + }); + + it("honors a caller-supplied shouldReconnect policy", () => { + vi.useFakeTimers(); + const onClosed = vi.fn(); + render( false} onClosed={onClosed} />); + + FakeWebSocket.instances[0].drop(1006, ""); + vi.advanceTimersByTime(MAX_RECONNECT_MS * 2); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onClosed).toHaveBeenCalledOnce(); + }); +}); + +describe("TerminalPane WebGL context budget", () => { + it("acquires a WebGL context on mount, shared policy allowing", () => { + render(); + + expect(policyEnabled).toHaveBeenCalled(); + expect(FakeWebglAddon.instances).toHaveLength(1); + }); + + it("never creates a context once the shared budget is exhausted", () => { + policyEnabled.mockReturnValue(false); + render(); + + expect(FakeWebglAddon.instances).toHaveLength(0); + }); + + it("releases its context on unmount, handing the budget back", () => { + const { unmount } = render(); + const addon = FakeWebglAddon.instances[0]; + + unmount(); + + expect(addon.disposed).toBe(true); + }); + + it("counts a context loss and re-acquires while still mounted", () => { + render(); + const first = FakeWebglAddon.instances[0]; + + first.triggerLoss(); + + expect(policyNoteLoss).toHaveBeenCalledOnce(); + expect(first.disposed).toBe(true); + expect(FakeWebglAddon.instances).toHaveLength(2); + }); + + it("does not re-acquire a lost context once the budget is spent", () => { + render(); + const first = FakeWebglAddon.instances[0]; + policyEnabled.mockReturnValue(false); + + first.triggerLoss(); + + expect(FakeWebglAddon.instances).toHaveLength(1); + }); +}); + +describe("TerminalPane imperative handle", () => { + it("sendInput writes bytes to the PTY only once the socket is open", () => { + const ref = createRef(); + render(); + const sock = FakeWebSocket.instances[0]; + + ref.current!.sendInput("nope"); + expect(sock.sent).toHaveLength(0); + + sock.open(); + sock.sent.length = 0; // drop the resize control frame from open() + ref.current!.sendInput("hi"); + + expect(sock.sent).toHaveLength(1); + expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi"); + }); + + it("focusTerminal focuses the underlying xterm instance", () => { + const ref = createRef(); + render(); + + ref.current!.focusTerminal(); + + expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce(); + }); + + it("routes typed keystrokes through to the socket", () => { + render(); + const sock = FakeWebSocket.instances[0]; + sock.open(); + sock.sent.length = 0; + + FakeTerminal.instances[0].emitData("q"); + + expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q"); + }); +}); diff --git a/web/src/components/terminal/TerminalPane.tsx b/web/src/components/terminal/TerminalPane.tsx new file mode 100644 index 0000000..02b495c --- /dev/null +++ b/web/src/components/terminal/TerminalPane.tsx @@ -0,0 +1,362 @@ +// React port of ../../attach.ts: an xterm terminal bound to the daemon's +// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget +// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect +// button, sessions drawer) stays out of scope here — this is the thin +// ref-based xterm wrapper the issue asked for; a header composes around it. +import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; + +import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js"; +import { attachWsUrl } from "../../routes.js"; +import type { ClientControl, Uuid } from "../../types.js"; +import { WebglPolicy } from "../../webglRenderer.js"; +import { KeyBar, useKeyBarModifiers } from "./KeyBar.js"; + +/** One budget for the whole page — every mounted `TerminalPane` shares it, the + * same reasoning as attach.ts's own module-level policy. */ +const webglPolicy = new WebglPolicy(); + +export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error"; + +/** Application-defined close codes (daemon rejection) and the clean-close + * code — retrying either would only loop. Mirrors reconnect.ts's + * `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */ +function defaultShouldReconnect(code: number): boolean { + return code < 4000 && code !== 1000; +} + +export interface TerminalPaneProps { + sessionId: Uuid; + /** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */ + focus?: boolean; + /** Asked on every close: should the socket be retried? */ + shouldReconnect?: (code: number) => boolean; + /** The socket closed for good (retry declined, or the daemon rejected the + * session) — not called on an ordinary unmount. */ + onClosed?: () => void; + /** Connection status changes, for a header/status badge to render. */ + onStatusChange?: (status: TerminalStatus, detail?: string) => void; + className?: string; +} + +export interface TerminalPaneHandle { + focusTerminal(): void; + sendInput(data: string): void; +} + +export const TerminalPane = forwardRef( + function TerminalPane( + { sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className }, + ref, + ) { + const bodyRef = useRef(null); + const sendInputRef = useRef<(data: string) => void>(() => {}); + const focusTerminalRef = useRef<() => void>(() => {}); + const keyBar = useKeyBarModifiers(); + const [keybarActive, setKeybarActive] = useState(false); + + useImperativeHandle( + ref, + () => ({ + focusTerminal: () => focusTerminalRef.current(), + sendInput: (data: string) => sendInputRef.current(data), + }), + [], + ); + + useEffect(() => { + const body = bodyRef.current; + if (!body) return; + + const term = new Terminal({ + cursorBlink: true, + fontFamily: + '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 13, + convertEol: false, + theme: { + background: "#121419", + foreground: "#e6e8ec", + cursor: "#5fe3a1", + cursorAccent: "#121419", + selectionBackground: "#2b6b4f", + }, + scrollback: 5000, + allowProposedApi: true, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(body); + + let webgl: WebglAddon | null = null; + let dprMedia: MediaQueryList | null = null; + + const onDprChange = () => { + try { + term.clearTextureAtlas(); + } catch { + // ignore — nothing to rebuild if the renderer already went away + } + watchDpr(); + }; + + function watchDpr(): void { + dprMedia?.removeEventListener("change", onDprChange); + dprMedia = null; + try { + dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null; + dprMedia?.addEventListener("change", onDprChange); + } catch { + // ignore — a browser without `resolution` media queries just keeps + // the atlas it has + } + } + + function releaseWebgl(): void { + dprMedia?.removeEventListener("change", onDprChange); + dprMedia = null; + if (!webgl) return; + const addon = webgl; + webgl = null; + try { + addon.dispose(); + } catch { + // ignore — a lost context can throw on the way out + } + } + + function acquireWebgl(): void { + if (webgl || disposed || !webglPolicy.enabled()) return; + try { + const addon = new WebglAddon(); + addon.onContextLoss(() => { + webglPolicy.noteContextLoss(); + releaseWebgl(); + if (mounted) acquireWebgl(); + }); + term.loadAddon(addon); + webgl = addon; + watchDpr(); + } catch { + webglPolicy.noteContextLoss(); + webgl = null; + } + } + + let mounted = false; + const tryFit = () => { + if (!mounted) return; + try { + const el2 = term.element; + const parent = el2?.parentElement; + const core = ( + term as unknown as { + _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } }; + } + )._core; + const cell = core?._renderService?.dimensions?.css?.cell; + if (!el2 || !parent || !cell || !cell.width || !cell.height) { + fit.fit(); + return; + } + const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null; + let availW: number; + let availH: number; + if (vp && vp.clientWidth > 0) { + availW = vp.clientWidth; + availH = vp.clientHeight; + } else { + const ps = getComputedStyle(parent); + const ts = getComputedStyle(el2); + availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0); + availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0); + } + const cols = Math.max(2, Math.floor(availW / cell.width)); + const rows = Math.max(1, Math.floor(availH / cell.height)); + if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows); + } catch { + try { + fit.fit(); + } catch { + // ignore — fit fails if the container is detached or zero-sized + } + } + }; + + let ws: WebSocket; + let reconnectMs = INITIAL_RECONNECT_MS; + let reconnectTimer: number | null = null; + let awaitingReplay = true; + let disposed = false; + + const sendControl = (msg: ClientControl) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(msg)); + }; + + const sendInput = (data: string) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(new TextEncoder().encode(data)); + }; + sendInputRef.current = sendInput; + focusTerminalRef.current = () => term.focus(); + + const onResize = () => { + tryFit(); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + }; + + function connect(): void { + awaitingReplay = true; + ws = new WebSocket(attachWsUrl(sessionId)); + ws.binaryType = "arraybuffer"; + + ws.onopen = () => { + reconnectMs = INITIAL_RECONNECT_MS; + onStatusChange?.("open"); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + if (mounted && focus) term.focus(); + }; + + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + try { + JSON.parse(ev.data) as ClientControl; + } catch { + // ignore unknown control frames + } + return; + } + if (ev.data instanceof ArrayBuffer) { + const replay = awaitingReplay; + awaitingReplay = false; + term.write(new Uint8Array(ev.data), () => { + if (replay) term.scrollToBottom(); + }); + } else if (ev.data instanceof Blob) { + void ev.data.arrayBuffer().then((b) => { + const replay = awaitingReplay; + awaitingReplay = false; + term.write(new Uint8Array(b), () => { + if (replay) term.scrollToBottom(); + }); + }); + } + }; + + ws.onerror = () => { + onStatusChange?.("error"); + }; + + ws.onclose = (ev) => { + const reason = ev.reason?.trim(); + if (ev.code >= 4000 && reason && !disposed) { + onStatusChange?.("error", reason); + term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`); + return; + } + if (disposed) return; + + if (shouldReconnect(ev.code)) { + const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); + onStatusChange?.("reconnecting"); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); + connect(); + }, delay); + return; + } + + onStatusChange?.("closed", String(ev.code)); + onClosed?.(); + }; + } + + onStatusChange?.("connecting"); + connect(); + + const dataDisp = term.onData((data) => { + sendInput(keyBar.consume(data)); + }); + const resizeDisp = term.onResize(({ cols, rows }) => { + sendControl({ type: "Resize", cols, rows }); + }); + const selectionDisp = term.onSelectionChange(() => { + const text = term.getSelection(); + if (text) void navigator.clipboard?.writeText(text).catch(() => {}); + }); + + const ta = term.textarea; + const onTaFocus = () => { + setKeybarActive(true); + requestAnimationFrame(tryFit); + }; + const onTaBlur = () => { + setKeybarActive(false); + requestAnimationFrame(tryFit); + }; + ta?.addEventListener("focus", onTaFocus); + ta?.addEventListener("blur", onTaBlur); + + window.addEventListener("resize", onResize); + const visual = window.visualViewport ?? null; + visual?.addEventListener("resize", onResize); + + const ro = new ResizeObserver(() => tryFit()); + ro.observe(body); + document.fonts?.ready?.then(() => tryFit()).catch(() => {}); + + mounted = true; + acquireWebgl(); + requestAnimationFrame(() => { + tryFit(); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + if (focus && ws.readyState === WebSocket.OPEN) term.focus(); + }); + + return () => { + disposed = true; + mounted = false; + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ro.disconnect(); + window.removeEventListener("resize", onResize); + visual?.removeEventListener("resize", onResize); + ta?.removeEventListener("focus", onTaFocus); + ta?.removeEventListener("blur", onTaBlur); + dataDisp.dispose(); + resizeDisp.dispose(); + selectionDisp.dispose(); + releaseWebgl(); + try { + ws.close(); + } catch { + // ignore + } + term.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting + // a session change is a fresh connection, not a prop-driven re-render + }, [sessionId]); + + return ( +
+
+ {keybarActive && ( + keyBar.setMod(mod, !keyBar.armed[mod])} + onClearMods={keyBar.clearMods} + onSend={(data) => sendInputRef.current(data)} + /> + )} +
+ ); + }, +);