diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts
index 995076d41b..56202eb4b3 100644
--- a/web/src/lib/pty-reconnect.test.ts
+++ b/web/src/lib/pty-reconnect.test.ts
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'
import {
PTY_RECONNECT_BASE_MS,
+ PTY_KEEPALIVE_INTERVAL_MS,
PTY_RECONNECT_MAX_ATTEMPTS,
PTY_RECONNECT_MAX_MS,
ptyReconnectDelayMs,
shouldBlockPtyInput,
+ shouldSendPtyKeepalive,
shouldReconnectPtyOnPageResume
} from './pty-reconnect'
@@ -139,6 +141,36 @@ describe('shouldBlockPtyInput', () => {
})
})
+describe('shouldSendPtyKeepalive', () => {
+ it('keeps an active, visible PTY socket alive every 20 seconds', () => {
+ expect(PTY_KEEPALIVE_INTERVAL_MS).toBe(20_000)
+ expect(
+ shouldSendPtyKeepalive({
+ isActive: true,
+ visibilityState: 'visible',
+ socketReadyState: 1
+ })
+ ).toBe(true)
+ })
+
+ it('does not send control traffic from hidden tabs or closed sockets', () => {
+ expect(
+ shouldSendPtyKeepalive({
+ isActive: true,
+ visibilityState: 'hidden',
+ socketReadyState: 1
+ })
+ ).toBe(false)
+ expect(
+ shouldSendPtyKeepalive({
+ isActive: true,
+ visibilityState: 'visible',
+ socketReadyState: 3
+ })
+ ).toBe(false)
+ })
+})
+
describe('ptyReconnectDelayMs', () => {
it('doubles from the base on each attempt and clamps at the cap', () => {
expect(Array.from({ length: PTY_RECONNECT_MAX_ATTEMPTS }, (_, i) => ptyReconnectDelayMs(i + 1))).toEqual([
diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts
index 4a6ce8cf7a..da592461ba 100644
--- a/web/src/lib/pty-reconnect.ts
+++ b/web/src/lib/pty-reconnect.ts
@@ -28,6 +28,12 @@ export const PTY_RECONNECT_BASE_MS = 250
export const PTY_RECONNECT_MAX_MS = 3000
export const PTY_RECONNECT_MAX_ATTEMPTS = 5
+// Browsers cannot emit WebSocket ping frames directly. A resize control frame
+// is consumed by `/api/pty` without reaching the child process, so it is a
+// safe application-level keepalive for quiet terminals behind idle-closing
+// proxies.
+export const PTY_KEEPALIVE_INTERVAL_MS = 20_000
+
/** Delay before PTY reconnect `attempt` (1-based: ChatPage bumps its counter before scheduling). */
export function ptyReconnectDelayMs(attempt: number): number {
return reconnectBackoffDelayMs(attempt - 1, {
@@ -63,6 +69,16 @@ const WS_OPEN = 1
const WS_CLOSING = 2
const WS_CLOSED = 3
+export interface PtyKeepaliveInput {
+ isActive: boolean
+ visibilityState?: DocumentVisibilityState
+ socketReadyState?: number | null
+}
+
+export function shouldSendPtyKeepalive({ isActive, visibilityState, socketReadyState }: PtyKeepaliveInput): boolean {
+ return isActive && visibilityState !== 'hidden' && socketReadyState === WS_OPEN
+}
+
export function shouldReconnectPtyOnPageResume({
isActive,
visibilityState,
diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx
index 77b0d7d257..6fc28d2166 100644
--- a/web/src/pages/ChatPage.test.tsx
+++ b/web/src/pages/ChatPage.test.tsx
@@ -15,6 +15,10 @@ class FakeFitAddon {
}
class FakeWebglAddon {
+ constructor() {
+ webglAddonConstructed += 1;
+ }
+
onContextLoss() {
return { dispose() {} };
}
@@ -150,7 +154,7 @@ class FakeWebSocket {
this.readyState = 3;
}
- send() {}
+ send = vi.fn();
}
type CloseEventLike = {
@@ -161,6 +165,7 @@ type CloseEventLike = {
let container: HTMLDivElement;
let root: Root;
+let webglAddonConstructed = 0;
// jsdom runs without an origin here (per-file @vitest-environment jsdom on a
// node-default config), so localStorage is undefined. Stub it so components
@@ -195,6 +200,7 @@ async function render(ui: ReactNode) {
beforeEach(() => {
FakeWebSocket.instances = [];
+ webglAddonConstructed = 0;
maybeReloadForLoopbackWsAuthFailure.mockClear();
apiMocks.buildWsUrl.mockReset();
apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1");
@@ -259,6 +265,65 @@ afterEach(async () => {
});
describe("ChatPage", () => {
+ it("uses the canvas renderer and sends a visible PTY keepalive", async () => {
+ vi.useFakeTimers();
+ try {
+ const { default: ChatPage } = await import("./ChatPage");
+ await render(
+
+
+ ,
+ );
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(webglAddonConstructed).toBe(0);
+
+ const socket = FakeWebSocket.instances[0];
+ await act(async () => socket.onopen?.());
+ socket.send.mockClear();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(20_000);
+ });
+
+ expect(socket.send).toHaveBeenCalledWith("\x1b[RESIZE:80;24]");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("defers a reconnect while the chat tab is inactive", async () => {
+ vi.useFakeTimers();
+ try {
+ const { default: ChatPage } = await import("./ChatPage");
+ await render(
+
+
+ ,
+ );
+ await act(async () => {
+ await Promise.resolve();
+ });
+ const socket = FakeWebSocket.instances[0];
+ await act(async () => {
+ socket.onclose?.({ code: 1001, reason: "", wasClean: true });
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
const { default: ChatPage } = await import("./ChatPage");
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 3deb64c7e2..ca74a9eac9 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -4,7 +4,7 @@
*
(dashboard chrome) .
* └─
(rounded, dark bg, padded — the "terminal window" .
* look that gives the page a distinct visual identity) .
- * └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
+ * └─ @xterm/xterm Terminal (canvas renderer, Unicode 11 widths) .
* │ onData keystrokes → WebSocket → PTY master .
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
* │ write(data) PTY output bytes → VT100 parser .
@@ -19,7 +19,6 @@
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
-import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Button } from "@nous-research/ui/ui/components/button";
@@ -43,6 +42,7 @@ import { shouldRestoreTerminalFocus } from "@/lib/pty-focus";
import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer";
import {
PTY_CONNECTING_TIMEOUT_MS,
+ PTY_KEEPALIVE_INTERVAL_MS,
PTY_RECONNECT_INPUT_MESSAGE,
PTY_RECONNECT_MAX_ATTEMPTS,
PTY_RESUME_RECONNECT_THROTTLE_MS,
@@ -51,6 +51,7 @@ import {
type PtyConnectionState,
ptyReconnectDelayMs,
shouldBlockPtyInput,
+ shouldSendPtyKeepalive,
shouldReconnectPtyOnPageResume,
} from "@/lib/pty-reconnect";
import {
@@ -195,6 +196,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const termRef = useRef(null);
const fitRef = useRef(null);
const wsRef = useRef(null);
+ const isActiveRef = useRef(isActive);
+ useEffect(() => {
+ isActiveRef.current = isActive;
+ }, [isActive]);
const stickToBottomRef = useRef(true);
// Exposed to the main metrics-sync effect so it can refit the terminal
// the moment `isActive` flips back to true (display:none → display:flex
@@ -333,10 +338,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// to misfire on the next activation (#106403: repeated last character).
useEffect(() => {
if (!isActive) {
+ clearReconnectTimer();
ptyInputLineRef.current = "";
mobileReplacementInputUntilRef.current = 0;
}
- }, [isActive]);
+ }, [clearReconnectTimer, isActive]);
// Raw state for the mobile side-sheet + a derived value that force-
// closes whenever the chat tab isn't active. The *derived* value is
// what side-effects (body-scroll lock, keydown listener, portal render)
@@ -923,24 +929,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
};
}
- // WebGL draws from a texture atlas sized with device pixels. On phones and
- // in DevTools device mode that often produces *visually* much larger cells
- // than `fontSize` suggests — users see "huge" text even at 7–9px settings.
- // The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
- // hosts. Wide layouts still get WebGL for crisp box-drawing.
- const useWebgl = terminalTierWidthPx(host) >= 768;
- if (useWebgl) {
- try {
- const webgl = new WebglAddon();
- webgl.onContextLoss(() => webgl.dispose());
- term.loadAddon(webgl);
- } catch (err) {
- console.warn(
- "[hermes-chat] WebGL renderer unavailable; falling back to default",
- err,
- );
- }
- }
+ // Keep the default canvas renderer. Reconnects recreate the xterm
+ // instance; avoiding WebGL prevents those short-lived instances from
+ // exhausting the browser's limited WebGL context budget.
// Initial fit + resize observer. fit.fit() reads the container's
// current bounding box and resizes the terminal grid to match.
@@ -1177,6 +1168,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// attempt cannot open a socket behind the replacement this schedules.
let ticketSuperseded = false;
let ticketTimer: ReturnType | null = null;
+ let keepaliveTimer: ReturnType | null = null;
+ const clearKeepaliveTimer = () => {
+ if (keepaliveTimer) {
+ clearInterval(keepaliveTimer);
+ keepaliveTimer = null;
+ }
+ };
const clearTicketTimer = () => {
if (ticketTimer) {
clearTimeout(ticketTimer);
@@ -1186,6 +1184,16 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// `code` is null when the attempt died before any socket existed — the
// banner then omits the "(code N)" suffix rather than inventing one.
const scheduleReconnect = (code: number | null) => {
+ // ChatPage remains mounted behind other dashboard routes. Do not churn
+ // through reconnect attempts while it is inactive or the document is
+ // hidden; the page-resume listener starts one when the user returns.
+ if (
+ !isActiveRef.current ||
+ (typeof document !== "undefined" && document.visibilityState === "hidden")
+ ) {
+ setPtyState("closed");
+ return;
+ }
if (reconnectTimerRef.current) {
return;
}
@@ -1290,7 +1298,23 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// out against on its first paint. The double-rAF block above will
// follow up with the authoritative measurement — at worst Ink
// reflows once after the PTY boots, which is imperceptible.
- ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
+ const sendTerminalResize = () => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
+ }
+ };
+ sendTerminalResize();
+ keepaliveTimer = setInterval(() => {
+ if (
+ shouldSendPtyKeepalive({
+ isActive: isActiveRef.current,
+ visibilityState: document.visibilityState,
+ socketReadyState: ws.readyState,
+ })
+ ) {
+ sendTerminalResize();
+ }
+ }, PTY_KEEPALIVE_INTERVAL_MS);
// Resumed sessions replay scrollback over the socket. Start pinned to
// the bottom so the latest output is in view; released once the user
// scrolls up (#59591).
@@ -1384,6 +1408,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
};
ws.onclose = (ev) => {
+ clearKeepaliveTimer();
// Drain buffered sanitizer state. A buffered partial escape is dropped
// (writing an unterminated CSI would wedge xterm's parser); a buffered
// newline run is emitted collapsed.
@@ -1563,6 +1588,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
clearReconnectTimer();
clearConnectingTimer();
clearTicketTimer();
+ clearKeepaliveTimer();
ticketSuperseded = true;
connectInFlightRef.current = false;
// Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode
@@ -1720,6 +1746,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
window.addEventListener("pageshow", onResume);
window.addEventListener("focus", onResume);
window.addEventListener("online", onResume);
+ onResume();
return () => {
document.removeEventListener("visibilitychange", onResume);