Skip to content

fix(terminal): native-feel mobile touch scrolling — 1:1 tracking + momentum tail#40

Merged
miguelrisero merged 9 commits into
mainfrom
chief/bc-mobile-scroll
Jul 21, 2026
Merged

fix(terminal): native-feel mobile touch scrolling — 1:1 tracking + momentum tail#40
miguelrisero merged 9 commits into
mainfrom
chief/bc-mobile-scroll

Conversation

@miguelrisero

@miguelrisero miguelrisero commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Problem

Owner report: "fix scrolling on mobile, it works but its not great, we scroll super slow on mobile which is not great."

All touch scrolling in the CLI terminal pane goes through the touch→wheel bridge in terminalTouchScroll.ts (xterm 5.5 disables its own touch scrolling whenever the app enables mouse tracking, and the embedded tmux always does). The bridge required ~25px of finger travel per scrolled line (WHEEL_STEP_PX = 24 + 8px axis lock) regardless of gesture speed, and had zero momentum — scrolling halted the instant the finger lifted. Native mobile scrolling tracks content 1:1 under the finger and adds physics-based deceleration after lift.

Root cause, measured live (CDP touch emulation, 390x844@DPR3, real claude/tmux session)

gesture (delivered finger travel) BEFORE lines scrolled content px (15px rows) native scroller, same gesture
flick 300px (~185ms) 12 180px 428px (286 drag + 142 fling)
medium drag 200px 8 120px 190px
slow drag 400px 16 240px 390px
  • Rate was velocity-independent: a fast flick and a slow drag both got 1 line/25px; wheels stopped at finger lift in every sample.
  • A full-screen flick moved ~1/4 of the 46-row screen; muscle memory expects a flick to keep gliding.
  • The chat/conversation view was checked separately and is pure native touch scroll (passive listeners only) — the terminal bridge is the only slow surface.

Fix (9 commits)

Tuning of the existing controller — the DOM-free createTouchScrollController stays the unit-tested seam:

  1. 1:1 tracking — fixed 24px step replaced by the measured rendered row height (.xterm-screen height / terminal.rows, sampled per gesture, clamped [8,48]px, fallback 16px).
  2. Momentum tail — exit velocity from a 120ms ring buffer of e.timeStamp-stamped positions (robust to touchmove coalescing/drops), started only when ≥0.3px/ms and the newest sample is <100ms old at release (drag→hold-still→lift does NOT fling), estimated from the final consistent-direction sample run (a reversal never flings the wrong way), clamped to 3.5px/ms, decayed v *= exp(-dt/325ms) per rAF tick with real elapsed time, drained through the same wheel-step logic (≤6 wheels/tick, ≤150/tail, stop <0.05px/ms), and canceled outright when a frame gap exceeds 250ms (backgrounded tab never resumes a stale tail).
  3. Instant cancel — new touchstart, touchcancel (dedicated onTouchCancel), suppression (D-pad/select, per tick), mouse-tracking off (per tick), element detach/dispose (adapter el.isConnected gate ahead of every terminal.modes read), or disposer — all kill the tail with zero leftover velocity/carry.
  4. Catch-tap is inert — tapping to catch a live fling no longer synthesizes a click (no xterm focus, no scroll-to-bottom jump, no keyboard pop): the controller reports caughtFling and the adapter prevents default on that touchend; a transient flingCatch flag in terminalMobileState makes the gestures layer skip tap counting, so a double catch-tap can never inject Tab into the PTY.

All pre-existing invariants preserved: mouse-tracking gate (no double-scroll), axis lock, pinch abandonment, D-pad/select suppression, .xterm-screen coordinate clamping, drag-phase preventDefault semantics.

After, measured live (same rig, device, gestures, workspace)

gesture BEFORE AFTER breakdown
flick 300px 12 lines 37 lines (3.1x) 20 drag (exact 1:1) + 17 momentum over 910ms
medium drag 200px 8 lines 13 lines (exact 1:1) no tail at ~0.4px/ms — matches native feel (native fling was 19px there)
re-grab mid-momentum n/a 0 wheels during a 600ms hold after touchdown tail demonstrably running, canceled within one 30ms grace tick
long-press D-pad (#35) works works overlay active during stationary 500ms press (0 wheels), gone on release, next flick scrolls

Full flick content: 555px vs the native rig baseline's 428px — in the native-feel zone (the rig under-estimates real-device fling: sparse synthetic events weaken Chrome's velocity estimator).

