diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5c654743..2bb8c74d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -66,6 +66,8 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. +**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts. + ### Run launch synchronization **Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d6b9fd33..bb10cf60 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -418,10 +418,39 @@ Object.assign(CodemanApp.prototype, { // history lives there); the plain wheel stays on the CLI's transcript for // those modes regardless of scroll position, so the CLI's input box never // slides off the screen (see _shouldForwardWheelToApp). + // + // CAPTURE phase, deliberately, and Codeman owns the scroll. xterm's + // viewport is a vscode-style ScrollableElement that consumes wheel events + // itself (preventDefault + stopPropagation) whenever it believes a + // scrollbar exists, does NOT consult attachCustomWheelEventHandler, and — + // measured on the live instance — goes DEAF after terminal.reset(): a tab + // switch or full-history replay leaves its scroll dimensions stale, after + // which wheel events neither scroll nor propagate reliably. A bubble-phase + // listener here therefore never fired once local scrollback existed + // (measured: _shouldForwardWheelToApp call count stayed 0 while xterm + // scrolled), and after a tab switch NOTHING scrolled at all — the "input + // box scrolls up then it fights", "works at first, breaks after a tab + // switch" reports on #205. + // + // So: capture runs ancestors-first; this handler sees every wheel first + // and stops propagation, keeping xterm's scroller out of it entirely. + // Local scrolling goes through terminal.scrollLines() — buffer-level, so + // it keeps working after resets — with our own deltaMode normalization + // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still + // belong to xterm and are passed through untouched: + // - mouseTrackingMode active: xterm's own encoder forwards the wheel to + // the PTY (htop/vim with mouse on in a shell pane); + // - alternate buffer (direct-PTY fallback running vim/less): xterm's + // alt-scroll handling converts the wheel to cursor keys, which is what + // those apps expect. container.addEventListener( 'wheel', (ev) => { + const trackingMode = this.terminal?.modes?.mouseTrackingMode; + if (trackingMode && trackingMode !== 'none') return; + if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); + ev.stopPropagation(); const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { this._forwardScrollToApp(ev.clientX, ev.clientY, lines); @@ -431,7 +460,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); }, - { passive: false } + { passive: false, capture: true } ); // Touch scrolling — use terminal.scrollLines() for all devices.