docs: Pane B is a TerminalTile (reconnect, exactly-once input, focus)

CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 10:46:33 +02:00
parent fe9b209f67
commit 497711a05e
4 changed files with 38 additions and 25 deletions
+3 -3
View File
@@ -274,9 +274,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`; Ctrl+W deliberately still closes `activeSessionId`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The planned tile grid reuses `TerminalTile` (`docs/tile-grid-plan.md`). → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
@@ -325,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
File diff suppressed because one or more lines are too long
+7
View File
@@ -4,6 +4,13 @@
**Author**: Claude (session with Tim), 2026-09-15
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
> reconnects after a drop, delivers input exactly once, has clickable paths
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
> the app-level terminal shortcuts follow the focused pane (Ctrl+W excepted).
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
## Problem
Codeman's terminal area shows exactly one active session (pane) at a time —
+27 -21
View File
@@ -1,6 +1,6 @@
# Tile Grid: Design Spec
**Status**: Proposed, not implemented. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
@@ -239,8 +239,9 @@ behavior listed under "Current architecture". The split orchestration stays in
`terminal-split.js` and constructs a `TerminalTile` for Pane B; the grid (PR 2)
constructs one per tile. New in the class:
**Reconnect.** Backoff 0.5 s, 1 s, 2 s, 4 s, 8 s, capped at 15 s, reset on a
successful open. A reconnect is also kicked when SSE `handleInit` reports the
**Reconnect.** The primary pane's backoff ladder (`CodemanWsReconnect`,
constants.js: 0, 250 ms, 500 ms, ... capped at 10 s) plus up to 250 ms of
jitter, the attempt count reset only by a successful open. A reconnect is also kicked when SSE `handleInit` reports the
server is back. After every reopen the tile runs a bounded refresh (the same
in-stream `\x1bc` clear plus replay as `_refreshBuffer`), because output
frames carry no sequence number and a gap cannot be replayed otherwise. That
@@ -252,7 +253,7 @@ prevent. Close codes that must NOT reconnect:
| Code | Meaning | Tile does | Owner decides (via `onExit`) |
|---|---|---|---|
| 4009 | Session exited | Stops reconnecting, reports the code | PR 1 split: an "exited" marker in Pane B (the split's existing delete path collapses it if the session is removed). PR 2 grid: the Attach overlay |
| 4003 / 4004 | Forbidden / session gone | Stops reconnecting, reports the code | PR 1 split: a "stopped" marker. PR 2 grid: removes the tile |
| 4003 / 4004 | Forbidden / session gone | Stops reconnecting, reports the code (4003 is stopped by the tile itself: `CodemanWsReconnect` classes it as transient) | PR 1 split: a marker saying why. PR 2 grid: removes the tile |
| 4010 | Superseded by a socket with the same cid | Stops, but only for the CURRENT socket (see below) | Same marker as 4003 |
The class never decides what happens to its container; it reports the close
@@ -268,8 +269,11 @@ opening a replacement the tile detaches the old socket's handlers (as
`close()`), and every handler checks `event.target === this.ws` and ignores
events from any socket that is no longer current.
The marker text becomes `[disconnected, reconnecting…]` and keeps its
"last thing on screen" rule.
The marker text becomes `[disconnected, reconnecting…]` (a stop writes
`[disconnected: <why>]`, `TerminalTile.STOP_MARKERS`) and keeps its "last
thing on screen" rule. On reopen the closed state is cleared BEFORE the gap
refresh, or the refresh re-owes the marker and stamps it under a healthy
pane. `reconnectNow()` skips the backoff and never replaces an open socket.
**Client id on the upgrade URL.** `cid=${clientId}:${tabNonce}:tile`. The
connection registry supersedes by cid PER SESSION, so a distinct suffix means a
@@ -317,9 +321,9 @@ to a 1 MiB window, so a larger buffer only fills with live output over time.
The shell history pull's "pane full" check reads `term.options.scrollback`, so
it adapts to the lower cap unchanged.
**Key handler.** Pane B's `attachCustomKeyEventHandler` body is extracted into
a shared factory, `createPaneKeyHandler({ terminal, getSessionId, getMode })`,
used by every tile, plus:
**Key handler.** Pane B's `attachCustomKeyEventHandler` stays in
`TerminalTile` (every tile IS a `TerminalTile`, so no separate factory is
needed), plus:
- Ctrl+V routes into the image-paste trap with this tile's terminal and session;
- the new tile chords are swallowed (return false) so they never reach the PTY.
@@ -508,7 +512,7 @@ run back to back on the event loop and stall every WS and SSE stream on the
server for seconds.
So EVERY tile load goes through ONE grid-level client queue (PR 2, plugged in
through the `scheduleLoad` option `TerminalTile` gets in PR 1): the initial load,
through a `scheduleLoad` option PR 2 adds to `TerminalTile`): the initial load,
the refresh after a reconnect, a server `{t:'r'}` refresh, and the shell
history pull. No tile calls `fetch('/terminal…')` on its own. The tile's
single-flight flag stays (it is what keeps one tile's replays from
@@ -638,11 +642,9 @@ Commits:
`TerminalTile` for Pane B. New in the class: reconnect with race-free socket
replacement and the close-code table, the `:tile` cid suffix, reliable input
through the socket map, `zc` handling with one geometry method, the
file-path link provider, image paste, and the shared key handler factory.
Constructor options make it reusable by PR 2 without changes:
`scrollback` (the split passes `DEFAULT_SCROLLBACK`), `scheduleLoad`
(default: run the load directly; PR 2 injects the grid queue), `onFocus`
and `onExit` callbacks.
file-path link provider and image paste, and an `onExit` callback. The
`scrollback` and `scheduleLoad` options were deferred to PR 2, which
introduces them together with the grid's load queue, their first user.
3. **Split pane follows focus.** `_focusedPane()` returns Pane B while its
xterm has focus, so Ctrl+L, Ctrl+Shift+R, voice and image paste act on the
pane you are typing in. This removes most of the asymmetry the split-pane
@@ -672,8 +674,8 @@ PR 1 tests (gate):
(single-flight, marker-last including the async-parse fake, history pull),
plus: reconnect backoff and each close code, a late `onclose` (4010) from a
replaced socket is ignored and the tile keeps running, `zc` columns-only
adoption, the cid suffix, input routed through `_sendInputAsync`, loads routed
through an injected `scheduleLoad`.
adoption, the cid suffix, input routed through `_sendInputAsync` (as built:
`test/terminal-tile-input.test.ts`, which runs `connect()` for real).
- `test/input-socket-map.test.ts`: acks routed to the right session's queue,
redelivery per socket, POST fallback when no socket is registered.
- `test/focused-pane-shortcuts.test.ts`: with Pane B focused, Ctrl+L clears
@@ -689,9 +691,12 @@ PR 1 tests (gate):
- Every existing `split-pane-*` test keeps passing (class name updated where it
is referenced).
PR 1 browser tests: the existing `split-pane-*.browser.test.ts` files, plus
Pane B reconnecting after a server restart and a click on a printed path in
Pane B opening the file preview for Pane B's session.
PR 1 browser tests: the existing `split-pane-*.browser.test.ts` files (they
match master, one pre-existing environmental failure in both). Pane B
reconnecting after a server restart and a click on a printed path opening
Pane B's file preview are covered by unit tests and checked live on the beta
instance rather than as browser tests (the harness cannot restart its own
server).
PR 1 verification: a split with two real Claude sessions on the beta instance;
restart the server mid-typing in Pane B (reconnect, refresh, no lost or doubled
@@ -708,7 +713,8 @@ Commits:
observer), the `selectSession` tile branch with the `auto` rule, the
grid-aware `closeSession` fallback, focus rules, tile chords in the shortcut
registry, the single grid-level load queue that every tile load goes
through (injected as each tile's `scheduleLoad`), coexistence with the split.
through (a new `scheduleLoad` option on `TerminalTile`, plus a `scrollback`
option for `TILE_SCROLLBACK`), coexistence with the split.
2. **Tile chrome and entry points.** Header (dot, name, menu, zoom, add,
remove), the Attach overlay, picker, dividers, drag-a-tab, Ctrl/Cmd+click,
"Open group as tiles".