diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 40625548..b024875a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -404,7 +404,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ### Split-pane sessions -**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden on phones in mobile.css regardless of the setting, and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-plan.md`. +**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-plan.md`. ### Gesture control: the setting diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index dbac9fa7..e42a8b53 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -24,23 +24,33 @@ * created/destroyed pane has no equivalent of. */ function writeChunked(terminal, buffer, isDestroyed) { - if (!buffer) return; + if (!buffer) return Promise.resolve(); if (buffer.length <= TERMINAL_CHUNK_SIZE) { terminal.write(buffer); - return; + return Promise.resolve(); } - let offset = 0; - const writeNext = () => { - if (isDestroyed() || !terminal) return; - const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE); - offset += chunk.length; - terminal.write(chunk); - if (offset < buffer.length) { - if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext); - else setTimeout(writeNext, 16); - } - }; - writeNext(); + // Resolves once the LAST chunk is written (or the pane was destroyed + // mid-replay), so _loadBuffer() below can hold its single-flight flag + // across the whole replay rather than just the fetch that precedes it. + return new Promise((resolve) => { + let offset = 0; + const writeNext = () => { + if (isDestroyed() || !terminal) { + resolve(); + return; + } + const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE); + offset += chunk.length; + terminal.write(chunk); + if (offset < buffer.length) { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext); + else setTimeout(writeNext, 16); + } else { + resolve(); + } + }; + writeNext(); + }); } class SplitTerminalPane { @@ -59,6 +69,9 @@ this.ws = null; this._wsReady = false; this._destroyed = false; + // Single-flight state for _loadBuffer()/_refreshBuffer() below. + this._bufferLoading = false; + this._bufferRefreshPending = false; } async connect() { @@ -212,6 +225,8 @@ // pane. When Pane B's computed dimensions happened to already match // the session's last-known size, Session.resize() (session.ts) skips // the resize as a no-op, no repaint fires, and the pane stayed blank. + // The await covers the whole chunked replay, not just the fetch, so a + // live frame from the socket below can never land in the middle of it. await this._loadBuffer(); if (this._destroyed) return; @@ -236,8 +251,7 @@ // data was dropped). The primary pane routes this to // _onSessionNeedsRefresh (app.js:2990) — Pane B has its own // buffer loader for the same reason connect() does. - this.terminal.clear(); - void this._loadBuffer(); + this._refreshBuffer(); } } catch { /* Malformed frame — ignore, matches primary pane's tolerance. */ @@ -280,17 +294,46 @@ // get one full replay. `fetch` here goes through the global wrapper // (constants.js), which already prefixes CodemanBase — unlike the raw // WebSocket URL above, which does not. + // + // Single-flight: the flag is held across the fetch AND the chunked write + // (writeChunked resolves after its last chunk), so two replays can never + // interleave their chunks into one terminal. A second call while one is + // in flight is dropped here; _refreshBuffer() is the caller that queues + // a trailing re-run instead. async _loadBuffer() { + if (this._bufferLoading) return; + this._bufferLoading = true; try { const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`); const payload = (await res.json())?.data ?? {}; if (payload.terminalBuffer && this.terminal) { - writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); + await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); } } catch { /* Best-effort — live output still arrives once the socket connects. */ + } finally { + this._bufferLoading = false; } + if (this._bufferRefreshPending && !this._destroyed) { + this._bufferRefreshPending = false; + this._refreshBuffer(); + } + } + + // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh + // frames in a row used to start two concurrent replays, each clearing + // the terminal under the other's chunked write. A refresh that arrives + // mid-replay is COALESCED into one trailing re-run rather than ignored: + // the in-flight fetch may predate the drop the new frame is reporting, + // and no further frame is coming to correct stale content. + _refreshBuffer() { + if (this._bufferLoading) { + this._bufferRefreshPending = true; + return; + } + this.terminal?.clear(); + void this._loadBuffer(); } // Local reflow only — no PTY resize frame. Split out so a divider drag @@ -330,6 +373,10 @@ if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; + // onclose fires asynchronously AFTER close(); without this it ran + // its "disconnected" write against a pane already torn down. + this.ws.onclose = null; + this.ws.onerror = null; this.ws.close(); this.ws = null; } @@ -410,7 +457,7 @@ Object.assign(CodemanApp.prototype, { // does exact-string lookup over text nodes, and a session // literally named e.g. "Sessions" would otherwise get translated // on zh-CN (see the .session-name skip on the pane header below). - `` + `` ) .join(''); } @@ -488,6 +535,17 @@ Object.assign(CodemanApp.prototype, { // session, each independently claiming PTY dimensions via its own `{t:'z',...}` // resize frame. Refuse before creating any DOM or SplitTerminalPane. if (sessionId === this.activeSessionId) return; + // The picker's own exclusions (buildSplitPickerSessions in constants.js), + // re-applied here: the menu can sit open while a listed session's CLI + // exits (pid → null) or gets popped out to its own window, and nothing + // re-runs the picker filter for a row that already rendered. Same + // outcome as the picker gives such a session (not offered, silently): + // one with no PTY has nothing reading its pane, so Pane B would show + // nothing and drop every keystroke behind a healthy-looking socket, and + // a detached session's own window already owns its PTY size. + const session = this.sessions.get(sessionId); + if (!session || session.pid === null) return; + if (this.detachedSessions?.has?.(sessionId)) return; if (this._splitPane) this.closeSplitPane(); const wrap = document.querySelector('.terminal-wrap'); @@ -501,7 +559,6 @@ Object.assign(CodemanApp.prototype, { const paneB = document.createElement('div'); paneB.className = 'terminal-pane-b'; - const session = this.sessions.get(sessionId); paneB.innerHTML = `