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
+1 -1
View File
@@ -624,7 +624,7 @@ graph LR
| `/analytics` | Analytics | `GET /api/analytics` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
| `/*` | NotFound | None (static 404 page) |
+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í <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í <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();
});
});
+12
View File
@@ -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;
+1 -1
View File
@@ -1671,7 +1671,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `TIOCSWINSZ` ioctl down the pane's pty when the browser terminal is resized, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `TIOCSWINSZ` ioctl down the pane's pty when the browser terminal is resized, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed