mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge pull request #674: fix(mobile): give a sideways drag on the terminal to the session swipe alone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9d30a7ff36
11 files changed
+394
-30
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<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, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>((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<number | null>('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);
|
||||
});
|
||||
Reference in new issue
Block a user