From e8fc08beb3e7221597b01502c0615987f5788ac5 Mon Sep 17 00:00:00 2001 From: JD Date: Thu, 22 Oct 2026 13:53:48 -0400 Subject: [PATCH] fix(mobile): hand a swipe-locked drag back to the scroll past the swipe's drift limit A scroll whose first move past the tap slop leaned sideways (a thumb arc) locked as 'swipe' for the whole contact, and SwipeHandler drops a swipe at 50 px of vertical drift, so the gesture did nothing. Past that limit the drag is now a 'y' scroll. Also brings the mobile-handlers fileoverview to the current 500 ms / 50 px limits and notes in the Mobile Guide that a selection pauses the swipe and a wide terminal pans instead. --- docs/wiki/Mobile-Guide.md | 3 ++- src/web/public/mobile-handlers.js | 2 +- src/web/public/terminal-ui.js | 16 ++++++++++++++-- test/session-swipe-gesture.test.ts | 14 +++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a78292..159d562c 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. ## The home screen diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 9bfd9acb..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. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 95d80e0a..8143c967 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1200,7 +1200,8 @@ Object.assign(CodemanApp.prototype, { // 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 + // ends as a tap on the session being left, until it drifts past the + // swipe's vertical limit and becomes a 'y' scroll. Published per gesture as // `this._terminalGestureAxis`, which SwipeHandler reads to stand aside // for a scroll or a pan. let panAxis = null; @@ -1335,7 +1336,18 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); - if (panAxis === 'swipe') return; + 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 (a thumb arc whose + // first move leaned sideways): 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/session-swipe-gesture.test.ts b/test/session-swipe-gesture.test.ts index 9acece0b..5eb13c54 100644 --- a/test/session-swipe-gesture.test.ts +++ b/test/session-swipe-gesture.test.ts @@ -147,6 +147,18 @@ describe('terminal touch handler hands sideways drags to the swipe', () => { 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;"); + expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') {"); + }); + + it("hands a 'swipe' drag back to the scroll once it passes the swipe's own drift limit", () => { + // A thumb arc whose first move leans sideways (+9 px across, +5 px down) locks + // as 'swipe'; SwipeHandler drops it past maxVerticalDrift, so without the + // handback the 300 px of downward travel that follows would do nothing. + const start = TERMINAL_SOURCE.indexOf("if (panAxis === 'swipe') {"); + const block = TERMINAL_SOURCE.slice(start, TERMINAL_SOURCE.indexOf("if (panAxis === 'x') {", start)); + expect(block).toContain('SwipeHandler.maxVerticalDrift'); + expect(block).toContain('if (Math.abs(touchY - touchStartY) <= drift) return;'); + expect(block).toContain("panAxis = 'y';"); + expect(block).toContain("this._terminalGestureAxis = 'y';"); }); });