Live lifecycle proofs (same rig):

  • Mid-momentum detach/re-attach: with 9 tail wheels live in the preceding 150ms, removing the terminal element produced 0 wheels after remove+50ms and 0 spontaneous wheels after re-attach; a fresh flick afterwards scrolls (element-level wheel listener, which sees dispatches even on a detached node).
  • Double-catch-tap wire silence: WebSocket sends instrumented and base64-decoded — 401 SGR mouse-report inputs as positive control, 0 Tab (0x09) bytes across the whole session including a genuine double catch-tap on a live tail; only other inputs were xterm's automatic identity/color handshake replies.

Rig notes for reproducibility: Playwright page.screenshot resets CDP emulation overrides (use Page.captureScreenshot); Input.synthesizeScrollGesture never delivers touchmoves (compositor-level); on a busy repainting terminal Chrome drops touchmoves via input ack-timeout, so all rates are computed on delivered finger travel.

Tests

  • terminalTouchScroll.test.ts: 1:1 stepping, step fallback/clamping, slow-release ⇒ no momentum, hold-still-then-lift ⇒ no momentum (100ms release-age gate), flick ⇒ decaying terminating tail, re-grab cancels instantly + requires a full row of fresh travel, mid-tail halt on suppression / mouse-tracking-off, release-time gates as the deciding factor (suppression or tracking flip between last move and lift ⇒ nothing even scheduled), irregular-frame envelope (dt=48 within ±25% of dt=16), frame-gap boundary (249ms continues, 251ms cancels), velocity/frame/total caps, touchcancel reset, coalesced-single-move velocity, direction-reversal never flings the wrong way, catch-tap reporting, plus all pre-existing behaviors.
  • terminalTouchGestures.test.ts: catch-tap sequences don't count toward double-tap Tab.
  • Full touch suite run locally (CI does not run frontend tests — tsconfig excludes *.test.ts): npx vitest run on the three touch suites → 3 files, 114 tests, all passing.
  • pnpm run web-core:check, pnpm run local-web:check, pnpm run local-web:lint all green locally.

Review gauntlet

  • Council round 1 (mixed engines): Codex ux-advocate (BLOCK → all three findings fixed: dispose gate, frame-gap cancel, reversal velocity), Claude ux-advocate cross-check (CAUTION → catch-tap P1 fixed), Claude tech-lead fallback seat (CAUTION → release-gate + irregular-frame test holes closed; its phantom-momentum P3 was already fixed by the batch; drag-drain-burst P3 consciously accepted — capping would discard real finger travel; noted as future hardening). Engine note: council.sh seat extraction failed (recorded deviation) — the Codex seat's report was recovered verbatim from its raw transcript; the Kimi seat produced nothing and was re-run as the Claude fallback.
  • /simplify (4 angles): drain() consolidation, lastY removal, onTouchCancel simplification, e.timeStamp reuse — all applied; generation counter kept deliberately (guards batched schedulers).
  • /code-review xhigh (5 parallel reviewers) + lane-owned read-only Codex sweep (CodeRabbit CLI unusable non-interactively on this fork — recorded deviation): 4 reviewers no-issues; 3 real introduced bugs found and fixed in the final two commits — (a) a second finger landing mid-catch clobbered the flingCatch state (now only reassessed on a true sequence start), (b) a partial touchcancel re-armed the surviving finger against stale coordinates and could burst wheels (now mirrors touchend's remaining-count re-arm rule), (c) in the 8-12px band a fast micro-flick was simultaneously a momentum scroll AND a gesture-layer tap, and a catch left tap history armed (flick→catch→tap could send Tab) — fixed via a sequence-scoped scrollOwned state flag that disqualifies taps once the bridge commits vertical ownership, plus tap-history clearing on catches; no thresholds touched, D-pad handoff unchanged. Suite now 118 tests. Catch-tap behavior re-verified LIVE after these changes: 0 wheels after catch (30ms grace), 64 SGR reports / 0 Tab bytes on the decoded wire, follow-up flick 31 wheels with momentum.
  • Known pre-existing issue, deferred (out of lane scope): the gestures adapter counts touches via e.targetTouches, which per the Touch Events spec only contains contacts that began on the exact event target — fingers landing on different xterm row descendants can be miscounted, affecting D-pad/3-finger classification. Predates this PR (sweep finding P2); recommend a follow-up lane.

DRAFT — boss flips to ready.

@miguelrisero

Copy link
Copy Markdown
Owner Author

Code review

Found 3 issues (all fixed in-place on this branch by 572cb6a and a6d1bb3):

  1. A second finger landing mid-catch clobbered the fling-catch state — sequenceBeganAsCatch was recomputed on every touchstart, so a stray palm/finger before lift silently disabled the catch-tap protections for the rest of the sequence (reproduced against the pure controller). Fixed: only reassessed on a genuine sequence start.

onTouchStart(p: TouchPoint): TouchStartResult {
if (p.touches === 1) {
sequenceBeganAsCatch = momentumFrameId !== undefined;
}
verticalTravelOccurred = false;

  1. A partial touchcancel (spec allows canceling a subset of contacts) reset the controller to undecided, letting the surviving finger acquire vertical ownership from stale coordinates and burst wheels (reproduced: 6 wheels from a 100px stale delta). Fixed: onTouchCancel now mirrors touchend's remaining-count re-arm rule.

onTouchCancel(remainingTouches = 0): void {
cancelMomentum();
resetGesture(remainingTouches === 0 ? 'undecided' : 'ignore');
},

  1. In the 8-12px band (scroll axis-lock at 8px, gesture tap disqualification ~12px) a fast micro-flick was simultaneously a momentum scroll and a gesture-layer tap, and a fling catch returned without clearing tap history — so flick → catch → tap could dispatch Tab into the PTY. Fixed: the bridge publishes sequence-scoped scrollOwned state the moment it commits vertical ownership, the tap classifier disqualifies scroll-owned sequences, and catches clear tap history; no thresholds changed. Re-verified live at the decoded WebSocket wire: 0 Tab bytes, positive-controlled by 64 SGR mouse reports.

lastTap = null;
return;
}
if (flingCatch || scrollOwned) {
lastTap = null;
return;
}

Also noted (pre-existing, deferred with a note in the PR body): the gestures adapter counts touches via e.targetTouches, which only contains contacts that began on the exact event target — descendant-target fingers can be miscounted. Predates this PR.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@miguelrisero
miguelrisero marked this pull request as ready for review July 21, 2026 13:47
@miguelrisero

Copy link
Copy Markdown
Owner Author

Reviewer handoff + merge record (chief run bc-lanes-2026-07-19)

What it does: mobile terminal scrolling now feels native. Drags track your finger 1:1 (step derived from actual row height instead of a fixed 24px), and a flick glides with iOS-style momentum (measured: 12 → 37 lines on the same flick, 3.1x) that cancels instantly when you touch down, long-press for the D-pad, switch panes, or the tab stalls.

Safety properties, live-proven at the decoded wire: a momentum tail dies the moment its pane detaches — zero scroll events leak into a re-attached pane; catching a fling with a tap stays a catch — no focus jump, no keyboard pop, and zero Tab bytes reach the PTY (verified with a positive control proving the measurement itself worked).

Scope: 6 files, all mobile-touch frontend; no backend, no migrations. D-pad arrows and scroll-vs-gesture disambiguation from #35 re-verified live.

Review history: three found-and-fixed issues linked in the review comment; one pre-existing adapter quirk (targetTouches miscounting) deferred as an out-of-scope follow-up.

How to test: on your phone, flick-scroll a busy terminal — it should glide and coast like a normal page; grab it mid-glide — it stops instantly; long-press still gives arrows.

Boss verification passed (clean, CI green, 6 in-lane files, identity/trailers clean) — merging under the standing merge-when-ready authorization.

@miguelrisero
miguelrisero merged commit ce97ca6 into main Jul 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant