From 15e27275b1f2fa56c8c546bf36846e924b11243f Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 31 Oct 2026 19:56:15 +0400 Subject: [PATCH 1/2] fix: focus mobile terminal on explicit session selection (johoja12/Codeman#35) --- config/test-suites.ts | 1 + src/web/public/app.js | 21 ++-- .../mobile-tab-keyboard-focus.browser.test.ts | 100 ++++++++++++++++++ test/mobile/tabs.test.ts | 4 +- 4 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 test/mobile-tab-keyboard-focus.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index ae9745422..178f13743 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,7 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ export const BROWSER_TEST_GLOBS = [ + 'test/mobile-tab-keyboard-focus.browser.test.ts', 'test/command-palette-agent-arrows.browser.test.ts', 'test/tmux-capture-sgr.browser.test.ts', 'test/tab-rail-resize.browser.test.ts', diff --git a/src/web/public/app.js b/src/web/public/app.js index 255132a63..2b31e40bc 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -7782,9 +7782,8 @@ class CodemanApp { // instead of switching to it; on a window too narrow for the grid it is an // ordinary click. if ((event?.ctrlKey || event?.metaKey) && this.addSessionToTiles?.(sessionId)) return; - // On touch with the keyboard hidden, blur the tapped tab so switching - // sessions doesn't pop the on-screen keyboard. Focus policy itself lives - // in selectSession via _shouldFocusTerminalForTabSwitch(). + // Retire stale textarea focus so an explicit mobile tap can reopen the + // keyboard synchronously. Automatic selection keeps its passive policy. const keyboardOpen = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true; if (!keyboardOpen && MobileDetection.isTouchDevice()) { document.activeElement?.blur?.(); @@ -9839,11 +9838,12 @@ class CodemanApp { bar.hidden = false; } - _shouldFocusTerminalForTabSwitch() { + _shouldFocusTerminalForTabSwitch(options = {}) { if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) { return true; } - return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + return options.auto !== true || + (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible); } async selectSession(sessionId, options = {}) { @@ -9876,7 +9876,10 @@ class CodemanApp { // which tab is showing, and so does the reload seed), and every other // clear path runs on the switch this early return skips, leaving a // yellow tab that no tap could clear. - if (userInitiated) this.markIdleAlertSeen(sessionId); + if (userInitiated) { + this.markIdleAlertSeen(sessionId); + if (this.terminal && this._shouldFocusTerminalForTabSwitch(options)) this.terminal.focus(); + } return; } // Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and @@ -9919,9 +9922,9 @@ class CodemanApp { // programmatic focus() within the user-gesture call stack (e.g. tab click). // After the first await the gesture context is lost and focus() is silently // ignored, leaving the keyboard unable to send input to the terminal. - // Desktop always focuses; touch focuses only while the on-screen keyboard - // is already open (so a tab switch doesn't pop the keyboard). - const shouldFocusTerminal = this._shouldFocusTerminalForTabSwitch(); + // Explicit mobile selection opens the keyboard. App-driven selection only + // preserves an already-visible keyboard; boot/replay must not pop it open. + const shouldFocusTerminal = this._shouldFocusTerminalForTabSwitch(options); if (shouldFocusTerminal && this.terminal) this.terminal.focus(); const _selStart = performance.now(); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts new file mode 100644 index 000000000..3cfcde7d5 --- /dev/null +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -0,0 +1,100 @@ +/** Real browser DOM/focus regression; uses exact methods, no live sessions or native keyboard claims. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +function method(file: string, name: string): string { + const source = readFileSync(resolve(publicDir, file), 'utf8'); + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + let result = ''; + const visit = (node: ts.Node) => { + if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) result = node.getText(tree); + ts.forEachChild(node, visit); + }; + visit(tree); + if (!result) throw new Error(`Missing ${name}`); + return result; +} +const methods = ['handleSessionTabClick', '_shouldFocusTerminalForTabSwitch', 'selectSession'] + .map((name) => method('app.js', name)) + .join(','); +async function harness(browser: Browser, touch = true): Promise { + const page = await browser.newPage({ + viewport: { width: touch ? 393 : 1280, height: 852 }, + hasTouch: touch, + isMobile: touch, + }); + await page.setContent( + '' + ); + await page.addScriptTag({ + content: ` + window.KeyboardHandler={keyboardVisible:false};window.MobileDetection={isTouchDevice:()=>${touch}}; + window._crashDiag={log(){}};window.focusCalls=0; + window.app={${methods},activeSessionId:'a',isSoloWindow:true,detachedSessions:new Set(), + sessions:new Map([['b',{name:'b'}]]),_selectGeneration:0, + terminal:{focus(){focusCalls++;document.getElementById('terminal').focus()}}, + _clearTimer(){},_flushLocalEchoTo(){},_setTerminalLoadState(){},_cleanupPreviousSession(){throw Error('STOP_BEFORE_SESSION_IO')},markIdleAlertSeen(){}}; + const select=app.selectSession;app.selectSession=(...args)=>select.apply(app,args).catch(e=>{ + if(e.message!=='STOP_BEFORE_SESSION_IO')throw e; + }); + document.getElementById('same').onclick=e=>app.handleSessionTabClick(e,'a'); + document.getElementById('other').onclick=e=>app.handleSessionTabClick(e,'b'); + `, + }); + return page; +} +describe('explicit mobile tab keyboard focus', () => { + let browser: Browser; + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser.close(); + }); + for (const target of ['same', 'other']) { + it(`focuses the terminal synchronously for a closed-keyboard ${target} tab tap`, async () => { + const page = await harness(browser); + try { + await page.locator('#terminal').focus(); // stale textarea focus with keyboard gone + await page.locator(`#${target}`).tap(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + expect(await page.evaluate('focusCalls')).toBe(1); + } finally { + await page.close(); + } + }); + } + it('direct same-session selection also restores terminal focus', async () => { + const page = await harness(browser); + try { + await page.evaluate('app.selectSession("a")'); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + it('keeps keyboard-hidden automatic selection passive, and preserves an existing keyboard', async () => { + const page = await harness(browser); + try { + await page.evaluate('app.selectSession("b",{auto:true})'); + expect(await page.evaluate('focusCalls')).toBe(0); + await page.evaluate('KeyboardHandler.keyboardVisible=true;app.selectSession("b",{auto:true})'); + expect(await page.evaluate('focusCalls')).toBe(1); + } finally { + await page.close(); + } + }); + it('preserves desktop selection focus', async () => { + const page = await harness(browser, false); + try { + await page.locator('#other').click(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); +}); diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index a751509b4..64d1b3b08 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -710,7 +710,7 @@ describe('Tab Navigation', () => { }); describe('Tab Touch Focus', () => { - it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => { + it('a closed-keyboard tab click retires stale textarea focus before explicit selection', async () => { const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'); try { await page.waitForTimeout(WAIT.PAGE_SETTLE); @@ -746,7 +746,7 @@ describe('Tab Navigation', () => { expect(result.hasHandler).toBe(true); expect(result.selected).toBe('mock-session-2'); - expect(result.preserveKeyboard).toBe(false); + expect(result.preserveKeyboard).toBeUndefined(); expect(result.activeIsTextarea).toBe(false); } finally { await context.close(); From 9a595fe85bc0cfdbc7b8d2d2bc993ef2597f8d8c Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Tue, 3 Nov 2026 14:13:58 +0400 Subject: [PATCH 2/2] fix(mobile): recover keyboard focus across dismissals and tab changes (#35) --- src/web/public/app.js | 16 +- src/web/public/mobile-handlers.js | 210 ++++++- src/web/public/terminal-ui.js | 117 +++- test/mobile-keyboard-stale-state.test.ts | 474 ++++++++++++++++ .../mobile-tab-keyboard-focus.browser.test.ts | 525 +++++++++++++++++- test/mobile/keyboard.test.ts | 21 +- test/terminal-touch-tap.test.ts | 3 +- 7 files changed, 1345 insertions(+), 21 deletions(-) create mode 100644 test/mobile-keyboard-stale-state.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 2b31e40bc..129a456ea 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -9870,6 +9870,15 @@ class CodemanApp { // fails toward acknowledging rather than toward an alert that can never be // cleared. const userInitiated = options?.auto !== true; + const touchDevice = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice(); + const focusTerminal = (fromGesture = false) => { + if (touchDevice && fromGesture) { + this._clearTouchSelection?.(); + this._touchSelectionFocusGuardUntil = 0; + if (this._focusMobileTerminalInput) this._focusMobileTerminalInput('session-tab'); + else this.terminal.focus(); + } else this.terminal.focus(); + }; if (this.activeSessionId === sessionId && !forceReload) { // Tapping the tab you are already on is still "I checked it". The alert // can be armed on the ACTIVE tab (a live idle_prompt fires regardless of @@ -9878,7 +9887,7 @@ class CodemanApp { // yellow tab that no tap could clear. if (userInitiated) { this.markIdleAlertSeen(sessionId); - if (this.terminal && this._shouldFocusTerminalForTabSwitch(options)) this.terminal.focus(); + if (this.terminal && this._shouldFocusTerminalForTabSwitch(options)) focusTerminal(true); } return; } @@ -9925,7 +9934,6 @@ class CodemanApp { // Explicit mobile selection opens the keyboard. App-driven selection only // preserves an already-visible keyboard; boot/replay must not pop it open. const shouldFocusTerminal = this._shouldFocusTerminalForTabSwitch(options); - if (shouldFocusTerminal && this.terminal) this.terminal.focus(); const _selStart = performance.now(); const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); @@ -9963,6 +9971,8 @@ class CodemanApp { // selectSession or reconnect catches up. this._updateSseSubscription(sessionId); this.hideWelcome(); + // Reveal the input before spending the synchronous mobile focus gesture. + if (shouldFocusTerminal && this.terminal) focusTerminal(userInitiated); // Terminal-pane entrance: plays for a freshly created session, and on every // switch when that option is on. Transform/opacity/clip-path only, xterm's // FitAddon reads the untransformed layout box, so this cannot reach the PTY. @@ -10511,7 +10521,7 @@ class CodemanApp { this._connectWs(sessionId); _crashDiag.log('FOCUS'); - if (shouldFocusTerminal && this.terminal) this.terminal.focus(); + if (shouldFocusTerminal && this.terminal && !touchDevice) focusTerminal(); this.scrollToLastNonEmptyLine(); // If we switched INTO this tab while the soft keyboard is already up, no // viewport-resize transition fires (handleViewportResize only runs diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index e172683e7..9f94abd7e 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -221,6 +221,100 @@ const MobileDetection = { * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { + _keyboardMinHeight: 0, + KEYBOARD_GONE_GROWTH_PX: 150, + KEYBOARD_FOCUS_SETTLE_MS: 350, + _editableHasFocus() { + const el = document.activeElement; + if (!el) return false; + if (this._isTerminalInput(el) || el.isContentEditable) return true; + const tag = el.tagName?.toLowerCase(); + if (tag === 'textarea') return true; + if (tag !== 'input') return false; + const type = el.type?.toLowerCase(); + return !['checkbox', 'radio', 'range', 'file', 'button', 'submit', 'reset', 'color', 'image', 'hidden'].includes( + type + ); + }, + + _isTerminalInput(el) { + return !!el && (el.classList?.contains('xterm-helper-textarea') || el.id === 'cjkInput'); + }, + + _traceKeyboardInputBox(reason) { + if (typeof _crashDiag === 'undefined') return; + try { + const active = document.activeElement; + const input = this._isTerminalInput(active) ? active : typeof app !== 'undefined' ? app.terminal?.textarea : null; + if (!input?.getBoundingClientRect) return; + const rect = input.getBoundingClientRect(); + const viewport = window.visualViewport; + let left = viewport?.offsetLeft || 0; + let top = viewport?.offsetTop || 0; + let right = left + (viewport?.width || window.innerWidth); + let bottom = top + (viewport?.height || window.innerHeight); + for (let parent = input.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent); + const box = parent.getBoundingClientRect(); + if (/auto|scroll|hidden|clip/.test(style.overflowX)) { + left = Math.max(left, box.left); + right = Math.min(right, box.right); + } + if (/auto|scroll|hidden|clip/.test(style.overflowY)) { + top = Math.max(top, box.top); + bottom = Math.min(bottom, box.bottom); + } + } + const rounded = (value) => Math.round(value); + _crashDiag.log( + `KEYBOARD_BOX: v11 ${reason} ${JSON.stringify({ + rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded), + clip: [left, top, right, bottom].map(rounded), + intersects: + rect.width > 0 && + rect.height > 0 && + rect.right > left && + rect.left < right && + rect.bottom > top && + rect.top < bottom, + active: active === input, + activeTag: active?.tagName || 'none', + pageFocus: document.hasFocus(), + rendered: input.getClientRects().length, + inert: !!input.closest('[inert]'), + readonly: input.readOnly, + disabled: input.disabled, + inputMode: input.inputMode, + appHeight: getComputedStyle(document.documentElement).getPropertyValue('--app-height').trim(), + })}` + ); + } catch { + _crashDiag.log(`KEYBOARD_BOX: v11 ${reason} unavailable`); + } + }, + + _traceKeyboardState(reason) { + if (typeof _crashDiag === 'undefined') return; + const viewport = window.visualViewport; + const active = document.activeElement; + const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; + this._traceKeyboardInputBox(reason); + _crashDiag.log( + `KEYBOARD_STATE: v11 ${reason} focus=${focus} visible=${this.keyboardVisible} height=${Math.round(viewport?.height || 0)} width=${Math.round(viewport?.width || 0)} offset=${Math.round(viewport?.offsetTop || 0)} scale=${viewport?.scale || 1} layout=${window.innerHeight} baseline=${Math.round(this.initialViewportHeight)} min=${Math.round(this._keyboardMinHeight)} owner=${this._keyboardFocusOwner} request=${typeof app !== 'undefined' ? app._mobileKeyboardFocusGeneration : undefined} hide=${this._lastKeyboardHide || 'none'} page=${document.visibilityState}` + ); + }, + + forceKeyboardHidden() { + if (!this.keyboardVisible) return; + this.keyboardVisible = false; + this._keyboardMinHeight = 0; + document.body.classList.remove('keyboard-visible'); + this.onKeyboardHide(); + this._traceKeyboardState('force-hidden'); + MobileDetection.updateAppHeight(); + this.updateLayoutForKeyboard(); + }, + VIEWPORT_SETTLE_MS: 80, lastViewportHeight: 0, // Width of the visual viewport at the previous resize event. A virtual @@ -243,15 +337,32 @@ const KeyboardHandler = { // Only initialize on touch devices if (!MobileDetection.isTouchDevice()) return; - this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; - this.lastViewportHeight = this.initialViewportHeight; - // Seed the width too, or the first resize event reads as a shape change and - // swallows a real keyboard. - this.lastViewportWidth = window.visualViewport?.width || window.innerWidth; + // init() is reached from more than one boot path; register once. + if (this._initialized) return; + this._initialized = true; + + const height = window.visualViewport?.height || window.innerHeight; + const width = window.visualViewport?.width || window.innerWidth; + // SSE reconnect tears down/reinstalls these listeners while input can + // remain open. Retain its calibrated baseline on the same display width; + // adopting the shrunken height would turn the next event into a false hide. + if (!(this.keyboardVisible && this.lastViewportWidth === width && this.initialViewportHeight > 0)) { + this.initialViewportHeight = height; + } + this.lastViewportHeight = height; + this.lastViewportWidth = width; + + this._traceKeyboardState('init'); + if (typeof _crashDiag !== 'undefined') { + _crashDiag.log( + `KEYBOARD_BUILD: lifecycle-v11 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + ); + } // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { const target = e.target; + if (this._isTerminalInput(target)) this._traceKeyboardState('focusin-terminal'); if (!this.isInputElement(target)) return; // Wait for keyboard animation, then scroll input into view @@ -261,6 +372,37 @@ const KeyboardHandler = { }; document.addEventListener('focusin', this._focusinHandler); + // COD-467: a phone keyboard cannot be up with nothing editable focused. When + // focus leaves the last editable element and the closing resize did not + // clear the state (no event, or one the detector misread), clear it here, + // or throttledResize and the mobile resize retry keep skipping and the + // terminal never grows back. The settle lets a refocus land first (the + // accessory bar and the keyboard tap fix both hand focus straight back). + this._focusoutHandler = (e) => { + if (this._isTerminalInput(e?.target)) this._traceKeyboardState('focusout-terminal'); + clearTimeout(this._focusSettleTimer); + this._focusSettleTimer = setTimeout(() => { + this._focusSettleTimer = null; + if (this.keyboardVisible && !this._editableHasFocus()) this.forceKeyboardHidden(); + }, this.KEYBOARD_FOCUS_SETTLE_MS); + }; + document.addEventListener('focusout', this._focusoutHandler); + + // COD-467: iOS hides the keyboard when the page goes to the background (app + // switch, tab switch, lock) and never brings it back, but the terminal's + // hidden textarea KEEPS focus. A later tap then re-focuses the element that + // already has it, which WebKit ignores, so the keyboard would not open until + // something else took focus first (switching sessions did, via + // handleSessionTabClick's blur). Release it on return. + this._visibilityHandler = () => { + if (document.visibilityState !== 'visible') return; + const active = document.activeElement; + if (this._isTerminalInput(active)) active.blur?.(); + this.forceKeyboardHidden(); + this._traceKeyboardState('foreground'); + }; + document.addEventListener('visibilitychange', this._visibilityHandler); + // Use visualViewport to detect keyboard and reposition toolbar if (window.visualViewport) { this._viewportResizeHandler = () => { @@ -288,6 +430,17 @@ const KeyboardHandler = { /** Remove event listeners */ cleanup() { + this._initialized = false; + if (this._focusoutHandler) { + document.removeEventListener('focusout', this._focusoutHandler); + this._focusoutHandler = null; + } + if (this._visibilityHandler) { + document.removeEventListener('visibilitychange', this._visibilityHandler); + this._visibilityHandler = null; + } + clearTimeout(this._focusSettleTimer); + this._focusSettleTimer = null; if (this._focusinHandler) { document.removeEventListener('focusin', this._focusinHandler); this._focusinHandler = null; @@ -344,6 +497,7 @@ const KeyboardHandler = { // branches are skipped on a shape change, whichever way the height moved. if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; + this._keyboardMinHeight = currentHeight; // Hold the PTY's rows for as long as the keyboard covers the terminal // (keyboardHoldsRows, keyboardClipPx in constants.js): every height // change makes Claude Code clear and reprint only the tail that fits. @@ -355,16 +509,36 @@ const KeyboardHandler = { // While the keyboard is open, size the app to the visual viewport so // xterm's bottom row and cursor sit above the OS keyboard. document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); + // Reconciliation after foreground recovery can see the OLD shrunken + // viewport again. Only a newly observed shrink can own input focus; + // otherwise its eventual restore could cancel a newer focus request. + this._keyboardFocusOwner = + currentHeight < this.lastViewportHeight && typeof app !== 'undefined' + ? app._mobileKeyboardFocusGeneration + : undefined; + this._keyboardFocusOwnerInput = this._keyboardFocusOwner !== undefined ? document.activeElement : undefined; this.onKeyboardShow(); + this._traceKeyboardState('viewport-show'); } // Keyboard hidden (viewport grew back close to initial) // Use 100px threshold (not 50) to handle iOS address bar drift, - // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes - else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) { + // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes. + // COD-467: OR the viewport rose far above the lowest height seen while the + // keyboard was up. Chrome on iOS restores its own toolbars as the keyboard + // leaves, landing >100px short of a baseline taken with them collapsed, so + // the baseline rule alone left keyboardVisible latched (and every terminal + // resize skipped). Toolbars and keyboard size changes (predictive bar, + // emoji) move tens of px; a keyboard leaving moves ~300. + else if ( + !shapeChanged && + this.keyboardVisible && + (heightDiff < 100 || currentHeight - this._keyboardMinHeight > this.KEYBOARD_GONE_GROWTH_PX) + ) { this.keyboardVisible = false; this._releaseHeldRows(); document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); + this._traceKeyboardState('viewport-hide'); // Re-sync --app-height now that keyboard is gone (MobileDetection skipped // updates while keyboardVisible was true) MobileDetection.updateAppHeight(); @@ -400,6 +574,7 @@ const KeyboardHandler = { this.initialViewportHeight = currentHeight; } if (this.keyboardVisible) { + this._keyboardMinHeight = Math.min(this._keyboardMinHeight || currentHeight, currentHeight); document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); } @@ -541,6 +716,27 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { + // Native dismiss keeps xterm focused on iOS. Release that old editing + // session before the next tap, but never a newer open request: its focus + // generation differs from the request that produced this visible keyboard. + const owner = this._keyboardFocusOwner; + const ownerInput = this._keyboardFocusOwnerInput; + this._keyboardFocusOwner = undefined; + this._keyboardFocusOwnerInput = undefined; + const release = + Number.isSafeInteger(owner) && + typeof app !== 'undefined' && + owner === app._mobileKeyboardFocusGeneration && + ownerInput === document.activeElement && + app._isMobileTerminalInputFocused?.(); + // Retain the last close decision even if subsequent failed taps fill the + // breadcrumb ring. No input contents are recorded. + this._lastKeyboardHide = `owner:${owner},generation:${typeof app !== 'undefined' ? app._mobileKeyboardFocusGeneration : undefined},same:${ownerInput === document.activeElement},release:${!!release}`; + if (release) { + app._blurMobileTerminalInput(); + this._traceKeyboardState('owned-hide-release'); + } + // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.hide(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0a37bc46e..61bab24c9 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1356,7 +1356,8 @@ Object.assign(CodemanApp.prototype, { isTouching = true; touchContactId++; didScroll = false; - tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); + tapStartedWithTerminalFocus = this._mobileKeyboardIsUp(); + if (!tapStartedWithTerminalFocus) this._prepareMobileTerminalFocusGesture(); // Classifying scans the whole viewport with translateToString, and // this runs at the start of EVERY gesture including scroll drags. // Cache the result for the touchend of this same gesture rather than @@ -1375,7 +1376,7 @@ Object.assign(CodemanApp.prototype, { // early return, closes both routes to focus at once. Since // selectSession() ends with scrollToLastNonEmptyLine(), that made // the keyboard unreachable after every tab switch. - ev.preventDefault(); + if (!MobileDetection.isIOS?.() || document.hasFocus()) ev.preventDefault(); this._blurMobileTerminalInput(); } // Hold still and this press becomes a text selection. Cancelled by any @@ -5763,14 +5764,105 @@ Object.assign(CodemanApp.prototype, { return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine); }, - _focusMobileTerminalInput() { + + _prepareMobileTerminalFocusGesture() { + this._mobileNativeClickFocusRequest = null; + if (!this._isMobileTerminalInputFocused() || this._mobileKeyboardIsUp()) return false; + this._blurMobileTerminalInput(); + if (typeof KeyboardHandler !== 'undefined') KeyboardHandler._traceKeyboardState?.('gesture-release'); + return true; + }, + + _mobileKeyboardIsUp() { + if (!this._isMobileTerminalInputFocused()) return false; + const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.(); + if (!touch || typeof KeyboardHandler === 'undefined') return true; + // Native dismissal can leave both textarea focus and our cached flag set. + // A menu tap happens to repair this by moving focus to a button. Re-read + // the viewport on the terminal tap instead, using the resize detector's + // existing hide thresholds; never blur a genuinely visible keyboard. + const viewport = window.visualViewport; + if (KeyboardHandler.keyboardVisible && viewport?.height > 0 && + viewport.width === KeyboardHandler.lastViewportWidth && + typeof KeyboardHandler.handleViewportResize === 'function') { + const baseline = KeyboardHandler.initialViewportHeight; + const minimum = KeyboardHandler._keyboardMinHeight; + const grewPastKeyboard = minimum > 0 && viewport.height - minimum > KeyboardHandler.KEYBOARD_GONE_GROWTH_PX; + if ((baseline > 0 && baseline - viewport.height < 100) || grewPastKeyboard) { + KeyboardHandler.handleViewportResize(); + if (typeof _crashDiag !== 'undefined') + _crashDiag.log(`KEYBOARD_RECHECK: height=${Math.round(viewport.height)} baseline=${Math.round(baseline)} min=${Math.round(minimum)} up=${KeyboardHandler.keyboardVisible}`); + } + } + return KeyboardHandler.keyboardVisible === true; + }, + _focusMobileTerminalInput(source = 'terminal-tap') { this._syncMobileHelperTextareaToCursor(); const cjkInput = document.getElementById('cjkInput'); + const target = cjkInput?.classList.contains('cjk-input-visible') ? cjkInput : this.terminal?.textarea; + const keyboardUp = this._mobileKeyboardIsUp(); + // A caret tap that merely retains the same open input is not a new open. + // Counting it invalidated ownership, so the later native dismiss could + // leave stale editing focus behind. Actual opens/transfers still supersede + // the previous request BEFORE blur/focus callbacks can run (#35). + const retainsFocus = keyboardUp && !!target && document.activeElement === target; + if (!retainsFocus) this._mobileKeyboardFocusGeneration = (this._mobileKeyboardFocusGeneration || 0) + 1; + // Fallback for callers without a terminal touchstart (e.g. tab selection). + if (this._isMobileTerminalInputFocused() && !keyboardUp) this._blurMobileTerminalInput(); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { this.terminal?.focus(); } + // A tab/search choice can reuse the visible keyboard without another + // viewport shrink. Transfer dismiss ownership only after that request has + // settled; a pending close must still leave a newer opening focused (#35). + const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null; + const viewport = window.visualViewport; + const transferGeneration = this._mobileKeyboardFocusGeneration; + const transferHeight = viewport?.height; + const transferWidth = viewport?.width; + if (source === 'session-tab' && !retainsFocus && handler?.keyboardVisible && + Number.isSafeInteger(handler._keyboardFocusOwner) && + transferHeight > 0 && transferHeight <= handler._keyboardMinHeight + 2 && + handler.initialViewportHeight - transferHeight > 150) { + setTimeout(() => { + if (this._mobileKeyboardFocusGeneration !== transferGeneration || + document.visibilityState !== 'visible' || !handler.keyboardVisible || + !Number.isSafeInteger(handler._keyboardFocusOwner) || + document.activeElement !== target || !this._isMobileTerminalInputFocused() || + viewport?.height !== transferHeight || viewport?.width !== transferWidth || + viewport.height > handler._keyboardMinHeight + 2) return; + handler._keyboardFocusOwner = transferGeneration; + handler._keyboardFocusOwnerInput = target; + handler._traceKeyboardState?.('tab-focus-owner-transfer'); + }, handler.KEYBOARD_FOCUS_SETTLE_MS || 350); + } + // Script window.focus() was declined on the phone. Preserve the native + // compatibility mouse default that can activate its page, then focus from + // that trusted click. Never forward those mouse events to the CLI. + this._mobileNativeClickFocusRequest = source === 'terminal-tap' && + MobileDetection.isIOS?.() && document.visibilityState === 'visible' && + typeof document.hasFocus === 'function' && !document.hasFocus() && + typeof navigator !== 'undefined' && navigator.userActivation?.isActive === true && + !handler?.keyboardVisible && target ? { + target, element: this.terminal?.element, sessionId: this.activeSessionId, + generation: this._mobileKeyboardFocusGeneration, + expires: performance.now() + (window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450), + } : null; + if (typeof _crashDiag !== 'undefined') { + const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null; + _crashDiag.log(`KEYBOARD_FOCUS: focused=${this._isMobileTerminalInputFocused()} gesture=${typeof navigator !== 'undefined' ? navigator.userActivation?.isActive : undefined} cached=${handler?.keyboardVisible} retains=${retainsFocus} height=${Math.round(window.visualViewport?.height || 0)} baseline=${Math.round(handler?.initialViewportHeight || 0)} min=${Math.round(handler?._keyboardMinHeight || 0)}`); + // Observe delayed native viewport/focus changes without retrying focus. + const request = this._keyboardFocusRequestId = (this._keyboardFocusRequestId || 0) + 1; + handler?._traceKeyboardState?.(`request-${request}-${source}`); + for (const delay of [350, 1000]) { + setTimeout(() => { + if (request !== this._keyboardFocusRequestId || document.visibilityState === 'hidden') return; + handler?._traceKeyboardState?.(`request-${request}-after-${delay}ms`); + }, delay); + } + } }, _handleMobileTerminalTap(touch, startedWithTerminalFocus, cachedIntent = null) { @@ -6430,11 +6522,28 @@ Object.assign(CodemanApp.prototype, { const suppressTrustedCompatMouse = (ev) => { const suppressUntil = this._trustedTapMouseSuppressUntil || 0; if (!ev.isTrusted || performance.now() > suppressUntil) return; - ev.preventDefault(); + const request = this._mobileNativeClickFocusRequest; + const recover = request && ev.button === 0 && performance.now() <= request.expires && + request.element === el && request.target?.isConnected && + request.sessionId === this.activeSessionId && + request.generation === this._mobileKeyboardFocusGeneration && + document.visibilityState === 'visible' && !this._touchSelectionActive && + !(typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) && + (document.activeElement === request.target || document.activeElement === document.body); + // stopImmediatePropagation fences xterm/CLI reports. preventDefault also + // blocks native page activation, so omit it only for the armed reopen. + if (!recover) ev.preventDefault(); ev.stopImmediatePropagation(); + if (recover && typeof _crashDiag !== 'undefined') + _crashDiag.log(`KEYBOARD_NATIVE_CLICK: v11 ${ev.type} page=${document.hasFocus()}`); + if (ev.type === 'click') { + this._mobileNativeClickFocusRequest = null; + if (recover) this._focusMobileTerminalInput('native-click-recovery'); + } }; el.addEventListener('mousedown', suppressTrustedCompatMouse, true); el.addEventListener('mouseup', suppressTrustedCompatMouse, true); + el.addEventListener('click', suppressTrustedCompatMouse, true); }, _suppressTrustedTapMouseEvents() { diff --git a/test/mobile-keyboard-stale-state.test.ts b/test/mobile-keyboard-stale-state.test.ts new file mode 100644 index 000000000..21258d21e --- /dev/null +++ b/test/mobile-keyboard-stale-state.test.ts @@ -0,0 +1,474 @@ +// Port: none (pure logic in a vm context: no browser, no server). +// +// COD-467 (iPhone, Chrome = WebKit, Claude tab): "sometimes the keyboard does +// not open unless I switch sessions and back" and "sometimes when the keyboard +// is closed the terminal window does not resize". Both are STALE keyboard +// state, from opposite sides: +// +// 1. Stale FOCUS: the terminal's hidden textarea keeps focus while the +// keyboard is gone (iOS hides it on an app/tab switch or lock and never +// brings it back). Then a prompt-row tap calls focus() on the element that +// already has it, which WebKit ignores, and a transcript tap reads +// "keyboard up" and runs the tap-to-close toggle. Switching sessions fixed +// it only because handleSessionTabClick blurs the active element. +// 2. Stale FLAG: KeyboardHandler.keyboardVisible stays true after the +// keyboard closed, because hiding needed the visual viewport back within +// 100px of a baseline taken before it opened, and Chrome's own toolbars +// move it further than that. throttledResize and the mobile resize retry +// skip while the flag is set, so the terminal never resizes back. +// +// Lives outside test/mobile/ on purpose: that Playwright suite is excluded from +// the CI gate, and headless Chromium does not apply WebKit's keyboard rules. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const MOBILE = readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8'); + +interface Handler { + init(): void; + cleanup(): void; + handleViewportResize(): void; + keyboardVisible: boolean; + initialViewportHeight: number; +} + +type FakeEl = { + tagName: string; + classList: { contains(c: string): boolean }; + closest(): null; + blur(): void; + id?: string; + isContentEditable?: boolean; +}; + +/** iPhone 15 Pro in Chrome (CSS px). Chrome's toolbars collapsed → expanded costs ~110px. */ +const FULL = 750; +const TOOLBARS = 110; +const KEYBOARD = 330; +const WIDTH = 393; + +function loadKeyboardHandler() { + const viewport = { + width: WIDTH, + height: FULL, + offsetTop: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + const layout = { height: FULL }; + const docListeners = new Map void>>(); + const timers: Array<{ fn: () => void; at: number }> = []; + let now = 0; + const bodyClasses = new Set(); + const hides: number[] = []; + const doc = { + activeElement: null as FakeEl | null, + visibilityState: 'visible', + body: { classList: { add: (c: string) => bodyClasses.add(c), remove: (c: string) => bodyClasses.delete(c) } }, + documentElement: { style: { setProperty: () => {} } }, + addEventListener: (type: string, fn: (ev?: unknown) => void) => { + if (!docListeners.has(type)) docListeners.set(type, []); + docListeners.get(type)!.push(fn); + }, + removeEventListener: (type: string, fn: (ev?: unknown) => void) => { + docListeners.set( + type, + (docListeners.get(type) || []).filter((f) => f !== fn) + ); + }, + getElementById: () => null, + querySelector: () => null, + }; + const app = { + _mobileKeyboardFocusGeneration: 0, + relayoutMobileSubagentWindows: () => {}, + _isMobileTerminalInputFocused: () => !!doc.activeElement?.classList.contains('xterm-helper-textarea'), + _blurMobileTerminalInput: () => doc.activeElement?.blur(), + }; + const context = vm.createContext({ + console, + app, + navigator: { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/130 Mobile/15E148', + maxTouchPoints: 5, + }, + window: { + get innerWidth() { + return viewport.width; + }, + get innerHeight() { + return layout.height; + }, + visualViewport: viewport, + addEventListener: () => {}, + removeEventListener: () => {}, + matchMedia: () => ({ matches: true }), + scrollTo: () => {}, + }, + document: doc, + setTimeout: (fn: () => void, ms = 0) => timers.push({ fn, at: now + ms }), + clearTimeout: () => {}, + }); + vm.runInContext(`${MOBILE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' }); + const handler = (context as unknown as { __KH: Handler & { onKeyboardHide(): void } }).__KH; + const realHide = handler.onKeyboardHide.bind(handler); + handler.onKeyboardHide = () => { + hides.push(now); + realHide(); + }; + handler.init(); + const resize = (visual: number, layoutHeight: number) => { + viewport.height = visual; + layout.height = layoutHeight; + handler.handleViewportResize(); + }; + const fire = (type: string) => (docListeners.get(type) || []).forEach((fn) => fn({ type })); + const advance = (ms: number) => { + now += ms; + for (const t of timers.splice(0).sort((a, b) => a.at - b.at)) { + if (t.at <= now) t.fn(); + else timers.push(t); + } + }; + const requestFocus = (input: FakeEl) => { + app._mobileKeyboardFocusGeneration++; + doc.activeElement = input; + }; + return { handler, resize, fire, advance, doc, bodyClasses, hides, requestFocus }; +} + +const terminalTextarea = (onBlur = () => {}): FakeEl => ({ + tagName: 'TEXTAREA', + classList: { contains: (c: string) => c === 'xterm-helper-textarea' }, + closest: () => null, + blur: onBlur, +}); + +describe('COD-467 stale keyboard FLAG: the terminal resizes back when the keyboard closes', () => { + it('detects the close even when Chrome toolbars leave the viewport >100px from the old baseline', () => { + const { handler, resize, bodyClasses } = loadKeyboardHandler(); + // Keyboard opens over a page whose toolbars were collapsed (baseline 750). + resize(FULL - KEYBOARD, FULL); + expect(handler.keyboardVisible).toBe(true); + // Keyboard closes and Chrome brings its toolbars back: 640, i.e. 110px + // short of the baseline. The old rule (within 100px) never cleared. + resize(FULL - TOOLBARS, FULL - TOOLBARS); + expect(handler.keyboardVisible).toBe(false); + expect(bodyClasses.has('keyboard-visible')).toBe(false); + }); + + it('a keyboard that merely changes size (predictive bar, emoji) stays a keyboard', () => { + const { handler, resize } = loadKeyboardHandler(); + resize(FULL - KEYBOARD, FULL); + resize(FULL - KEYBOARD + 45, FULL); // predictive bar hidden + resize(FULL - KEYBOARD - 60, FULL); // taller emoji keyboard + expect(handler.keyboardVisible).toBe(true); + }); + + it('clears the flag once nothing editable has focus (a phone keyboard cannot be up then)', () => { + const { handler, resize, fire, advance, doc, hides } = loadKeyboardHandler(); + doc.activeElement = terminalTextarea(); + resize(FULL - KEYBOARD, FULL); + expect(handler.keyboardVisible).toBe(true); + // The keyboard is dismissed by blurring the input, but the closing resize + // never satisfies the detector (here: no resize event at all). + doc.activeElement = null; + fire('focusout'); + advance(500); + expect(handler.keyboardVisible).toBe(false); + expect(hides).toHaveLength(1); + }); + + it('does not clear it when focus just moves to another editable element', () => { + const { handler, resize, fire, advance, doc } = loadKeyboardHandler(); + doc.activeElement = terminalTextarea(); + resize(FULL - KEYBOARD, FULL); + fire('focusout'); + doc.activeElement = terminalTextarea(); // e.g. the accessory bar refocused the terminal + advance(500); + expect(handler.keyboardVisible).toBe(true); + }); +}); + +describe('COD-467 stale FOCUS: coming back to the page releases the hidden textarea', () => { + it('blurs the focused terminal input and clears the keyboard state on return', () => { + const { handler, resize, fire, doc } = loadKeyboardHandler(); + const blur = vi.fn(() => { + doc.activeElement = null; + }); + doc.activeElement = terminalTextarea(blur); + resize(FULL - KEYBOARD, FULL); + // App switch: iOS hides the keyboard; the textarea keeps focus. + doc.visibilityState = 'hidden'; + fire('visibilitychange'); + doc.visibilityState = 'visible'; + fire('visibilitychange'); + expect(blur).toHaveBeenCalledTimes(1); + expect(handler.keyboardVisible).toBe(false); + }); + + it('leaves an unrelated focused field alone', () => { + const { fire, doc } = loadKeyboardHandler(); + const blur = vi.fn(); + doc.activeElement = { + tagName: 'INPUT', + classList: { contains: () => false }, + closest: () => null, + blur, + id: 'renameInput', + }; + fire('visibilitychange'); + expect(blur).not.toHaveBeenCalled(); + }); + + it('registers its listeners once even though init() runs twice', () => { + const { handler, resize, fire, doc } = loadKeyboardHandler(); + handler.init(); + const blur = vi.fn(() => { + doc.activeElement = null; + }); + doc.activeElement = terminalTextarea(blur); + resize(FULL - KEYBOARD, FULL); + fire('visibilitychange'); + expect(blur).toHaveBeenCalledTimes(1); + }); +}); + +describe('keyboard lifecycle interleavings (#35)', () => { + for (const delay of [0, 80, 200, 349]) { + it(`does not let the previous closing resize blur a reopen after ${delay}ms`, () => { + const { handler, resize, fire, advance, doc, requestFocus } = loadKeyboardHandler(); + const blur = vi.fn(() => { + doc.activeElement = null; + }); + const input = terminalTextarea(blur); + requestFocus(input); + resize(FULL - KEYBOARD, FULL); + // Explicit dismiss, then a new focus before the old close animation ends. + input.blur(); + fire('focusout'); + advance(delay); + requestFocus(input); + resize(FULL, FULL); + expect(doc.activeElement).toBe(input); + expect(blur).toHaveBeenCalledTimes(1); + expect(handler.keyboardVisible).toBe(false); + // The new keyboard's own opening resize is still classified correctly. + resize(FULL - KEYBOARD, FULL); + expect(handler.keyboardVisible).toBe(true); + advance(500); + expect(doc.activeElement).toBe(input); + expect(handler.keyboardVisible).toBe(true); + }); + } + + it('releases native-dismissed focus before the next gesture when its request still owns the keyboard', () => { + const { handler, resize, doc, requestFocus } = loadKeyboardHandler(); + const blur = vi.fn(() => { + doc.activeElement = null; + }); + const input = terminalTextarea(blur); + requestFocus(input); + resize(FULL - KEYBOARD, FULL); + // OS dismiss restores geometry but leaves the original editable DOM focus. + resize(FULL, FULL); + expect(doc.activeElement).toBeNull(); + expect(blur).toHaveBeenCalledTimes(1); + expect(handler.initialViewportHeight).toBe(FULL); + // A subsequent gesture starts from released focus rather than same-task refocus. + requestFocus(input); + resize(FULL - KEYBOARD, FULL); + expect(doc.activeElement).toBe(input); + expect(handler.keyboardVisible).toBe(true); + }); + + it('does not release a different terminal input that acquired focus natively', () => { + const { resize, doc, requestFocus } = loadKeyboardHandler(); + const first = terminalTextarea(() => { + doc.activeElement = null; + }); + const second = terminalTextarea(() => { + doc.activeElement = null; + }); + requestFocus(first); + resize(FULL - KEYBOARD, FULL); + // A tile or another native input can focus without using the main helper. + doc.activeElement = second; + resize(FULL, FULL); + expect(doc.activeElement).toBe(second); + }); + + it('preserves open-keyboard calibration when reconnect reinitializes the listeners', () => { + const { handler, resize, doc, requestFocus } = loadKeyboardHandler(); + const input = terminalTextarea(() => { + doc.activeElement = null; + }); + requestFocus(input); + resize(FULL - KEYBOARD, FULL); + handler.cleanup(); + handler.init(); + resize(FULL - KEYBOARD, FULL); + expect(handler.initialViewportHeight).toBe(FULL); + expect(handler.keyboardVisible).toBe(true); + expect(doc.activeElement).toBe(input); + }); + + it('does not let reconciliation of an old shrunken viewport own a newer focus request', () => { + const { handler, resize, fire, doc, requestFocus } = loadKeyboardHandler(); + const input = terminalTextarea(() => { + doc.activeElement = null; + }); + requestFocus(input); + resize(FULL - KEYBOARD, FULL); + doc.visibilityState = 'hidden'; + fire('visibilitychange'); + doc.visibilityState = 'visible'; + fire('visibilitychange'); + requestFocus(input); + // No fresh shrink occurred: this is still the previous keyboard geometry. + resize(FULL - KEYBOARD, FULL); + resize(FULL, FULL); + expect(doc.activeElement).toBe(input); + expect(handler.keyboardVisible).toBe(false); + }); + + it('preserves keyboard-free calibration across foreground recovery before closing resize', () => { + const { handler, resize, fire, doc } = loadKeyboardHandler(); + doc.activeElement = terminalTextarea(() => { + doc.activeElement = null; + }); + resize(FULL - KEYBOARD, FULL); + doc.visibilityState = 'hidden'; + fire('visibilitychange'); + doc.visibilityState = 'visible'; + fire('visibilitychange'); + expect(handler.initialViewportHeight).toBe(FULL); + expect(handler.keyboardVisible).toBe(false); + // Reopen before restoration: the same smaller viewport must remain detectable. + doc.activeElement = terminalTextarea(); + resize(FULL - KEYBOARD, FULL); + expect(handler.keyboardVisible).toBe(true); + expect(handler.initialViewportHeight).toBe(FULL); + }); + + it('keeps calibration when the focus-out fallback fires before the closing animation', () => { + const { handler, resize, fire, advance, doc } = loadKeyboardHandler(); + doc.activeElement = terminalTextarea(); + resize(FULL - KEYBOARD, FULL); + doc.activeElement = null; + fire('focusout'); + advance(500); + expect(handler.initialViewportHeight).toBe(FULL); + doc.activeElement = terminalTextarea(); + resize(FULL - KEYBOARD, FULL); + expect(handler.keyboardVisible).toBe(true); + resize(FULL - TOOLBARS, FULL - TOOLBARS); + expect(handler.keyboardVisible).toBe(false); + expect(handler.initialViewportHeight).toBe(FULL - TOOLBARS); + }); +}); + +// ── terminal-ui.js: the tap decision ────────────────────────────────────── +function loadTapApp(opts: { keyboardVisible: boolean }) { + const CodemanApp = function CodemanApp(this: unknown) {}; + const calls: string[] = []; + const textarea = terminalTextarea(() => { + calls.push('blur'); + doc.activeElement = null; + }); + const doc = { activeElement: textarea as FakeEl | null, getElementById: () => null, querySelector: () => null }; + const context = vm.createContext({ + window: {}, + CodemanApp, + console, + document: doc, + performance: { now: () => 1000 }, + requestAnimationFrame: () => 1, + setTimeout: () => 1, + MobileDetection: { isTouchDevice: () => true, isMobile: () => true }, + KeyboardHandler: { keyboardVisible: opts.keyboardVisible }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + Blob: function Blob() {}, + URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }, + Worker: function Worker(this: { postMessage: () => void }) { + this.postMessage = () => {}; + }, + _crashDiag: { log: () => {} }, + }); + vm.runInContext(readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' }); + const app = new (CodemanApp as unknown as new () => Record)(); + app.terminal = { + textarea, + modes: { mouseTrackingMode: 'none' }, + focus: () => { + calls.push('focus'); + doc.activeElement = textarea; + }, + }; + app._syncMobileHelperTextareaToCursor = () => {}; + app._isActionableMobileTerminalTap = () => false; + app._tapIsOnCaretLine = () => true; + app._activateTerminalLinkAtPoint = () => false; + app._shouldReportMouseToCli = () => false; + return { app, calls, doc }; +} + +describe('COD-467 stale FOCUS: a tap opens the keyboard when the textarea is focused but the keyboard is gone', () => { + const touch = { clientX: 100, clientY: 600 }; + + it('"keyboard up" for the tap toggle means focused AND visible, not just focused', () => { + expect(loadTapApp({ keyboardVisible: false }).app._mobileKeyboardIsUp()).toBe(false); + expect(loadTapApp({ keyboardVisible: true }).app._mobileKeyboardIsUp()).toBe(true); + }); + + it('retains the opening generation when tapping an already-focused prompt with keyboard up', () => { + const { app, calls } = loadTapApp({ keyboardVisible: true }); + app._mobileKeyboardFocusGeneration = 7; + app._focusMobileTerminalInput(); + app._focusMobileTerminalInput(); + expect(app._mobileKeyboardFocusGeneration).toBe(7); + expect(calls).toEqual(['focus', 'focus']); + }); + + it('advances the opening generation for a real focus transfer while keyboard remains up', () => { + const { app, doc } = loadTapApp({ keyboardVisible: true }); + app._mobileKeyboardFocusGeneration = 7; + doc.activeElement = terminalTextarea(); // another terminal/CJK target owns focus + app._focusMobileTerminalInput(); + expect(app._mobileKeyboardFocusGeneration).toBe(8); + expect(doc.activeElement).toBe(app.terminal.textarea); + }); + + it('prompt-row tap: requests fresh focus with blur followed by focus', () => { + const { app, calls } = loadTapApp({ keyboardVisible: false }); + app._handleMobileTerminalTap(touch, app._mobileKeyboardIsUp(), 'input'); + expect(calls).toEqual(['blur', 'focus']); + }); + + it('transcript tap with stale focus opens the keyboard instead of running the close toggle', () => { + const { app, calls } = loadTapApp({ keyboardVisible: false }); + app._blurMobileTerminalInput(); // what touchstart does for a 'content' tap + calls.length = 0; + app._handleMobileTerminalTap(touch, false, 'content'); + expect(calls).toEqual(['focus']); + }); + + it('with the keyboard really up, a transcript tap still closes it, and a prompt tap does not bounce it', () => { + const up = loadTapApp({ keyboardVisible: true }); + up.app._handleMobileTerminalTap(touch, up.app._mobileKeyboardIsUp(), 'content'); + expect(up.calls).toEqual(['blur']); + const prompt = loadTapApp({ keyboardVisible: true }); + prompt.app._handleMobileTerminalTap(touch, false, 'input'); + expect(prompt.calls).toEqual(['focus']); // no blur/refocus flicker while the keyboard is up + }); + + it('touchstart records "keyboard up", not mere focus, for the tap decision', () => { + const src = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8'); + expect(src).toContain('tapStartedWithTerminalFocus = this._mobileKeyboardIsUp();'); + expect(src).not.toContain('tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();'); + }); +}); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 3cfcde7d5..0791bfd5c 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -6,12 +6,20 @@ import { chromium, type Browser, type Page } from 'playwright'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const publicDir = resolve(import.meta.dirname, '../src/web/public'); -function method(file: string, name: string): string { +function method(file: string, name: string, owner?: string): string { const source = readFileSync(resolve(publicDir, file), 'utf8'); const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); let result = ''; const visit = (node: ts.Node) => { - if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) result = node.getText(tree); + if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) { + const declaration = ts.isObjectLiteralExpression(node.parent) ? node.parent.parent : null; + if ( + !owner || + (declaration && ts.isVariableDeclaration(declaration) && declaration.name.getText(tree) === owner) + ) { + result = node.getText(tree); + } + } ts.forEachChild(node, visit); }; visit(tree); @@ -36,8 +44,8 @@ async function harness(browser: Browser, touch = true): Promise { window._crashDiag={log(){}};window.focusCalls=0; window.app={${methods},activeSessionId:'a',isSoloWindow:true,detachedSessions:new Set(), sessions:new Map([['b',{name:'b'}]]),_selectGeneration:0, - terminal:{focus(){focusCalls++;document.getElementById('terminal').focus()}}, - _clearTimer(){},_flushLocalEchoTo(){},_setTerminalLoadState(){},_cleanupPreviousSession(){throw Error('STOP_BEFORE_SESSION_IO')},markIdleAlertSeen(){}}; + terminal:{options:{},focus(){focusCalls++;document.getElementById('terminal').focus()}}, + _clearTimer(){},_flushLocalEchoTo(){},_setTerminalLoadState(){},_cleanupPreviousSession(){},_userScrollback(){return 100000},_renderHistoryTruncationBanner(){},_updateSseSubscription(){},_queueTransportActivationReconcile(){},_connectWs(){},hideWelcome(){},playTerminalEntrance(){throw Error('STOP_BEFORE_SESSION_IO')},markIdleAlertSeen(){}}; const select=app.selectSession;app.selectSession=(...args)=>select.apply(app,args).catch(e=>{ if(e.message!=='STOP_BEFORE_SESSION_IO')throw e; }); @@ -82,12 +90,68 @@ describe('explicit mobile tab keyboard focus', () => { try { await page.evaluate('app.selectSession("b",{auto:true})'); expect(await page.evaluate('focusCalls')).toBe(0); - await page.evaluate('KeyboardHandler.keyboardVisible=true;app.selectSession("b",{auto:true})'); + await page.evaluate( + 'KeyboardHandler.keyboardVisible=true;app.activeSessionId="a";app.selectSession("b",{auto:true})' + ); expect(await page.evaluate('focusCalls')).toBe(1); } finally { await page.close(); } }); + it('reveals a webview-hidden terminal before spending the synchronous focus', async () => { + const page = await harness(browser); + try { + await page.evaluate(() => { + const input = document.getElementById('terminal')!; + const wrap = document.createElement('div'); + wrap.id = 'hidden-terminal'; + input.before(wrap); + wrap.append(input); + wrap.style.display = 'none'; + (window as any).app._hideWebviewLayer = () => { + wrap.style.display = ''; + }; + }); + await page.locator('#other').tap(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + it('retires the previous text selection guard on an explicit tab tap', async () => { + const page = await harness(browser); + try { + await page.evaluate(() => { + const app = (window as any).app; + app._touchSelecting = true; + app._clearTouchSelection = () => { + app._touchSelecting = false; + }; + document.getElementById('terminal')!.addEventListener('focus', (e) => { + if (app._touchSelecting) (e.target as HTMLElement).blur(); + }); + }); + await page.locator('#other').tap(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + it('uses the mobile input target after mode setup, including CJK input', async () => { + const page = await harness(browser); + try { + await page.evaluate(() => { + const field = document.createElement('textarea'); + field.id = 'cjkInput'; + document.body.append(field); + (window as any).app._focusMobileTerminalInput = () => field.focus(); + }); + await page.locator('#other').tap(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('cjkInput'); + } finally { + await page.close(); + } + }); it('preserves desktop selection focus', async () => { const page = await harness(browser, false); try { @@ -98,3 +162,454 @@ describe('explicit mobile tab keyboard focus', () => { } }); }); + +describe('keyboard lifecycle and stale visibility', () => { + let browser: Browser; + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser.close(); + }); + async function keyboardPage() { + const page = await browser.newPage({ viewport: { width: 440, height: 852 }, hasTouch: true, isMobile: true }); + await page.setContent( + '
' + ); + await page.addScriptTag({ + content: ` + window.MobileDetection={isTouchDevice:()=>true,updateAppHeight(){}}; + window.hideEvents=0;window.focusEvents=0; + window.KeyboardHandler={${['init', 'cleanup', 'handleViewportResize', 'onKeyboardHide', 'onKeyboardShow', 'forceKeyboardHidden', '_isTerminalInput', '_editableHasFocus', 'isInputElement'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')}, + keyboardVisible:true,initialViewportHeight:visualViewport.height, + _keyboardMinHeight:visualViewport.height-300,lastViewportWidth:visualViewport.width, + KEYBOARD_GONE_GROWTH_PX:150,KEYBOARD_FOCUS_SETTLE_MS:350,_releaseHeldRows(){return false},resetLayout(){hideEvents++},_scheduleViewportSettle(){}, + _traceKeyboardState(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; + window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, + _syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, + terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + app.terminal.focus(); + document.getElementById('terminal').addEventListener('focus',()=>focusEvents++); + `, + }); + return page; + } + it('creates a fresh focus when a hide resize was missed', async () => { + const page = await keyboardPage(); + try { + await page.evaluate('app._focusMobileTerminalInput()'); + expect(await page.evaluate('focusEvents')).toBe(1); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false); + expect(await page.evaluate('hideEvents')).toBe(1); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + await page.locator('#menu').tap(); + await page.evaluate('focusEvents=0;KeyboardHandler.keyboardVisible=false;app._focusMobileTerminalInput()'); + expect(await page.evaluate('focusEvents')).toBe(1); + } finally { + await page.close(); + } + }); + it('releases stale input at touchstart and focuses only at touchend', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + const surface=document.createElement('div');surface.id='surface'; + surface.style.cssText='height:80px;background:#ddd';document.body.append(surface); + app.terminal.element=surface;app._installMobileTapMouseGuard(); + window.phases=[]; + surface.addEventListener('touchstart',()=>{ + app._prepareMobileTerminalFocusGesture(); + phases.push(['touchstart',app._isMobileTerminalInputFocused()]); + }); + surface.addEventListener('touchend',()=>{ + app._suppressTrustedTapMouseEvents(); + app._focusMobileTerminalInput(); + phases.push(['touchend',app._isMobileTerminalInputFocused()]); + }); + KeyboardHandler.keyboardVisible=false; + app.terminal.focus(); + `); + await page.locator('#surface').tap(); + expect(await page.evaluate('phases')).toEqual([ + ['touchstart', false], + ['touchend', true], + ]); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + + it('retains dismiss ownership after prompt taps while the keyboard is open', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + const surface=document.createElement('div');surface.id='prompt-surface'; + surface.style.cssText='height:60px;background:#ddd';document.body.append(surface); + app.terminal.element=surface;app._installMobileTapMouseGuard(); + surface.addEventListener('touchstart',()=>app._prepareMobileTerminalFocusGesture()); + surface.addEventListener('touchend',()=>{ + app._suppressTrustedTapMouseEvents();app._focusMobileTerminalInput(); + }); + `); + for (let cycle = 0; cycle < 20; cycle++) { + await page.locator('#menu').tap(); + await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()'); + for (let tap = 0; tap < cycle % 3; tap++) await page.locator('#prompt-surface').tap(); + expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===app._mobileKeyboardFocusGeneration')).toBe( + true + ); + await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); + expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true); + } + } finally { + await page.close(); + } + }); + + it('transfers dismiss ownership after a settled tab choice with the keyboard open', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput('session-tab'); + `); + for (let cycle = 0; cycle < 6; cycle++) { + await page.locator('#menu').tap(); + await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()'); + // A tab/search choice moves focus temporarily, while the OS keyboard stays up. + await page.locator('#menu').tap(); + await page.waitForTimeout(420); + expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===app._mobileKeyboardFocusGeneration')).toBe( + true + ); + await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); + } + } finally { + await page.close(); + } + }); + + for (const interruption of ['other-editor', 'new-request', 'closing-geometry']) { + it(`does not adopt a settled tab request after ${interruption}`, async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + app._focusMobileTerminalInput(); + cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); + window.oldOwner=KeyboardHandler._keyboardFocusOwner; + app.terminal.textarea.blur();app._focusMobileTerminalInput('session-tab'); + `); + await page.evaluate((kind) => { + const w = window as any; + if (kind === 'other-editor') { + const other = document.createElement('textarea'); + other.id = 'other-editor'; + document.body.append(other); + other.focus(); + } else if (kind === 'new-request') { + w.app.terminal.textarea.blur(); + w.app._focusMobileTerminalInput(); + } else { + w.cycleHeight += 50; + w.KeyboardHandler.handleViewportResize(); + } + }, interruption); + await page.waitForTimeout(420); + expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===oldOwner')).toBe(true); + expect(await page.evaluate(() => document.activeElement?.id)).toBe( + interruption === 'other-editor' ? 'other-editor' : 'terminal' + ); + } finally { + await page.close(); + } + }); + } + + for (const delay of [0, 80, 200, 349]) { + it(`protects a tab opening from the previous keyboard close at ${delay}ms`, async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + app._focusMobileTerminalInput(); + cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); + app.terminal.textarea.blur(); + app._focusMobileTerminalInput('session-tab'); + setTimeout(()=>{cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()},${delay}); + `); + await page.waitForTimeout(500); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false); + expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner')).toBeUndefined(); + } finally { + await page.close(); + } + }); + } + + it('survives listener reconnect with the keyboard open, then dismisses and reopens', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + app._focusMobileTerminalInput(); + cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); + KeyboardHandler.cleanup();KeyboardHandler.init();KeyboardHandler.handleViewportResize(); + `); + expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); + await page.evaluate(`document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput()`); + await page.locator('#menu').tap(); + await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + + it('preserves a new focus through each of 25 delayed closing viewport transitions', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height; + window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + `); + for (let cycle = 0; cycle < 25; cycle++) { + await page.locator('#menu').tap(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + await page.evaluate(` + cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); + if (!KeyboardHandler.keyboardVisible) throw Error('show not detected'); + app.terminal.textarea.blur(); + `); + // Reopen before the preceding close has restored the visual viewport. + await page.waitForTimeout([0, 16, 80, 200][cycle % 4]); + await page.locator('#menu').tap(); + await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } + // A hide notification must not take focus from a different editor. + await page.evaluate(` + const other=document.createElement('textarea');other.id='other-editor'; + document.body.append(other);other.focus();KeyboardHandler.onKeyboardHide(); + `); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('other-editor'); + } finally { + await page.close(); + } + }); + it('retains calibration when returning before the close animation completes', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); + cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); + window.fakeVisibility='hidden'; + Object.defineProperty(document,'visibilityState',{configurable:true,get:()=>fakeVisibility}); + document.dispatchEvent(new Event('visibilitychange')); + fakeVisibility='visible';document.dispatchEvent(new Event('visibilitychange')); + `); + expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true); + // A real resize may already have reconciled the still-obscured viewport. + expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); + await page.evaluate(` + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + `); + await page.locator('#menu').tap(); + await page.evaluate('KeyboardHandler.handleViewportResize()'); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + it('observes a focus request without stealing a different editor after the tap', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window._crashDiag={log(){}};window.observations=[]; + KeyboardHandler._traceKeyboardState=reason=>observations.push(reason); + app._focusMobileTerminalInput('session-tab'); + const editor=document.createElement('textarea');editor.id='other-editor'; + document.body.append(editor);editor.focus(); + `); + await page.waitForTimeout(1150); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('other-editor'); + expect(await page.evaluate('observations')).toEqual([ + 'viewport-hide', + 'request-1-session-tab', + 'request-1-after-350ms', + 'request-1-after-1000ms', + ]); + } finally { + await page.close(); + } + }); + it('records clipped input geometry without recording text or changing focus', async () => { + const page = await browser.newPage({ viewport: { width: 440, height: 852 } }); + try { + await page.setContent(`
+ +
`); + await page.addScriptTag({ + content: ` + window.logs=[];window._crashDiag={log:message=>logs.push(message)}; + window.KeyboardHandler={${['_traceKeyboardInputBox', '_isTerminalInput'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')}}; + window.app={terminal:{textarea:document.getElementById('terminal')}}; + document.getElementById('terminal').value='PRIVATE_TYPED_CONTENT'; + document.getElementById('editor').focus(); + KeyboardHandler._traceKeyboardInputBox('before-menu'); + document.getElementById('terminal').style.top='20px'; + KeyboardHandler._traceKeyboardInputBox('after-menu'); + `, + }); + const logs = (await page.evaluate('logs')) as string[]; + expect(logs[0]).toContain('"intersects":false'); + expect(logs[1]).toContain('"intersects":true'); + expect(logs.join('')).not.toContain('PRIVATE_TYPED_CONTENT'); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('editor'); + } finally { + await page.close(); + } + }); + it('permits native page activation on a lost-focus tap without duplicating CLI mouse actions', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + MobileDetection.isIOS=()=>true;KeyboardHandler.keyboardVisible=false; + window.pageFocused=false;window.cliMouseEvents=0;window.nativeDefaults=[]; + Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>pageFocused}); + window.focus=()=>{}; // Reproduce the phone declining script window.focus(). + const surface=document.createElement('div');surface.id='native-surface'; + surface.style.cssText='height:80px;background:#ddd';document.body.append(surface); + app.terminal.element=surface;app._installMobileTapMouseGuard(); + surface.addEventListener('touchend',()=>{ + app._suppressTrustedTapMouseEvents();app._focusMobileTerminalInput(); + }); + // Model only the native activation default: the cancelled v10 mousedown + // must not activate the page. This does not simulate an OS keyboard. + window.lastNativeMouseDown=null; + document.addEventListener('mousedown',event=>lastNativeMouseDown=event,true); + document.addEventListener('mouseup',()=>{ + nativeDefaults.push(lastNativeMouseDown.defaultPrevented); + if(!lastNativeMouseDown.defaultPrevented)pageFocused=true; + },true); + for(const kind of ['mousedown','mouseup','click']) + surface.addEventListener(kind,()=>cliMouseEvents++); + `); + await page.locator('#native-surface').tap(); + expect(await page.evaluate('nativeDefaults')).toEqual([false]); + expect(await page.evaluate('document.hasFocus()')).toBe(true); + expect(await page.evaluate('cliMouseEvents')).toBe(0); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + + for (const interruption of [ + 'page-focused', + 'newer-request', + 'other-editor', + 'expired', + 'session-change', + 'selection', + ]) { + it(`cancels native-click recovery safely for ${interruption}`, async () => { + const page = await keyboardPage(); + try { + await page.evaluate((kind) => { + const w = window as any; + w.MobileDetection.isIOS = () => true; + w.KeyboardHandler.keyboardVisible = false; + w.pageFocused = kind === 'page-focused'; + w.cliMouseEvents = 0; + w.nativeDefaults = []; + Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => w.pageFocused }); + const surface = document.createElement('div'); + surface.id = 'native-surface'; + surface.style.cssText = 'height:80px;background:#ddd'; + document.body.append(surface); + w.app.terminal.element = surface; + w.app._installMobileTapMouseGuard(); + surface.addEventListener('touchend', () => { + w.app._suppressTrustedTapMouseEvents(); + w.app._focusMobileTerminalInput(); + }); + let down: MouseEvent; + document.addEventListener( + 'mousedown', + (event) => { + down = event; + if (kind === 'newer-request') w.app._mobileKeyboardFocusGeneration++; + if (kind === 'expired' && w.app._mobileNativeClickFocusRequest) + w.app._mobileNativeClickFocusRequest.expires = 0; + if (kind === 'session-change') w.app.activeSessionId = 'another-session'; + if (kind === 'selection') w.app._touchSelectionActive = true; + if (kind === 'other-editor') { + const editor = document.createElement('textarea'); + editor.id = 'editor'; + document.body.append(editor); + editor.focus(); + } + }, + true + ); + document.addEventListener('mouseup', () => w.nativeDefaults.push(down.defaultPrevented), true); + for (const type of ['mousedown', 'mouseup', 'click']) + surface.addEventListener(type, () => w.cliMouseEvents++); + }, interruption); + await page.locator('#native-surface').tap(); + expect(await page.evaluate('nativeDefaults')).toEqual([true]); + expect(await page.evaluate('cliMouseEvents')).toBe(0); + expect(await page.evaluate('app._mobileNativeClickFocusRequest')).toBeNull(); + if (interruption === 'other-editor') + expect(await page.evaluate(() => document.activeElement?.id)).toBe('editor'); + } finally { + await page.close(); + } + }); + } + + it('does not blur/refocus an actually shrunken keyboard viewport', async () => { + const page = await keyboardPage(); + try { + await page.setViewportSize({ width: 440, height: 552 }); + await page.evaluate( + 'KeyboardHandler._keyboardMinHeight=visualViewport.height;focusEvents=0;app._prepareMobileTerminalFocusGesture();app._focusMobileTerminalInput()' + ); + expect(await page.evaluate('focusEvents')).toBe(0); + expect(await page.evaluate('hideEvents')).toBe(0); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true); + } finally { + await page.close(); + } + }); +}); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 1dfd4c971..212d2e50b 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -1136,6 +1136,19 @@ describe('Virtual Keyboard', () => { ) ); app.terminal.focus(); + // Headless Chromium has no native keyboard; model its visible viewport. + const viewport = window.visualViewport!; + const descriptor = Object.getOwnPropertyDescriptor(viewport, 'height'); + const fullHeight = viewport.height; + Object.defineProperty(viewport, 'height', { configurable: true, value: fullHeight - 300 }); + (window as any).__restoreKeyboardViewport35 = () => { + if (descriptor) Object.defineProperty(viewport, 'height', descriptor); + else delete (viewport as any).height; + }; + KeyboardHandler.initialViewportHeight = fullHeight; + KeyboardHandler._keyboardMinHeight = fullHeight - 300; + KeyboardHandler.lastViewportWidth = viewport.width; + KeyboardHandler.keyboardVisible = true; const screen = app.terminal.element?.querySelector('.xterm-screen'); const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; @@ -1165,7 +1178,13 @@ describe('Virtual Keyboard', () => { // one on an inert row — the case that now closes the keyboard. Previously // it re-focused, which left the accessory bar's chevron as the only way to // dismiss. The prompt row is unaffected and still positions the caret. - const activeClass = await page.evaluate(() => document.activeElement?.className); + const activeClass = await page.evaluate(() => { + const cls = document.activeElement?.className; + (window as any).__restoreKeyboardViewport35?.(); + delete (window as any).__restoreKeyboardViewport35; + KeyboardHandler.keyboardVisible = false; + return cls; + }); expect(activeClass).not.toContain('xterm-helper-textarea'); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 0a4674043..36a288fde 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -276,12 +276,13 @@ describe('terminal touch tap mouse guard', () => { // The toggle is scoped to 'content' on purpose: a second tap on the PROMPT // must still position the cursor. This is the guarantee that makes the // change safe to make, so it is pinned separately. - const { app, setActiveElement } = loadTerminalUiHarness(); + const { app, setActiveElement, setKeyboardVisible } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.sessions = new Map([['sess-1', { mode: 'claude' }]]); app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4); app._sendInputAsync = vi.fn(); + setKeyboardVisible(true); setActiveElement(app.terminal.textarea); expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input'); expect(app.terminal.textarea.blur).not.toHaveBeenCalled();