fix(mobile): give a sideways drag on the terminal to the session swipe alone

The terminal's touch handler and the session swipe judged every gesture separately, so a sideways flick also ended as a tap on the session being left (a link, a CLI click, a keyboard toggle) or as a vertical scroll. The terminal now locks the dominant axis past the tap slop, hands a sideways drag it cannot pan to the swipe and publishes the axis, and the swipe stands aside for scrolls, pans, a lift the terminal consumed (its selection gesture), touch selections, a second finger and surfaces with controls of their own. A touch that starts outside the terminal resets the published axis, so swiping from the phone home screen into a session keeps working after a terminal scroll. A deliberate swipe gets 500ms instead of 300ms, and vertical drift cancels it mid-gesture instead of only at the lift.
This commit is contained in:
JD committed 2026-10-21 21:40:27 -04:00
1 parent 39077281d1
commit 63cada938d
8 files changed
+221 -28

No files matched your search

+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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([]);
+152
View File
@@ -0,0 +1,152 @@
// 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;
startTime: number;
};
function load(appOverrides: Record<string, unknown> = {}) {
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.
expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') return;");
});
});