diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b19bf6..63d6234 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//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=` 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=` 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) | diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index f16ff27..c35d216 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -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ĩ */ 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 | 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]); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index dfda206..3abeb4d 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -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ĩ */ 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(); + 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(); + 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(); + const ws = MockWebSocket.instances[0]!; + ws.onmessage?.({ data: '{"type":"exit","code":0}' }); + ws.close(); + vi.advanceTimersByTime(5000); + expect(MockWebSocket.instances).toHaveLength(1); + vi.useRealTimers(); + }); }); diff --git a/client/src/index.css b/client/src/index.css index f9baf14..a8aeeed 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/docs/API.md b/docs/API.md index 717f09c..d1306f4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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