fix(workspaces): mobile touch scroll + copy/paste in the terminal console#22
Merged
Conversation
…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.
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.
Owner
Author
Ship-it summary — verificationStatus: CI green, ready-for-review, mergeable. All review findings resolved in code. Reviews run (pre + post implementation)
Verification
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/touchmovescroll handlers toterminal.element, but they early-return when the app enables mouse tracking (coreMouseService.areMouseEventsActive) — exactly what tmux/claudedo. So touch scroll is dead in the CLI pane while the desktop mouse-wheel path still works. Copy/paste were mouse-only (drag-selectonSelectionChange, right-clickcontextmenu).What changed
shared/lib/terminalTouchScroll.ts. A single-finger vertical swipe is translated into the sameWheelEvents xterm already handles for a desktop mouse wheel: forwarded to claude/tmux under mouse tracking, native scrollback otherwise. UsesdeltaMode:1(DOM_DELTA_LINE) + coords clamped into.xterm-screento 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).shared/components/TerminalMobileControls.tsx, gated onpointer: 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).XTermInstance.tsx: install the bridge once in the terminal-creation branch (lives with the element, torn down ondispose()like the existingcontextmenuhandler — never removed per-unmount, so reattached terminals keep scrolling); render the controls as a sibling of (not inside) the xterm element;overscroll-containon the wrapper.Files
shared/lib/terminalTouchScroll.ts(+.test.ts)shared/lib/terminalViewportText.ts(+.test.ts)shared/hooks/useIsTouchDevice.tsshared/components/TerminalMobileControls.tsxshared/components/XTermInstance.tsxWhy desktop can't regress
Touch events never fire for mouse users; the controls render
nulloff-touch; and no wheel /onSelectionChange/contextmenu/ OSC 52 path is modified. Therelative/overscroll-containwrapper 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.npx vitest runinpackages/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 barenpx viteston@/alias resolution — pre-existing baseline, not run in CI.)TouchEvents vs real xterm 5.5, Playwright): mouse-mode active swipe →\x1b[<65;…Mwheel reports forwarded to the app +preventDefault; mouse-mode off swipe → 0 app dispatch, nopreventDefault(defers to native). Scrollback + mouse-mode forwarding both validated withdeltaMode:1.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