fix: route mobile terminal swipes to the terminal, not the page layout

- Add touch-action: none and overscroll-behavior: none to mobile terminal
  page, content, wrapper, container, and xterm viewport so the browser
  never treats swipes as page/address-bar scrolling or pull-to-refresh.
- Make .terminal-page.mobile fixed-position to prevent viewport layout
  scroll; keep the overlay toolbar as absolute with pointer-events only
  on interactive parts.
- Rework the mobile touch handler in terminal.tsx:
  * Detect normal vs alternate buffer via term.buffer.active.type instead
    of measuring the DOM viewport, which was unreliable in tmux/vim.
  * Accumulate swipe distance and emit SGR 1006 mouse-wheel sequences in
    steps, so tmux pane scrolling tracks the gesture correctly.
  * Prevent default as soon as the swipe is recognized so the page does
    not start a competing scroll gesture.

Quality gates: npm run typecheck, npm run lint clean, npm test -- --run 87 passed.
This commit is contained in:
Developer
2026-06-13 20:30:52 +00:00
parent 1f2c3dbe2a
commit c9b0259993
18 changed files with 111 additions and 49 deletions
@@ -356,18 +356,48 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
// the active buffer type. We also lock the browser into the
// terminal area: touchstart prevents the browser from starting a
// page-scroll gesture, so swipes always go to the terminal.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
let startX = 0;
let isScrolling = false;
let scrollPending = 0;
const WHEEL_DISTANCE = 12;
const flushScroll = (force = false) => {
if (scrollPending === 0) return;
if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return;
const direction = Math.sign(scrollPending);
const steps = Math.max(
1,
Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE),
);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
const btn = direction > 0 ? 64 : 65;
for (let i = 0; i < steps; i++) {
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
scrollPending = 0;
};
const isNormalBuffer = () => {
return termRef.current?.buffer.active.type === "normal";
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
scrollPending = 0;
}
};
const onTouchMove = (e: TouchEvent) => {
@@ -381,40 +411,39 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}
}
if (isScrolling) {
// Always stop the browser from treating this as a page scroll.
e.preventDefault();
const normalBuffer = isNormalBuffer();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
if (normalBuffer && viewport) {
// Normal buffer: scroll the xterm viewport directly.
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
// Alternate screen (tmux/vim): accumulate the swipe and
// send SGR 1006 mouse-wheel events in steps.
scrollPending += deltaY;
flushScroll();
}
startY = touch.clientY;
} else if (Math.abs(deltaY) > 4 || Math.abs(deltaX) > 4) {
// Once the user has moved far enough to be considered a
// gesture, prevent any default page scroll/pinch behavior.
e.preventDefault();
}
};
const onTouchEnd = () => {
if (isScrolling) {
flushScroll(true);
}
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
passive: false,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {