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:
2026-08-19 12:02:01 +07:00
parent aaa67da394
commit c7e934b4b4
5 changed files with 103 additions and 29 deletions
+56 -26
View File
@@ -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]);