fix(run): stop the terminal wheel from typing arrow keys, re-attach dropped sockets
Three separate reasons the Workspace terminal looked unscrollable: - xterm falls back, on an alt-screen buffer whose program has not enabled mouse tracking, to translating each wheel notch into a cursor-key press. Inside a `claude` pane that reads as arrow up/down, so scrolling walked the prompt history. A custom wheel handler returning false suppresses only that emulation branch; real mouse reports come from a separate listener xterm registers when the program does request wheel events. - TerminalView never reconnected, so a server restart left the pane frozen on its last painted frame — still live-looking, but swallowing every keystroke and mouse report. It now re-attaches 1.5s after an unexpected close and stops only on the server's `exit` frame; tmux keeps the session, so the re-attach repaints in full. - tmux repaints the whole pane, so xterm's own scrollback is always empty and its viewport scrollbar renders as a groove with no thumb that cannot move. Hidden, since scrolling there goes through mouse reports, not the DOM.
This commit is contained in:
@@ -6,6 +6,9 @@
|
||||
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
|
||||
* directions; a JSON text frame carries the initial `resize` on mount and
|
||||
* the server's one-shot `exit` notice when the pane process ends.
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
@@ -28,52 +31,79 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
fit.fit();
|
||||
|
||||
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||
ws.binaryType = "arraybuffer";
|
||||
// When the pane's program has NOT enabled mouse tracking, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
|
||||
// the wheel silently walks the prompt history instead of scrolling. This
|
||||
// handler kills only that emulation branch: real mouse reports are sent by
|
||||
// a separate listener xterm registers when the program does ask for wheel
|
||||
// events, so wheel scrolling still works wherever tracking is on.
|
||||
term.attachCustomWheelEventHandler(() => false);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||
if (typeof data === "string") {
|
||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
let ws: WebSocket | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let ended = false; // server said the pane exited — nothing left to attach to
|
||||
let disposed = false;
|
||||
|
||||
const connect = () => {
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
ws = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||
sock.binaryType = "arraybuffer";
|
||||
|
||||
sock.onopen = () => {
|
||||
sock.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
sock.onmessage = (event) => {
|
||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||
if (typeof data === "string") {
|
||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
term.write(data);
|
||||
}
|
||||
term.write(data);
|
||||
}
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (disposed || ended) return;
|
||||
// tmux keeps the session alive across a server restart, so re-attaching
|
||||
// repaints the pane in full — no output is lost by retrying.
|
||||
retryTimer = setTimeout(connect, 1500);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
resizeObserver.disconnect();
|
||||
dataDisposable.dispose();
|
||||
ws.close();
|
||||
ws?.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file TerminalView.test.tsx
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, and forwards typed input as outgoing binary frames.
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
@@ -13,6 +14,7 @@ const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
@@ -24,6 +26,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
},
|
||||
dispose: disposeMock,
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
@@ -56,6 +59,7 @@ describe("TerminalView", () => {
|
||||
MockWebSocket.instances = [];
|
||||
onDataHandlers.length = 0;
|
||||
writeMock.mockClear();
|
||||
wheelHandlerMock.mockClear();
|
||||
openMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -90,4 +94,32 @@ describe("TerminalView", () => {
|
||||
onDataHandlers[0]!("ls -la\r");
|
||||
expect(ws.sent).toEqual(["ls -la\r"]);
|
||||
});
|
||||
|
||||
it("swallows wheel events xterm would otherwise turn into arrow keys", () => {
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(wheelHandlerMock).toHaveBeenCalledTimes(1);
|
||||
// false = xterm skips its alt-screen wheel→cursor-key emulation, which in a
|
||||
// claude pane would walk the prompt history instead of scrolling.
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
MockWebSocket.instances[0]!.close();
|
||||
vi.advanceTimersByTime(1600);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not re-attach once the server reported the pane exited", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onmessage?.({ data: '{"type":"exit","code":0}' });
|
||||
ws.close();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The run console's terminal attaches to tmux, which repaints the whole pane
|
||||
on every frame — xterm's own scrollback stays empty forever, so its
|
||||
viewport scrollbar renders as a groove with no thumb that never moves.
|
||||
Scrolling there goes through mouse reports to the pane's program, not the
|
||||
DOM, so hide the dead affordance. */
|
||||
.xterm-viewport {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Visually hidden until focused — first Tab lands here before the sidebar. */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user