From 3a7be356aeff5d66fa698f271776e0a8eefc770a Mon Sep 17 00:00:00 2001 From: lior Date: Mon, 10 Aug 2026 18:20:41 +0300 Subject: [PATCH] fix(mobile): do not dismiss the keyboard when a scroll ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the dismiss handler in #279: it fired on any touchend, and a scroll ends in touchend too. Scrolling to read something while composing closed the keyboard and dropped the composer — worse than the bug it fixed. Track finger travel from touchstart and only treat a near-stationary gesture as a tap, using the same 8px TAP_THRESHOLD the terminal's own touch handling uses so both agree on tap-vs-scroll. Multi-touch is never a dismissing tap. All three listeners stay passive; nothing calls preventDefault. Measured on a Pixel-class viewport with a Firefox UA: tap -> dismissed scroll (120px) -> keyboard kept micro-drift (4px) -> dismissed, so an imprecise tap still works Co-Authored-By: Claude Opus 5 --- src/web/public/terminal-ui.js | 38 +++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ba97630b..a497b597 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -36,6 +36,9 @@ // (_installMobileKeyboardDismiss). Two groups: anything that is about to take // focus itself, and the accessory bar, which is built to be used while the // keyboard is open. + // Finger travel (px) still counted as a tap for keyboard dismissal. Matches + // the terminal's own TAP_THRESHOLD so both agree on tap-vs-scroll. + const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8; const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [ 'input', 'textarea', @@ -199,6 +202,7 @@ PAGE_KEY_MAX_PER_BATCH, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, + MOBILE_KEYBOARD_DISMISS_TAP_SLOP, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -3534,7 +3538,35 @@ Object.assign(CodemanApp.prototype, { */ _installMobileKeyboardDismiss() { if (this._mobileKeyboardDismissHandler) return; + + // A SCROLL also ends in touchend, and dismissing there is wrong: scrolling + // to read something while composing must not close the keyboard and lose + // the composer. Track how far the finger travelled and only treat a + // near-stationary gesture as a tap — the same TAP_THRESHOLD the terminal's + // own touch handling uses, so both agree on what a tap is. + let startX = 0; + let startY = 0; + let moved = false; + this._mobileKeyboardDismissStart = (ev) => { + if (ev.touches.length !== 1) { + moved = true; // a multi-touch gesture is never a dismissing tap + return; + } + startX = ev.touches[0].clientX; + startY = ev.touches[0].clientY; + moved = false; + }; + this._mobileKeyboardDismissMove = (ev) => { + if (moved || !ev.touches.length) return; + const dx = ev.touches[0].clientX - startX; + const dy = ev.touches[0].clientY - startY; + const slop = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP; + if (Math.abs(dx) > slop || Math.abs(dy) > slop) { + moved = true; + } + }; this._mobileKeyboardDismissHandler = (ev) => { + if (moved) return; if (!this._isMobileTerminalInputFocused()) return; const target = ev.target; if (!target || typeof target.closest !== 'function') return; @@ -3542,8 +3574,10 @@ Object.assign(CodemanApp.prototype, { if (target.closest(window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR)) return; this._blurMobileTerminalInput(); }; - // Passive: this never calls preventDefault, so it must not make the page - // feel less responsive to scrolling. + // Passive throughout: this never calls preventDefault, so it must not make + // the page feel less responsive to scrolling. + document.addEventListener('touchstart', this._mobileKeyboardDismissStart, { passive: true }); + document.addEventListener('touchmove', this._mobileKeyboardDismissMove, { passive: true }); document.addEventListener('touchend', this._mobileKeyboardDismissHandler, { passive: true }); },