diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..f2925ae7 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -51,6 +51,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/run-mode-menu-scroll.browser.test.ts', 'test/markdown-anchor-links.browser.test.ts', 'test/fullscreen-tab-switch-capture.browser.test.ts', + 'test/terminal-touch-swipe-axis.browser.test.ts', ]; /** diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index dc0355f8..db83fef5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -932,7 +932,9 @@ Further detail: the design follows Apple's [Designing for iPhone Duo](https://de ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). -Tests: `test/terminal-touch-tap.test.ts`. +⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. ⚠️ Only up to the swipe's own drift limit (`SwipeHandler.maxVerticalDrift`): past it the swipe has given up, so the drag is handed back to the scroll (`y`). Otherwise a scroll whose first few pixels lean sideways (a thumb arc) locked as `swipe` and did nothing at all. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. + +Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/terminal-touch-swipe-axis.browser.test.ts` (the real touch listeners: the drift handback and a clean swipe, browser suite), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). ### Entrance animations diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index b21b896e..bc986b5e 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -37,7 +37,8 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s the hinge, and opening or closing the device is treated as the device changing shape, never as the keyboard appearing. -**Swipe left and right** on the terminal to switch sessions. +**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses +the swipe, and a terminal wider than the screen pans sideways instead of switching. ## The home screen diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 27a89d38..f2cca22b 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -14,7 +14,7 @@ **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. ⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF): a header let go outside the window pops the tile out (`detachSession` with a placement), dropped on ANOTHER Codeman window's tiles it moves there, and a pop-out's title drags back onto any grid. A foreign tile docks ONLY through `_dockForeignTile` (the target's own drop, then `tile-adopted` on the window channel), and only the window it was dragged FROM lets go of its copy (never a last-tile fallback onto that session: two windows sizing one PTY garble it). The gesture overlay moves tiles only through tile-grid.js's helpers (`tileDropTargetAt`/`dropOnTileTarget`/`detachTileAtPoint`). → [architecture-invariants#tile-grid](../../docs/architecture-invariants.md#tile-grid) -**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) +**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left until it passes the swipe's own vertical drift limit (`SwipeHandler.maxVerticalDrift`), where it is handed back to the scroll as `y` (a scroll that opens on a thumb arc), and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) **Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index fb22923a..3c3ba549 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -9,7 +9,7 @@ * toolbar/accessory bar repositioning, terminal resize on keyboard open/close, * and input scroll-into-view. Uses 100px threshold for iOS address bar drift. * - SwipeHandler — Horizontal swipe detection on the terminal area for session switching. - * 80px minimum distance, 300ms maximum time, 100px max vertical drift. + * 80px minimum distance, 500ms maximum time, 50px max vertical drift. * * All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE * reconnect (in handleInit) to prevent stale closures. @@ -758,14 +758,24 @@ const SwipeHandler = { startY: 0, startTime: 0, minSwipeDistance: 80, // Minimum pixels for a valid swipe - maxSwipeTime: 300, // Maximum ms for a swipe gesture - maxVerticalDrift: 100, // Max vertical movement allowed + // 300ms rejected an unhurried but deliberate swipe outright, which read as an + // unresponsive gesture rather than as "too slow". + maxSwipeTime: 500, // Maximum ms for a swipe gesture + maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture _touchStartHandler: null, + _touchMoveHandler: null, _touchEndHandler: null, _element: null, _ignoreGesture: false, + /** + * Surfaces inside `.main` that own their own gestures or controls: a swipe + * there must not switch sessions. The phone overview and welcome screen are + * deliberately NOT listed: swiping from home into a session is a feature. + */ + IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar', + /** Initialize swipe handling */ init() { // Only on touch devices @@ -776,8 +786,10 @@ const SwipeHandler = { this._element = terminal; this._touchStartHandler = (e) => this.onTouchStart(e); + this._touchMoveHandler = (e) => this.onTouchMove(e); this._touchEndHandler = (e) => this.onTouchEnd(e); terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true }); + terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true }); terminal.addEventListener('touchend', this._touchEndHandler, { passive: true }); }, @@ -785,9 +797,11 @@ const SwipeHandler = { cleanup() { if (this._element && this._touchStartHandler) { this._element.removeEventListener('touchstart', this._touchStartHandler); + this._element.removeEventListener('touchmove', this._touchMoveHandler); this._element.removeEventListener('touchend', this._touchEndHandler); } this._touchStartHandler = null; + this._touchMoveHandler = null; this._touchEndHandler = null; this._element = null; }, @@ -797,20 +811,43 @@ const SwipeHandler = { // here. Swiping across the open session drawer — the natural "dismiss it" // gesture — would otherwise fire nextSession() and drop the user into a // session they never tapped. + // The terminal's touch handler publishes the axis it locks for a gesture + // that starts on it; a touch that starts elsewhere in `.main` (the home + // screen) must not inherit the last one, or a terminal scroll a moment + // earlier vetoes a swipe from home. Reset before any early return. + if (typeof app !== 'undefined') app._terminalGestureAxis = null; this._ignoreGesture = !!e.target?.closest?.('.session-sidebar'); if (this._ignoreGesture) return; - if (!e.touches || e.touches.length !== 1) return; + // A second finger makes this a different gesture (pinch, two-finger scroll): + // never measure a later lift against the first finger's start. Stays set + // until the next touchstart, so the first finger's lift cannot clear it. + if (!e.touches || e.touches.length !== 1) { + this._ignoreGesture = true; + return; + } + this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR); this.startX = e.touches[0].clientX; this.startY = e.touches[0].clientY; this.startTime = Date.now(); }, + onTouchMove(e) { + if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return; + if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true; + }, + onTouchEnd(e) { - if (this._ignoreGesture) { - this._ignoreGesture = false; - return; - } + if (this._ignoreGesture) return; if (!e.changedTouches || e.changedTouches.length !== 1) return; + if (typeof app === 'undefined') return; + // The terminal's own touch handler (terminal-ui.js) runs first, on the same + // gesture: a drag it locked as a scroll or a pan is not a session swipe, + // and neither is a lift it consumed (its long-press selection gesture + // preventDefaults the touchend, even on blank space where no selection + // survives the lift). A swipe must not extend or drop a touch selection. + if (e.defaultPrevented) return; + if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return; + if (app._touchSelecting || app._touchSelectionActive) return; const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; @@ -824,13 +861,8 @@ const SwipeHandler = { if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short - // Valid swipe detected - if (deltaX > 0) { - // Swipe right -> previous session - if (typeof app !== 'undefined') app.prevSession(); - } else { - // Swipe left -> next session - if (typeof app !== 'undefined') app.nextSession(); - } + // Valid swipe detected. Swipe right -> previous session, left -> next. + if (deltaX > 0) app.prevSession(); + else app.nextSession(); }, }; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index afc03555..7643ba1e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1200,8 +1200,13 @@ Object.assign(CodemanApp.prototype, { let longPressStartY = 0; let touchStartY = 0; let touchStartX = 0; - // 'x' | 'y' | null — locked on the first travel past the tap slop, so a - // diagonal drag cannot pan and scroll at the same time. + // 'x' | 'y' | 'swipe' | null — locked on the first travel past the tap + // slop, so a diagonal drag cannot pan and scroll at the same time. 'swipe' + // is a sideways drag on a terminal that cannot pan: it is the session + // swipe's (SwipeHandler, mobile-handlers.js), so it neither scrolls nor + // ends as a tap on the session being left. Published per gesture as + // `this._terminalGestureAxis`, which SwipeHandler reads to stand aside + // for a scroll or a pan. let panAxis = null; /** * Can this gesture pan sideways? Only while the terminal is wider than @@ -1246,6 +1251,7 @@ Object.assign(CodemanApp.prototype, { touchStartY = touchLastY; touchStartX = touchLastX; panAxis = null; + this._terminalGestureAxis = null; velocity = 0; pixelAccum = 0; isTouching = true; @@ -1319,10 +1325,10 @@ Object.assign(CodemanApp.prototype, { if (!didScroll) { const travelY = Math.abs(touchY - touchStartY); const travelX = Math.abs(touchX - touchStartX); - const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD; - if (travelY >= TAP_THRESHOLD || sideways) { + if (travelY >= TAP_THRESHOLD || travelX >= TAP_THRESHOLD) { didScroll = true; - panAxis = sideways && travelX > travelY ? 'x' : 'y'; + panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y'; + this._terminalGestureAxis = panAxis; } } // Below the tap threshold, treat the gesture as a potential tap: @@ -1333,6 +1339,17 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); + if (panAxis === 'swipe') { + // Past the swipe's own vertical drift limit the session swipe has + // given up, so the drag is a scroll after all: hand it to 'y'. + const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50; + if (Math.abs(touchY - touchStartY) <= drift) return; + panAxis = 'y'; + this._terminalGestureAxis = 'y'; + touchLastX = touchX; + touchLastY = touchY; + return; + } if (panAxis === 'x') { // Pan the container, and touch nothing the vertical path owns — // no pixelAccum, no velocity, so touchend cannot turn a sideways diff --git a/test/mobile/README.md b/test/mobile/README.md index 1ff30a73..e910fdaf 100644 --- a/test/mobile/README.md +++ b/test/mobile/README.md @@ -130,7 +130,7 @@ The unified `showKeyboard(page, height, options?)` tries Layer 1 → 2 → 3 aut | **CDP `Input.dispatchTouchEvent`** | Chromium | `true` | Default for swipe/tap tests | | **Synthetic `TouchEvent`** | Cross-engine | `false` | WebKit fallback, still triggers handlers | -Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤300ms duration, ≤100px vertical drift). +Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤500ms duration, ≤50px vertical drift). ### Visual Regression diff --git a/test/mobile/helpers/constants.ts b/test/mobile/helpers/constants.ts index 95dc6961..e43a33e1 100644 --- a/test/mobile/helpers/constants.ts +++ b/test/mobile/helpers/constants.ts @@ -68,11 +68,11 @@ export const KEYBOARD = { CONFIRM_TIMEOUT: 2000, } as const; -// Swipe constants (match app.js SwipeHandler) +// Swipe constants (match SwipeHandler in mobile-handlers.js) export const SWIPE = { MIN_DISTANCE: 80, - MAX_TIME: 300, - MAX_VERTICAL_DRIFT: 100, + MAX_TIME: 500, + MAX_VERTICAL_DRIFT: 50, } as const; // Subagent window constants diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 977421d1..d9335f2b 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -420,11 +420,11 @@ describe('Tab Navigation', () => { expect(log).toEqual([]); }); - it('slow swipe (>300ms) does not trigger navigation', async () => { + it('slow swipe (past the time limit) does not trigger navigation', async () => { await clearSwipeLog(); await installSwipeSpy(); // Duration exceeds MAX_TIME threshold - await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: 400 }); + await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: SWIPE.MAX_TIME + 150 }); await page.waitForTimeout(200); const log = await getSwipeLog(); expect(log).toEqual([]); diff --git a/test/session-swipe-gesture.test.ts b/test/session-swipe-gesture.test.ts new file mode 100644 index 00000000..af8143a2 --- /dev/null +++ b/test/session-swipe-gesture.test.ts @@ -0,0 +1,162 @@ +// Port: none (pure logic in a vm context: no browser, no server). +// +// The session swipe (SwipeHandler, mobile-handlers.js) shares every gesture with +// the terminal's own touch handler (terminal-ui.js), which runs first. A swipe +// used to switch sessions even when the terminal had taken the same drag as a +// scroll or a pan, and the terminal took a sideways flick as a TAP on the +// session being left. The terminal now publishes the axis it locked +// (`app._terminalGestureAxis`) and the swipe stands aside for 'y' and 'x'. +// +// Lives outside test/mobile/ because that suite is Playwright-driven and not in +// the CI gate (same reasoning as test/viewport-shape-change.test.ts). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8'); +const TERMINAL_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type Touch = { clientX: number; clientY: number }; +type Swipe = { + onTouchStart(e: unknown): void; + onTouchMove(e: unknown): void; + onTouchEnd(e: unknown): void; + maxSwipeTime: number; + maxVerticalDrift: number; + startTime: number; +}; + +function load(appOverrides: Record = {}) { + const app = { + nextSession: vi.fn(), + prevSession: vi.fn(), + _terminalGestureAxis: null as string | null, + ...appOverrides, + }; + const context = vm.createContext({ + console, + app, + navigator: { userAgent: 'iPhone', maxTouchPoints: 5 }, + window: { addEventListener: () => {}, matchMedia: () => ({ matches: true }) }, + document: { querySelector: () => null }, + setTimeout: () => 0, + clearTimeout: () => {}, + }); + vm.runInContext(`${SOURCE}\nglobalThis.__swipe = SwipeHandler;`, context); + const swipe = (context as unknown as { __swipe: Swipe }).__swipe; + const target = (ignored = false) => ({ + closest: (sel: string) => (ignored && sel !== '.session-sidebar' ? {} : null), + }); + // `axis` is what the terminal's touchmove publishes mid-gesture (after the + // touchstart, which resets it); `consumed` a touchend the terminal + // preventDefault'ed. + const gesture = ( + from: Touch, + to: Touch, + opts: { ms?: number; ignored?: boolean; via?: Touch; axis?: string | null; consumed?: boolean } = {} + ) => { + swipe.onTouchStart({ target: target(opts.ignored), touches: [from] }); + if (opts.axis !== undefined) app._terminalGestureAxis = opts.axis; + if (opts.ms) swipe.startTime -= opts.ms; + if (opts.via) swipe.onTouchMove({ touches: [opts.via] }); + swipe.onTouchMove({ touches: [to] }); + swipe.onTouchEnd({ changedTouches: [to], defaultPrevented: !!opts.consumed }); + }; + return { app, swipe, gesture }; +} + +describe('session swipe', () => { + it('switches on a clean horizontal swipe', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 405 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + + gesture({ clientX: 100, clientY: 400 }, { clientX: 260, clientY: 400 }); + expect(app.prevSession).toHaveBeenCalledTimes(1); + }); + + it('accepts an unhurried swipe up to 500ms, not past it', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 450 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 600 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('cancels when the finger drifted vertically mid-gesture, even if it came back', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { via: { clientX: 220, clientY: 470 } }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('stands aside when the terminal took the drag as a scroll or a pan', () => { + for (const axis of ['y', 'x']) { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis }); + expect(app.nextSession, axis).not.toHaveBeenCalled(); + } + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis: 'swipe' }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('forgets the axis a previous gesture left behind, so a swipe from home works after a terminal scroll', () => { + // The terminal resets the axis only for a touch that starts on it; a swipe + // over the home screen starts elsewhere in `.main`. + const { app, gesture } = load({ _terminalGestureAxis: 'y' }); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('stands aside when the terminal consumed the lift (a long-press selection gesture)', () => { + // A long-press on blank space sets _touchSelecting, finds no word, and the + // terminal clears the flag synchronously in its own touchend before this + // handler runs; the preventDefault it leaves behind is the signal. + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { consumed: true }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('never switches during a touch selection or on a surface with its own controls', () => { + for (const flag of ['_touchSelecting', '_touchSelectionActive']) { + const { app, gesture } = load({ [flag]: true }); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }); + expect(app.nextSession, flag).not.toHaveBeenCalled(); + } + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ignored: true }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('ignores every lift after a second finger joined, the first finger lifting first included', () => { + // Finger A starts the gesture, B joins (a pinch), A lifts, then B lifts + // 150 px from A's start within the time limit: neither lift is a swipe. + const { app, swipe } = load(); + const a = { clientX: 300, clientY: 400 }; + const b = { clientX: 100, clientY: 400 }; + swipe.onTouchStart({ target: { closest: () => null }, touches: [a] }); + swipe.onTouchStart({ target: { closest: () => null }, touches: [a, b] }); + swipe.onTouchEnd({ changedTouches: [{ clientX: 200, clientY: 400 }] }); + swipe.onTouchEnd({ changedTouches: [{ clientX: 150, clientY: 400 }] }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); +}); + +describe('terminal touch handler hands sideways drags to the swipe', () => { + it('locks a dominant sideways drag as a swipe unless the terminal can pan', () => { + expect(TERMINAL_SOURCE).toContain("panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';"); + expect(TERMINAL_SOURCE).toContain('this._terminalGestureAxis = panAxis;'); + // A 'swipe' is neither a scroll nor (didScroll is set) a tap, until the + // finger passes the swipe's own vertical drift limit: the swipe has given + // up by then, so the drag is handed back to the scroll. + expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') {"); + expect(TERMINAL_SOURCE).toContain( + "const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50;" + ); + expect(TERMINAL_SOURCE).toContain('if (Math.abs(touchY - touchStartY) <= drift) return;'); + expect(TERMINAL_SOURCE).toContain("this._terminalGestureAxis = 'y';"); + // The terminal reads the swipe's limit by name; its fallback is the same value. + expect(load().swipe.maxVerticalDrift).toBe(50); + }); +}); diff --git a/test/terminal-touch-swipe-axis.browser.test.ts b/test/terminal-touch-swipe-axis.browser.test.ts new file mode 100644 index 00000000..0a9fa5b4 --- /dev/null +++ b/test/terminal-touch-swipe-axis.browser.test.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview Real Chromium coverage for the axis the primary pane's touch + * listeners lock on the first travel past the tap slop (#674): `'swipe'` for a + * sideways drag on a terminal that cannot pan, which belongs to the session + * swipe (SwipeHandler, mobile-handlers.js) and so neither scrolls nor ends as a + * tap. + * + * The edge this pins: a scroll whose first few pixels lean sideways (a thumb + * arc: +9 px across, +5 px down, then straight down) used to lock as `'swipe'` + * for the whole contact, so every later touchmove returned before the scroll + * code, the swipe itself gave up at its vertical drift limit, and the gesture + * did nothing. Past `SwipeHandler.maxVerticalDrift` the drag is handed back to + * the scroll (`'y'`). A clean sideways swipe must still lock as `'swipe'` and + * leave the buffer where it was. + * + * What runs for real: the shipped app (page served by a WebServer on port 0), + * its xterm, the touch listeners `initTerminal()` installs on + * `#terminalContainer` and the gates they call. What is stubbed: the active + * session (a shell session the page knows about but no PTY, so a drag scrolls + * xterm's own buffer), and `nextSession`/`prevSession`, so a swipe can never + * switch away mid-test. The touch events are DOM events carrying `touches`, + * dispatched on the container. + * + * Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PROBE_ID = 'touch-swipe-axis-probe'; + +describe('touch axis lock of the primary pane in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, hasTouch: true }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + // Let the page settle first: its layout refits the terminal (62 rows to 50 + // at this size, measured) a moment after load, which moves baseY. + await page.waitForTimeout(3500); + await page.evaluate(async (id) => { + const app = (window as any).app; + app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); + app.activeSessionId = id; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app.nextSession = () => {}; + app.prevSession = () => {}; + let text = ''; + for (let i = 0; i < 300; i++) text += `\r\nscrollback row ${i}`; + await new Promise((resolve) => app.terminal.write(text, resolve)); + }, PROBE_ID); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** Dispatch one touch event of `type` at (x, y) on the terminal container. */ + const touch = (type: string, x: number, y: number) => + page.evaluate( + ([t, cx, cy]) => { + const container = document.getElementById('terminalContainer')!; + const ev = new Event(t as string, { bubbles: true, cancelable: true }); + const point = { clientX: cx, clientY: cy, target: container }; + Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] }); + Object.defineProperty(ev, 'changedTouches', { value: [point] }); + container.dispatchEvent(ev); + }, + [type, x, y] as const + ); + + /** Viewport back at the bottom, and a start point inside the container. */ + const reset = () => + page.evaluate(() => { + const app = (window as any).app; + app.terminal.scrollToBottom(); + const container = document.getElementById('terminalContainer')!; + const r = container.getBoundingClientRect(); + const b = app.terminal.buffer.active; + return { + x: Math.round(r.left + r.width / 2), + y: Math.round(r.top + 40), + height: Math.round(r.height), + baseY: b.baseY, + viewportY: b.viewportY, + rows: app.terminal.rows, + canPan: container.classList.contains('term-overflows-x'), + }; + }); + + /** SwipeHandler is a top-level `const` (no window property): read it by name. */ + const swipeDrift = () => + page.evaluate('typeof SwipeHandler === "undefined" ? null : SwipeHandler.maxVerticalDrift'); + + const state = () => + page.evaluate(() => { + const app = (window as any).app; + const b = app.terminal.buffer.active; + return { axis: app._terminalGestureAxis, viewportY: b.viewportY, baseY: b.baseY, rows: app.terminal.rows }; + }); + + it('hands a drag that opens sideways back to the scroll once it passes the swipe drift limit', async () => { + const start = await reset(); + // The preconditions, so a pass is not vacuous: real scrollback to scroll + // through, a terminal that cannot pan (a sideways opening locks 'swipe', + // not 'x'), and room in the container for 300 px of travel. + expect(start.baseY).toBeGreaterThan(50); + expect(start.viewportY).toBe(start.baseY); + expect(start.canPan).toBe(false); + expect(await swipeDrift()).toBe(50); + expect(start.height).toBeGreaterThan(340); + + const { x, y } = start; + await touch('touchstart', x, y); + await touch('touchmove', x + 9, y + 5); // the thumb arc: past the slop, mostly sideways + const opened = await state(); + expect(opened.axis).toBe('swipe'); + expect(opened.viewportY).toBe(opened.baseY); + + for (let dy = 25; dy <= 305; dy += 20) await touch('touchmove', x + 9, y + dy); + const dragged = await state(); + expect(dragged.axis).toBe('y'); + // Finger down scrolls back through the scrollback, measured from the bottom + // (baseY) as it is now. 300 px of travel is well over ten rows; the travel + // up to the drift limit is not counted. + expect(dragged.baseY - dragged.viewportY).toBeGreaterThanOrEqual(5); + // Cancel rather than lift: a lift starts a momentum fling, which would keep + // scrolling into the next test. + await touch('touchcancel', x + 9, y + 305); + }, 60000); + + it('keeps a clean sideways swipe locked as the session swipe, with nothing scrolled', async () => { + const start = await reset(); + const { x, y } = start; + await touch('touchstart', x + 120, y); + for (let dx = 20; dx <= 160; dx += 20) await touch('touchmove', x + 120 - dx, y + 3); + const swiped = await state(); + expect(swiped.axis).toBe('swipe'); + expect(swiped.viewportY).toBe(swiped.baseY); + await touch('touchcancel', x - 40, y + 3); + }, 60000); +});