fix(terminal): native-feel mobile touch scrolling — 1:1 tracking + momentum tail#40
Conversation
Code reviewFound 3 issues (all fixed in-place on this branch by 572cb6a and a6d1bb3):
bettercoding/packages/web-core/src/shared/lib/terminalTouchScroll.ts Lines 302 to 306 in a6d1bb3
bettercoding/packages/web-core/src/shared/lib/terminalTouchScroll.ts Lines 396 to 399 in a6d1bb3
bettercoding/packages/web-core/src/shared/lib/terminalTouchGestures.ts Lines 464 to 470 in a6d1bb3 Also noted (pre-existing, deferred with a note in the PR body): the gestures adapter counts touches via 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
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. |
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)
Fix (9 commits)
Tuning of the existing controller — the DOM-free
createTouchScrollControllerstays the unit-tested seam:.xterm-screenheight /terminal.rows, sampled per gesture, clamped [8,48]px, fallback 16px).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, decayedv *= 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).onTouchCancel), suppression (D-pad/select, per tick), mouse-tracking off (per tick), element detach/dispose (adapterel.isConnectedgate ahead of everyterminal.modesread), or disposer — all kill the tail with zero leftover velocity/carry.caughtFlingand the adapter prevents default on that touchend; a transientflingCatchflag interminalMobileStatemakes 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-screencoordinate clamping, drag-phase preventDefault semantics.After, measured live (same rig, device, gestures, workspace)
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):
Rig notes for reproducibility: Playwright
page.screenshotresets CDP emulation overrides (usePage.captureScreenshot);Input.synthesizeScrollGesturenever 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.*.test.ts):npx vitest runon the three touch suites → 3 files, 114 tests, all passing.pnpm run web-core:check,pnpm run local-web:check,pnpm run local-web:lintall green locally.Review gauntlet
flingCatchstate (now only reassessed on a true sequence start), (b) a partialtouchcancelre-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-scopedscrollOwnedstate 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.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.