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
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components
## role
Provides reusable, accessible UI primitives and layout components for the web application, including navigation, feedback states, icons, code display, and authentication guards.
Provides reusable, accessible UI components for the web application including layout shell, data states, icons, code display, routing guards, and toast notifications.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web/src/components
index: apps/web/src/components/.pi-map.index.md
## role
Provides reusable, accessible UI primitives and layout components for the web application, including navigation, feedback states, icons, code display, and authentication guards.
Provides reusable, accessible UI components for the web application including layout shell, data states, icons, code display, routing guards, and toast notifications.
## files
- app-shell.tsx | Renders the main application shell layout with navigation, header, session management, and mobile-responsive behavior for a React Router-based SPA. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
@@ -17,7 +17,7 @@ Provides reusable, accessible UI primitives and layout components for the web ap
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type
## arch
Component-based React architecture with functional components, composition patterns, separation of concerns (presentation vs logic), utility-first modules, and test co-location; integrates third-party libraries (Phosphor icons, react-simple-code-editor) with thin abstraction wrappers.
Component-based React architecture with functional components, custom hooks for state management, centralized icon mapping, route guards with auth integration, and utility modules for cross-cutting concerns like notifications and data state handling.
## tags
toast, icon, react, state, code, loading, event, editor
## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features
## role
Contains reusable React components that implement specific product features and business logic for the web application.
Houses reusable React components that implement specific product features and business logic for the web application.
## parent
index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md
## role
Contains reusable React components that implement specific product features and business logic for the web application.
Houses reusable React components that implement specific product features and business logic for the web application.
## files
## arch
Feature-based component organization following domain-driven design principles, likely with co-located components, hooks, and utilities per feature area.
Feature-based component organization with co-located UI pieces, likely composed of smaller design-system elements and connected to domain hooks/stores for stateful functionality.
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/terminal
## role
Provides cross-platform terminal UI components for desktop and mobile with session management, special key input, and xterm.js WebSocket connectivity.
Provides cross-platform terminal UI components with session management, special key input, and WebSocket-backed xterm.js integration for web-based terminal emulation.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
@@ -4,7 +4,7 @@ dir: apps/web/src/components/features/terminal
index: apps/web/src/components/features/terminal/.pi-map.index.md
## role
Provides cross-platform terminal UI components for desktop and mobile with session management, special key input, and xterm.js WebSocket connectivity.
Provides cross-platform terminal UI components with session management, special key input, and WebSocket-backed xterm.js integration for web-based terminal emulation.
## files
- DesktopTerminalView.tsx | Renders a desktop-optimized terminal view with session tabs, fullscreen mode, font controls, and reset confirmation dialog | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, TerminalSession type
- MobileTerminalView.tsx | Renders a mobile-optimized terminal interface with toolbar, session tabs, terminal output, and special keys input controls. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal
@@ -12,9 +12,9 @@ Provides cross-platform terminal UI components for desktop and mobile with sessi
- special-keys-strip.tsx | Renders a strip of buttons for sending special keyboard keys (Esc, Tab, arrows, etc.) with optional modifier support for a terminal interface. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook
- terminal-session-tabs.test.tsx | Tests a React component that renders interactive terminal session tabs with selection, close confirmation, renaming, creation limits, and status indicators. | dep: @testing-library/react, vitest, ./terminal-session-tabs
- terminal-session-tabs.tsx | Renders a tabbed interface for managing multiple terminal sessions with selection, creation, renaming, and close confirmation features. | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React
- terminal.tsx | React component that renders an interactive xterm.js terminal with WebSocket connectivity, mobile touch support, flow control, heartbeat monitoring, and reconnection logic. | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys, React, use-special-keys hook
- terminal.tsx | A React component that renders an interactive xterm.js terminal connected to a WebSocket backend, with mobile touch support, flow control, heartbeat monitoring, and automatic reconnection. | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys
## arch
Component-based architecture with platform-specific view variants (Desktop/Mobile), shared session tabs state management, modular special key input panels, and a core terminal component handling WebSocket lifecycle with heartbeat/reconnection logic.
Feature-based component architecture with platform-specific view splitting (desktop/mobile), compound component pattern for terminal sessions, and adapter pattern for special key input variants (panel/strip).
## tags
terminal, special, keys, session, react, tabs, view, renders
## symbols
@@ -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, {