Skip to content

fix(workspaces): mobile touch scroll + copy/paste in the terminal console#22

Merged
miguelrisero merged 3 commits into
mainfrom
mr/eab9-fix-mobile-scroll-paste
Jun 27, 2026
Merged

fix(workspaces): mobile touch scroll + copy/paste in the terminal console#22
miguelrisero merged 3 commits into
mainfrom
mr/eab9-fix-mobile-scroll-paste

Conversation

@miguelrisero

Copy link
Copy Markdown
Owner

Summary

The terminal console (xterm.js running CLI/tmux claude) was unusable on touch devices: swiping didn't scroll and copy/paste didn't work. Desktop mouse/keyboard UX is great and stays byte-for-byte unchanged.

Root cause (verified vs installed xterm 5.5 source): xterm binds its own touchstart/touchmove scroll handlers to terminal.element, but they early-return when the app enables mouse tracking (coreMouseService.areMouseEventsActive) — exactly what tmux/claude do. So touch scroll is dead in the CLI pane while the desktop mouse-wheel path still works. Copy/paste were mouse-only (drag-select onSelectionChange, right-click contextmenu).

What changed

  • Touch→wheel scroll bridgeshared/lib/terminalTouchScroll.ts. A single-finger vertical swipe is translated into the same WheelEvents xterm already handles for a desktop mouse wheel: forwarded to claude/tmux under mouse tracking, native scrollback otherwise. Uses deltaMode:1 (DOM_DELTA_LINE) + coords clamped into .xterm-screen to sidestep xterm's private pixel→row accumulator and null-coord drop. The gesture logic is a pure, unit-tested controller; a thin adapter wires real touch events. Only acts when mouse tracking is on (so xterm's native touch scroll still drives shell side terminals — no double-scroll).
  • Touch-only Copy / Paste / Keyboard controlsshared/components/TerminalMobileControls.tsx, gated on pointer: coarse / maxTouchPoints (covers iPadOS-desktop-UA + touch laptops), collapsible, pinned top-right so it can't cover claude's bottom input, 44px targets + aria-labels. Explicit feedback (Pasted / Clipboard empty / Paste blocked; Copied selection vs Copied screen) since mobile clipboard calls fail silently. Copy uses the selection or the visible screen, respecting wrapped lines (IBufferLine.isWrapped).
  • WiringXTermInstance.tsx: install the bridge once in the terminal-creation branch (lives with the element, torn down on dispose() like the existing contextmenu handler — never removed per-unmount, so reattached terminals keep scrolling); render the controls as a sibling of (not inside) the xterm element; overscroll-contain on the wrapper.

Files

File What
shared/lib/terminalTouchScroll.ts (+.test.ts) Pure gesture controller + DOM adapter (touch→wheel)
shared/lib/terminalViewportText.ts (+.test.ts) Visible-buffer copy respecting wrapped lines
shared/hooks/useIsTouchDevice.ts Capability-based touch gate
shared/components/TerminalMobileControls.tsx Copy/Paste/Keyboard cluster (touch-only)
shared/components/XTermInstance.tsx Wire bridge + controls

Why desktop can't regress

Touch events never fire for mouse users; the controls render null off-touch; and no wheel / onSelectionChange / contextmenu / OSC 52 path is modified. The relative/overscroll-contain wrapper classes have no mouse-path effect.

Test plan

  • pnpm run check (web-core/local-web/remote-web/ui tsc) — green. ESLint (local-web, ui) — green. Prettier — green.
  • 18 new unit tests (npx vitest run in packages/web-core): gesture axis-lock, step quantization, scroll direction, mouse-mode gate, pinch/horizontal guards; wrapped-line + trailing-blank copy. (2 unrelated web-core test files fail under bare npx vitest on @/ alias resolution — pre-existing baseline, not run in CI.)
  • End-to-end contract proof (real TouchEvents vs real xterm 5.5, Playwright): mouse-mode active swipe → \x1b[<65;…M wheel reports forwarded to the app + preventDefault; mouse-mode off swipe → 0 app dispatch, no preventDefault (defers to native). Scrollback + mouse-mode forwarding both validated with deltaMode:1.
  • Manual matrix (recommended, can't run iOS in CI): Android Chrome + iOS Safari + emulated-mobile; CLI (mouse-active) and side-shell (mouse-inactive). Note: iOS Safari may restrict clipboard.readText() — Paste degrades with a "Paste blocked" hint, never stray input.

Reviewed before opening

Pre-impl design reviewed by a mixed-engine council (systems-architect, maintainability-architect, product-engineer, qa-strategist; CAUTION ×4, no BLOCK). Applied: bridge lifecycle = creation-branch-only; dropped a PTY-corrupting WS-sequence fallback and the fragile long-press synthetic-mouse selection; deltaMode:1 + clamped coords; capability gate; wrapped-line copy; toolbar UX + clipboard feedback; pure testable seam.

🤖 Generated with Claude Code

…sole

The console (xterm.js, CLI/tmux `claude`) was unusable on touch devices.
xterm's own touch-scroll handlers bail out when the application enables mouse
tracking — exactly what tmux/`claude` do — so swipes did nothing, while copy and
paste were mouse-only (drag-select + right-click). Desktop was unaffected and
must stay byte-for-byte unchanged.

- Touch→wheel bridge (terminalTouchScroll.ts): translate a single-finger vertical
  swipe into the SAME WheelEvents xterm already handles for a desktop mouse wheel —
  forwarded to claude/tmux under mouse tracking, native scrollback otherwise.
  deltaMode:1 + screen-clamped coords sidestep xterm's private scroll internals;
  the gesture logic is a pure, unit-tested controller. Bridge only acts when mouse
  tracking is on, so xterm's native touch scroll still drives shell side terminals.
- Touch-only Copy / Paste / Keyboard controls (TerminalMobileControls.tsx), gated
  on pointer-coarse / maxTouchPoints, collapsible, with explicit action feedback.
  Copy uses the selection or the visible screen (respecting wrapped lines).
- Desktop untouched: touch events never fire for mouse users, the controls render
  null off-touch, and no wheel/selection/contextmenu/OSC52 path changes.
Apply post-implementation review findings (codex + CodeRabbit + 4 cleanup passes):

- Clipboard guards: explicitly check navigator.clipboard.readText/writeText before
  use. Optional chaining short-circuits to undefined on insecure contexts/WebViews,
  which made Copy flash "Copied" without copying and Paste say "empty" when blocked.
  Now reports "unavailable" distinctly.
- Multi-touch: keep a gesture ignored until ALL fingers lift. A partial pinch
  release no longer lets the remaining finger resume bridged scrolling from stale
  state. Added a regression test.
- Hot path: resolve .xterm-screen once at install and memoize the clamped point
  per touchmove instead of querySelector + getBoundingClientRect per dispatched
  wheel.
- Cohesion: move isTouchDevice/useIsTouchDevice into useIsMobile.ts beside the
  viewport/UA detectors; drive the action buttons from a small array; hoist the
  button class to module scope; correct a comment that referenced an uncommitted
  contract test.
@miguelrisero miguelrisero marked this pull request as ready for review June 27, 2026 09:24
Read touch capability via useSyncExternalStore with a `false` server snapshot
(matching useIsMobile) instead of useState, so a server-rendered pass hydrates
deterministically and then reflects the real capability after mount.
@miguelrisero

Copy link
Copy Markdown
Owner Author

Ship-it summary — verification

Status: CI green, ready-for-review, mergeable. All review findings resolved in code.

Reviews run (pre + post implementation)

  • Pre-impl council (mixed: systems-architect, maintainability-architect, product-engineer, qa-strategist) — CAUTION ×4, no BLOCK. Shaped the design: creation-branch-only listener lifecycle, dropped a PTY-corrupting WS fallback and fragile long-press synthetic-mouse selection, deltaMode:1 + screen-clamped coords, capability gate, wrapped-line copy, clipboard feedback states.
  • codex (xhigh) + CodeRabbit CLI — both independently found the same 2 majors: clipboard optional-chaining false-success, and multi-touch partial-lift resuming scroll. Both fixed (+ regression test).
  • /simplify (4 agents) — applied: cache .xterm-screen + memoize clamp on the touch hot path; array-driven buttons; module-scope class; consolidated the touch hook into useIsMobile.ts. Skipped with reasons: IconButton reuse (twMerge disabled → class collision), clipboard-bridge helper (no readText/guard).
  • CodeRabbit CLI sweep — surfaced 1 more major (SSR/hydration of the touch hook); fixed via CodeRabbit's own suggested useSyncExternalStore form.
  • codex final passNO REMAINING ISSUES.

Verification

  • 19 unit tests (gesture math, axis-lock, mode-gate, multi-touch, wrapped-line copy); tsc + ESLint + Prettier clean across web-core/local-web/remote-web/ui.
  • Browser contract test (real TouchEvents vs real xterm 5.5): mouse-active swipe → wheel reports forwarded to the app + preventDefault; mouse-inactive swipe → 0 dispatch, defers to native scroll. Desktop mouse/keyboard paths unchanged.

Note: the CodeRabbit GitHub App isn't installed on this fork, so the CodeRabbit CLI was used as the authoritative review. A final confirming CLI sweep is temporarily rate-limited (free tier); all findings it raised are already fixed in code, and the independent codex pass is clean.

@miguelrisero miguelrisero merged commit ee0a969 into main Jun 27, 2026
7 checks passed
@miguelrisero miguelrisero deleted the mr/eab9-fix-mobile-scroll-paste branch June 27, 2026 19:23
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