From d9386f35b5ab459afcb370160968390fa1cd74fa Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 31 Oct 2026 21:17:07 +0400 Subject: [PATCH 01/15] fix: stage mobile focus lifecycle corrections and regression RCA (#35) --- .../lifecycle-rca/README.md | 64 +++ .../lifecycle-rca/chromium-matrix.json | 400 ++++++++++++++++++ src/web/public/app.js | 28 +- .../mobile-tab-keyboard-focus.browser.test.ts | 62 ++- 4 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/chromium-matrix.json diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md new file mode 100644 index 000000000..dc00d6e6c --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md @@ -0,0 +1,64 @@ +# Follow-up RCA: native keyboard still fails (#35) + +Status: candidate only; not a verified native-phone fix. Production is unchanged +from PR #36. Upstream PR #746 is draft pending this investigation. + +## Confirmed gaps in the first fix + +The shell-only checks proved DOM focus but did not prove the software keyboard +opened. They omitted non-terminal views, selection guards, input mode and user +interaction during a slow load. + +The full-page matrix used three isolated instances: original baseline c0347106, +deployed fix 57e7c5cd, and a candidate derived from the deployed fix. Each instance +used two disposable shell sessions on separate data directories/tmux sockets. The +candidate source stayed unchanged during its frozen smoke run. + +1. **Webview return:** the deployed version's first focus targets a hidden + (`display:none`) terminal. That focus is rejected. Focus only succeeds after + the network await, outside the original synchronous gesture path. +2. **Selection guard:** a lingering touch-selection guard blurs both deployed + focus attempts; document focus ends on body rather than terminal input. +3. **Regression introduced by the first fix:** with keyboard-hidden selection + now requesting focus, the post-load mobile refocus can steal focus from another + input the user intentionally tapped while the session was loading. The old + baseline leaves that input alone. + +The candidate reveals the terminal and configures its input mode before focusing +synchronously, retires the previous selection/guard only on an explicit tab pick, +uses the existing mobile focus helper, and avoids late mobile refocus. Automatic +restore stays passive with the keyboard hidden. Desktop's late focus is unchanged. + +## Verification and limits + +- Full configured mobile keyboard/tab suites: deployed **76 passed, 12 failed**; + baseline **75 passed, 13 failed** (88 cases each). All 12 deployed failures also + occur on the baseline. The additional baseline failure is the old touch-focus + test's stale `preserveKeyboard` expectation. These suites are not fully green. +- Candidate: **172 related tests and 9 focused browser checks passed**; typecheck, + lint, formatting, lockfile and frontend syntax checks passed. +- Seven candidate full-page Chromium matrix assertions passed: hidden keyboard, + same tab, webview return, CJK input, selection guard, stale helper coordinates, + and deliberate focus elsewhere during a delayed load. No page errors. +- `chromium-matrix.json` includes raw baseline/deployed/candidate focus observations. + Focus state and first-attempt geometry are tested; native keyboard visibility is + explicitly not claimed. +- WebKit binaries and temporary dependency libraries were prepared without changing + system packages. The engine launched, but timed out creating a page. Therefore + no WebKit browser matrix passed and no iOS keyboard behavior has been verified. +- Claude authentication reports not logged in. Codex/shell smoke was run separately; + this does not establish live Claude coverage or remote-host keyboard behavior. + +Raw run logs are retained under `/tmp/codeman-35-*`. The GitHub issue records final +smoke outcomes and the candidate commit. A phone/browser identification and a +real-device reproduction are still needed before declaring the keyboard fixed. + +## Fresh frozen smoke comparison + +The before-fix baseline smoke passed **86**, failed **0**, skipped **1**. The +frozen deployed version and candidate each passed **84**, failed **2**, skipped +**1**. Their failing assertions are long-scrollback wheel navigation (return to +late history and reveal text before a divider). Historical baseline runs also +showed these failures, but the fresh baseline was green. Their cause is therefore +not established by this comparison; they must not be dismissed as unrelated. +The broader smoke gate remains red and the candidate is not deployed. diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/chromium-matrix.json b/docs/reports/mobile-tab-keyboard/lifecycle-rca/chromium-matrix.json new file mode 100644 index 000000000..0aeef52fd --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/chromium-matrix.json @@ -0,0 +1,400 @@ +[ + { + "label": "baseline", + "scenario": "closed-keyboard", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "same-tab", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "webview-return", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "cjk-input", + "focus": "app-loaded device-mobile touch-device cjk-input-visible", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "selection-guard", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "stale-helper-offscreen", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "scenario": "intentional-focus-during-load", + "focus": "rca-other-field", + "terminalFocused": false, + "cjkFocused": false, + "trace": [], + "selectionGuard": false + }, + { + "label": "baseline", + "pageErrors": [], + "nativeKeyboardVerified": false + }, + { + "label": "deployed", + "scenario": "closed-keyboard", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + }, + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "same-tab", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "webview-return", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": false, + "visible": false, + "top": 0, + "width": 0, + "height": 0, + "activation": true + }, + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "cjk-input", + "focus": "cjkInput", + "terminalFocused": false, + "cjkFocused": true, + "trace": [ + { + "target": "cjkInput", + "accepted": true, + "visible": true, + "top": 775.609375, + "width": 393, + "height": 36.390625, + "activation": true + }, + { + "target": "cjkInput", + "accepted": true, + "visible": true, + "top": 775.609375, + "width": 393, + "height": 36.390625, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "selection-guard", + "focus": "app-loaded device-mobile touch-device", + "terminalFocused": false, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": false, + "visible": true, + "top": 95, + "width": 1, + "height": 1, + "activation": true + }, + { + "target": "xterm-helper-textarea", + "accepted": false, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "stale-helper-offscreen", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + }, + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "scenario": "intentional-focus-during-load", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + }, + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "deployed", + "pageErrors": [], + "nativeKeyboardVerified": false + }, + { + "label": "candidate", + "scenario": "closed-keyboard", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "same-tab", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "webview-return", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "cjk-input", + "focus": "cjkInput", + "terminalFocused": false, + "cjkFocused": true, + "trace": [ + { + "target": "cjkInput", + "accepted": true, + "visible": true, + "top": 775.609375, + "width": 393, + "height": 36.390625, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "selection-guard", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 95, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "stale-helper-offscreen", + "focus": "xterm-helper-textarea", + "terminalFocused": true, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "scenario": "intentional-focus-during-load", + "focus": "rca-other-field", + "terminalFocused": false, + "cjkFocused": false, + "trace": [ + { + "target": "xterm-helper-textarea", + "accepted": true, + "visible": true, + "top": 50, + "width": 1, + "height": 1, + "activation": true + } + ], + "selectionGuard": false + }, + { + "label": "candidate", + "pageErrors": [], + "nativeKeyboardVerified": false + } +] \ No newline at end of file diff --git a/src/web/public/app.js b/src/web/public/app.js index 0458d90a6..2e8e9d13e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -11627,8 +11627,18 @@ class CodemanApp { // fails toward acknowledging rather than toward an alert that can never be // cleared. const userInitiated = options?.auto !== true; - const focusTerminalWithoutActivation = () => { - const focusTerminal = () => this.terminal.focus(); + const touchDevice = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice(); + const focusTerminalWithoutActivation = (fromGesture = false) => { + const focusTerminal = () => { + if (touchDevice && fromGesture) { + // An explicit tab choice retires the previous terminal's selection. + // Otherwise its focus guard immediately blurs the new input target. + this._clearTouchSelection?.(); + this._touchSelectionFocusGuardUntil = 0; + if (this._focusMobileTerminalInput) this._focusMobileTerminalInput(); + else this.terminal.focus(); + } else this.terminal.focus(); + }; if (this._terminalViewerActivity?.runWithoutActivation) { this._terminalViewerActivity.runWithoutActivation(focusTerminal); } else { @@ -11650,7 +11660,7 @@ class CodemanApp { this.applyTerminalFontPolicy?.(this.sessions.get(sessionId)?.mode); try { this.syncTerminalGeometry?.(); } catch {} this._terminalViewerActivity?.activate('session-select'); - if (this.terminal && this._shouldFocusTerminalForTabSwitch(options)) focusTerminalWithoutActivation(); + if (this.terminal && this._shouldFocusTerminalForTabSwitch(options)) focusTerminalWithoutActivation(true); } return; } @@ -11698,10 +11708,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) { - focusTerminalWithoutActivation(); - } - const _selStart = performance.now(); const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); _crashDiag.log(`SELECT: ${_selName}`); @@ -11751,6 +11757,10 @@ class CodemanApp { // typing on the ordered WS path instead of briefly accumulating offline. this._connectWs(sessionId); this.hideWelcome(); + // Reveal the terminal and update its input mode BEFORE spending the tap's + // synchronous focus. A display:none webview terminal rejects focus; doing + // it again after the network await cannot summon the native mobile keyboard. + if (shouldFocusTerminal && this.terminal) focusTerminalWithoutActivation(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. @@ -12305,7 +12315,9 @@ class CodemanApp { idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); _crashDiag.log('FOCUS'); - if (shouldFocusTerminal && this.terminal) focusTerminalWithoutActivation(); + // Mobile input was focused synchronously while visible. Do not steal it + // back after a slow restore if the user has since tapped another control. + if (shouldFocusTerminal && this.terminal && !touchDevice) focusTerminalWithoutActivation(); 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/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 8150865e0..7832774e5 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -36,8 +36,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 +82,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 { From 1794975b62341eff1d0b858042337403fa425200 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 31 Oct 2026 21:24:10 +0400 Subject: [PATCH 02/15] docs: record requested mobile candidate deployment and complete test results (#35) --- .../mobile-tab-keyboard/lifecycle-rca/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md index dc00d6e6c..17e84fe2d 100644 --- a/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md @@ -1,7 +1,9 @@ # Follow-up RCA: native keyboard still fails (#35) -Status: candidate only; not a verified native-phone fix. Production is unchanged -from PR #36. Upstream PR #746 is draft pending this investigation. +Status: the candidate frontend was deployed for phone testing after the user's +explicit deployment request. Native-phone keyboard behavior remains unverified. +Backend artifacts and unrelated checkout work were preserved. Upstream PR #746 +and fork PR #37 remain draft pending this investigation. ## Confirmed gaps in the first fix @@ -32,7 +34,8 @@ restore stays passive with the keyboard hidden. Desktop's late focus is unchange ## Verification and limits - Full configured mobile keyboard/tab suites: deployed **76 passed, 12 failed**; - baseline **75 passed, 13 failed** (88 cases each). All 12 deployed failures also + baseline **75 passed, 13 failed**; candidate **76 passed, 12 failed** (88 cases + each). All 12 deployed and candidate failures also occur on the baseline. The additional baseline failure is the old touch-focus test's stale `preserveKeyboard` expectation. These suites are not fully green. - Candidate: **172 related tests and 9 focused browser checks passed**; typecheck, @@ -61,4 +64,6 @@ frozen deployed version and candidate each passed **84**, failed **2**, skipped late history and reveal text before a divider). Historical baseline runs also showed these failures, but the fresh baseline was green. Their cause is therefore not established by this comparison; they must not be dismissed as unrelated. -The broader smoke gate remains red and the candidate is not deployed. +The broader smoke gate remains red. The candidate frontend test deployment was +explicitly requested after these failures were reported; this is not an all-green +release or a verified native-keyboard completion. From 92e8dc7daf5fbb7e7615164b4a9a8b2e1f6d4cf2 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 31 Oct 2026 21:45:46 +0400 Subject: [PATCH 03/15] fix: reconcile stale keyboard visibility before terminal focus (#35) --- .../lifecycle-rca/README.md | 19 ++++++ .../lifecycle-rca/header-menu-focus.json | 32 ++++++++++ src/web/public/terminal-ui.js | 21 ++++++ .../mobile-tab-keyboard-focus.browser.test.ts | 64 +++++++++++++++++++ test/mobile/keyboard.test.ts | 20 ++++-- 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/header-menu-focus.json diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md index 17e84fe2d..0be982790 100644 --- a/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md @@ -67,3 +67,22 @@ not established by this comparison; they must not be dismissed as unrelated. The broader smoke gate remains red. The candidate frontend test deployment was explicitly requested after these failures were reported; this is not an all-green release or a verified native-keyboard completion. + +## Header-menu clue: cached keyboard state without native focus transition + +The reporter identified the top-right header menu as a reliable workaround. +A fresh full-page test against production reproduced the state transition using +only disposable shells: with a missed keyboard-hide resize, the terminal retains +focus and `keyboardVisible` stays true. Direct focus yields zero new focus events; +tapping the actual header menu first yields one fresh focus event on retry. + +The new candidate rechecks the current same-width visual viewport against the +existing resize detector's hide thresholds before trusting the visible flag. It +uses the canonical resize handler to repair a stale flag, then the existing +mobile focus helper can perform a synchronous blur/refocus. A genuinely shrunken +keyboard viewport is left alone (no blur/refocus bounce). + +The full-page candidate produced one fresh direct focus event without the menu; +the menu path also remained valid. This proves the stale state/focus transition, +not native OS keyboard presentation. Bounded KEYBOARD_RECHECK and KEYBOARD_FOCUS +breadcrumbs capture flag/viewport/focus state without typed text for phone RCA. diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/header-menu-focus.json b/docs/reports/mobile-tab-keyboard/lifecycle-rca/header-menu-focus.json new file mode 100644 index 000000000..1ed3418a9 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/header-menu-focus.json @@ -0,0 +1,32 @@ +[ + { + "label": "deployed", + "direct": { + "freshFocusEvents": 0, + "keyboardVisible": true, + "focused": true + }, + "afterMenu": { + "freshFocusEvents": 1, + "focused": true, + "expanded": "true" + }, + "errors": [], + "nativeKeyboardVerified": false + }, + { + "label": "candidate", + "direct": { + "freshFocusEvents": 1, + "keyboardVisible": false, + "focused": true + }, + "afterMenu": { + "freshFocusEvents": 1, + "focused": true, + "expanded": "true" + }, + "errors": [], + "nativeKeyboardVerified": false + } +] \ No newline at end of file diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 12a692dfa..8fd43cb76 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7538,6 +7538,10 @@ Object.assign(CodemanApp.prototype, { } else { this.terminal?.focus(); } + 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} height=${Math.round(window.visualViewport?.height || 0)} baseline=${Math.round(handler?.initialViewportHeight || 0)} min=${Math.round(handler?._keyboardMinHeight || 0)}`); + } }, /** @@ -7550,6 +7554,23 @@ Object.assign(CodemanApp.prototype, { 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; }, diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 7832774e5..81982e28d 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -187,3 +187,67 @@ describe('mobile sheet delayed search focus', () => { } }); }); + +describe('header-menu workaround and stale keyboard 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={${method('mobile-handlers.js', 'handleViewportResize')}, + keyboardVisible:true,initialViewportHeight:visualViewport.height, + _keyboardMinHeight:visualViewport.height-300,lastViewportWidth:visualViewport.width, + KEYBOARD_GONE_GROWTH_PX:150,_releaseHeldRows(){return false},onKeyboardHide(){hideEvents++}, + onKeyboardShow(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; + window.app={${['_mobileKeyboardIsUp', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, + ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){}, + terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; + document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); + app.terminal.focus(); + document.getElementById('terminal').addEventListener('focus',()=>focusEvents++); + `, + }); + return page; + } + it('creates a fresh focus without requiring the header-menu detour 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(); + expect(await page.locator('#menu').getAttribute('aria-expanded')).toBe('true'); + await page.evaluate('focusEvents=0;app._focusMobileTerminalInput()'); + expect(await page.evaluate('focusEvents')).toBe(1); + } 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._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 ab1d2d1c3..ca4d9e248 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -1160,10 +1160,20 @@ describe('Virtual Keyboard', () => { ) ); app.terminal.focus(); - // COD-467: "keyboard up" is focus AND a visible keyboard. Headless Chromium - // has no on-screen keyboard, so say so explicitly; focus alone is now the - // stale-focus case (keyboard gone, textarea still focused), where a tap - // must REOPEN the keyboard rather than run this close toggle. + // Model the visible keyboard's viewport as well as its cached flag. + // A true flag with a full-height viewport is the missed-hide-resize + // regression, not this second-tap-to-dismiss scenario. + const viewport = window.visualViewport!; + const originalHeight = Object.getOwnPropertyDescriptor(viewport, 'height'); + const fullHeight = viewport.height; + Object.defineProperty(viewport, 'height', { configurable: true, value: fullHeight - 300 }); + (window as any).__restoreKeyboardViewport35 = () => { + if (originalHeight) Object.defineProperty(viewport, 'height', originalHeight); + 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'); @@ -1196,6 +1206,8 @@ describe('Virtual Keyboard', () => { // dismiss. The prompt row is unaffected and still positions the caret. const activeClass = await page.evaluate(() => { const cls = document.activeElement?.className; + (window as any).__restoreKeyboardViewport35?.(); + delete (window as any).__restoreKeyboardViewport35; KeyboardHandler.keyboardVisible = false; // never leak into the next test return cls; }); From 2ded578a13e1b441a4c2a387c8a2655e78527187 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sat, 31 Oct 2026 22:03:23 +0400 Subject: [PATCH 04/15] fix(mobile): release terminal focus after keyboard dismiss (#35) --- .../lifecycle-rca/repeated-dismiss.md | 7 ++++ src/web/public/mobile-handlers.js | 8 ++++ .../mobile-tab-keyboard-focus.browser.test.ts | 37 +++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/repeated-dismiss.md diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/repeated-dismiss.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/repeated-dismiss.md new file mode 100644 index 000000000..67e9b677f --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/repeated-dismiss.md @@ -0,0 +1,7 @@ +# Repeated keyboard dismiss and reopen (#35) + +The previous repair refocused a retained terminal textarea inside one tap. Phone breadcrumbs still showed trusted, focused input with a keyboard-free viewport after repeated attempts. A native keyboard dismiss does not necessarily blur the textarea. + +The hide transition now blurs only terminal-owned input. The next deliberate tap starts from an unfocused input, instead of depending on a blur/focus pair within that tap. An unrelated editor retains focus. No timer refocus or keyboard-opening retry runs in the background. + +Validation: Chromium regression exercises 25 show/dismiss/reopen cycles through the actual viewport detector and hide handler. A separate full-page isolated session exercises 12 cycles (36 states), with no input outside the visual viewport and no focus-state mismatch. These are focus and viewport simulations, not native OS keyboard verification. Native phone confirmation remains required; issue #35 and PR #37 stay open. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index d8c11581c..b488e489d 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -685,6 +685,14 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { + // A native dismiss can leave the helper textarea focused. Release it on + // the hide transition, rather than blur/refocus within the next tap: mobile + // browsers can coalesce that pair and leave the keyboard closed (#35). + // Do not disturb another input the user has selected in the meantime. + if (typeof app !== 'undefined' && app._isMobileTerminalInputFocused?.()) { + app._blurMobileTerminalInput(); + } + // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.hide(); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 81982e28d..017132089 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -205,13 +205,13 @@ describe('header-menu workaround and stale keyboard visibility', () => { content: ` window.MobileDetection={isTouchDevice:()=>true,updateAppHeight(){}}; window.hideEvents=0;window.focusEvents=0; - window.KeyboardHandler={${method('mobile-handlers.js', 'handleViewportResize')}, + window.KeyboardHandler={${method('mobile-handlers.js', 'handleViewportResize')},${method('mobile-handlers.js', 'onKeyboardHide')}, keyboardVisible:true,initialViewportHeight:visualViewport.height, _keyboardMinHeight:visualViewport.height-300,lastViewportWidth:visualViewport.width, - KEYBOARD_GONE_GROWTH_PX:150,_releaseHeldRows(){return false},onKeyboardHide(){hideEvents++}, + KEYBOARD_GONE_GROWTH_PX:150,_releaseHeldRows(){return false},resetLayout(){hideEvents++},_scheduleViewportSettle(){}, onKeyboardShow(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; window.app={${['_mobileKeyboardIsUp', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, - ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){}, + ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); app.terminal.focus(); @@ -236,6 +236,37 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.close(); } }); + it('releases retained terminal focus after each of 25 native dismiss cycles', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + window.cycleHeight=visualViewport.height; + window.fullHeight=cycleHeight; + Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight}); + window.cycleResults=[]; + 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'); + cycleHeight=fullHeight;KeyboardHandler.handleViewportResize(); + `); + expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false); + expect(await page.evaluate(() => document.activeElement?.id)).not.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('does not blur/refocus an actually shrunken keyboard viewport', async () => { const page = await keyboardPage(); try { From 22ac4b77cf39e9ddc7d41637ba98fb33af669b24 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 02:00:34 +0400 Subject: [PATCH 05/15] docs: record mobile keyboard lifecycle races (#35) --- .../lifecycle-rca/deep-analysis-2026-11-01.md | 64 +++++++++++++++++++ .../keyboard-lifecycle-repro.json | 40 ++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/deep-analysis-2026-11-01.md create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/keyboard-lifecycle-repro.json diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/deep-analysis-2026-11-01.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/deep-analysis-2026-11-01.md new file mode 100644 index 000000000..487df6704 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/deep-analysis-2026-11-01.md @@ -0,0 +1,64 @@ +# Mobile keyboard reliability: deeper RCA, 2026-11-01 + +Scope: analysis of deployed frontend candidate `2ded578a`, issue #35 / draft PR #37. No production source or behavior was changed during this investigation. + +## Result + +Two deterministic lifecycle defects are reproduced. The latest automatic blur-on-hide change can cancel a fresh reopen request during the preceding close animation. Separately, foreground recovery can replace the keyboard-free baseline with a still-shrunken visual viewport. Neither finding alone proves the cause of every native keyboard refusal reported on the phone. + +## Fresh production evidence + +The server serves `mobile-handlers.90de8d54.js` and `terminal-ui.f6f7b9f3.js`. The most recent retained phone page contains 12 KEYBOARD_FOCUS breadcrumbs: 11 show focused=true, gesture=true, cached=false, viewport=763, baseline=763; one shows viewport=449, baseline=449, minimum=0. This confirms that successful DOM focus and a recorded user activation did not establish keyboard visibility, and that keyboard-sized geometry can become the hidden baseline. + +The page breadcrumbs do not identify the loaded asset hash, phone/browser, focus/blur source, or the subsequent viewport timeline. Server assets are verified; the particular phone page's build cannot be proven from this payload. A narrow CSS/screenshot inference cannot supply the missing device identity. No typed text was needed for this analysis. + +## Reproduction A: old close cancels a new open + +1. Keyboard is detected open: visual height 420, stable baseline 750, terminal focused. +2. User dismisses it, then refocuses the input before the close animation finishes. +3. The old closing resize reports height 750. +4. `handleViewportResize()` calls `onKeyboardHide()`. +5. The new unconditional terminal blur inside `onKeyboardHide()` clears the fresh focus. + +The actual deployed handler in a VM reproduces focused=true -> focused=false, with one blur caused by the old close transition. This is a code-level race, not a native keyboard test. It was introduced by the latest follow-up. A resize describes geometry; it does not identify which focus request it belongs to. + +## Reproduction B: foreground recovery corrupts calibration + +1. Start with baseline 750, visual height 420, keyboard=true. +2. Return to the page before a closing viewport resize has arrived. +3. The visibility listener blurs terminal input and calls `forceKeyboardHidden()`. +4. That method sets baseline=420 and keyboard=false. +5. A subsequent focus/resize at height 420 computes no shrink; keyboard remains false. + +The actual handler reproduces this transition. The phone's height=449/baseline=449/minimum=0 is consistent with it, but the existing breadcrumbs cannot prove that exact event ordering on the device. Initialization while already shrunken has the same calibration risk. + +## Why previous checks missed it + +The 25-cycle regression serializes open -> complete hide -> next focus. The full-page probe also supplies an idealized shrink/restore sequence. Neither tests reopening before the previous animation completes. Chromium emulation and mocked visualViewport height do not display an OS keyboard. Several unit descriptions incorrectly imply blur/focus guarantees WebKit keyboard opening; their assertions only check method calls. + +The helper-position probe used a disposable shell and did not reproduce an offscreen field. That excludes only that fixture; it does not establish geometry for a real Codex/Claude screen on the phone. + +Current GitHub CI is also not green: the unit/integration job reports `test/opencode-mobile-font.test.ts:910`, where the new hide-handler focus side effect reaches `_blurMobileTerminalInput` through an incomplete fixture. Static checks passed. This does not prove a native keyboard cause, but shows the coupling reaches beyond the targeted keyboard suite. + +## Repair direction + +- Separate focus intent from geometry. Keyboard-hide layout processing must not indiscriminately blur a newer open request. Explicit dismiss owns blur; passive resize owns layout. If focus release remains necessary for native dismissal, it needs a request generation and transition ownership check. +- Preserve keyboard-free calibration across background/foreground transitions. Mark visibility uncertain while geometry settles; do not overwrite the baseline with the current shrunken height merely because focus changed. Reconcile against observed restoration and appropriate layout-viewport evidence for the supported browser. +- Route explicit terminal/tab keyboard requests through one synchronous gesture path. Avoid timer-based reopen loops and late focus after network loads. +- Add content-free event tracing: build/browser identity, gesture source and request generation, focused input identity, blur reason, visibility, visual viewport height/width/offset/scale, layout height, and observations immediately / 100 / 350 / 700 ms after the request. Diagnostic timers may observe; they must not steal focus. +- Do not add a separate mobile composer or replace xterm input on the basis of these traces alone. First compare direct native textarea focus with routed xterm focus on the actual device. + +## Acceptance matrix + +Test rapid close/reopen before, during and after the closing resize; repeated same-tab taps; tab switching with slow remote loading; app background/foreground with delayed resize; existing keyboard on reload; header-menu detour; selection guards; CJK input; another editor focused during a late resize; native dismiss and accessory dismiss. Repeat at least 30 cycles on the reporter's real phone/browser. Assert the visible native keyboard and delivered input as well as DOM focus. Keep local/remote sessions and Claude/Codex separate from the browser-level keyboard result. + +No new fix or deployment is claimed by this report. Issue #35 should remain open and PR #37 draft until native device validation. + +## Primary references + +- WebKit distinguishes programmatic DOM focus from software keyboard presentation, including embedding-client exceptions: https://bugs.webkit.org/show_bug.cgi?id=195884 +- Chromium explains visual/layout viewport differences and keyboard resize behavior: https://developer.chrome.com/blog/viewport-resize-behavior + +## Evidence + +`keyboard-lifecycle-repro.json` contains both deterministic handler replays. Temporary runnable probe: `/tmp/codeman-35-deep-baseline.cjs`; it loads the actual handler through the existing test fixture. Raw production diagnostic snapshot: `/tmp/codeman-35-deep-diag.json` (kept outside git). These are code/state reproductions; nativeKeyboardVerified=false. diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/keyboard-lifecycle-repro.json b/docs/reports/mobile-tab-keyboard/lifecycle-rca/keyboard-lifecycle-repro.json new file mode 100644 index 000000000..8d7bafde8 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/keyboard-lifecycle-repro.json @@ -0,0 +1,40 @@ +{ + "states": [ + { + "label": "keyboard visible", + "height": 420, + "baseline": 750, + "keyboard": true, + "focused": true, + "blurs": 0 + }, + { + "label": "return before closing resize", + "height": 420, + "baseline": 420, + "keyboard": false, + "focused": false, + "blurs": 1 + }, + { + "label": "new focus while viewport remains keyboard sized", + "height": 420, + "baseline": 420, + "keyboard": false, + "focused": true, + "blurs": 1 + } + ], + "rapidReopen": { + "beforeOldClose": { + "focused": true, + "keyboard": true + }, + "afterOldClose": { + "focused": false, + "keyboard": false, + "blurs": 1 + } + }, + "nativeKeyboardVerified": false +} From a7a676a970d6d2a8bbd2d778879e367adef1318c Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 02:23:52 +0400 Subject: [PATCH 06/15] fix(mobile): preserve keyboard focus and calibration through lifecycle races (#35) --- .../lifecycle-rca/lifecycle-v4-fix.md | 22 ++++++ src/web/public/app.js | 2 +- src/web/public/mobile-handlers.js | 42 +++++++--- src/web/public/terminal-ui.js | 11 ++- test/mobile-keyboard-stale-state.test.ts | 72 ++++++++++++++++- .../mobile-tab-keyboard-focus.browser.test.ts | 79 ++++++++++++++++--- 6 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/lifecycle-v4-fix.md diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/lifecycle-v4-fix.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/lifecycle-v4-fix.md new file mode 100644 index 000000000..8a4e8d9d7 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/lifecycle-v4-fix.md @@ -0,0 +1,22 @@ +# Keyboard lifecycle correction (#35) + +The previous blur-on-hide follow-up is removed. Passive viewport hide processing now updates layout without blurring input, so a closing resize from an earlier dismiss cannot cancel a newer reopen. Explicit dismiss, foreground stale-input release, and deliberate gesture focus retain their own responsibilities. + +`forceKeyboardHidden()` no longer recalibrates the keyboard-free height from the current visual viewport. A focus-out fallback or foreground event can occur while that viewport is still shrunken; preserving the stable baseline allows a new open at the same smaller height to be detected. Actual restored viewport and display-shape events continue to update calibration normally. + +Content-free v4 diagnostics record browser/build identity, focus/blur and viewport transitions, and observations 350/1000 ms after a focus request. Observation callbacks never focus, blur, or type, and superseded requests stop logging delayed observations. No prompt or typed text is recorded. + +Validation: + +- Six new regression cases failed on the prior code, then passed: reopen at 0/80/200/349 ms before the old close resize, foreground recovery before restoration, and focus-out fallback before restoration. +- 211 related unit tests passed, including the OpenCode mobile font test that failed in the preceding PR revision. +- 14 browser regressions passed, including 25 real browser gesture cycles with overlapping close/reopen, the actual foreground listener, delayed observation preserving another editor, selection guard, hidden webview return, and no delayed focus stealing after slow session loads. +- Full mobile keyboard suite: 56 passed. +- Separate full-page disposable shell probe: 30 rapid reopen cycles using actual touch taps, 30 commands delivered and executed, zero browser page errors. The marker was produced by printf formatting, rather than matching echoed command text. The session and isolated server were cleaned up. +- Typecheck, lint, formatting, lockfile, frontend syntax and isolated build passed. + +The full unit/integration sweep and fresh realistic smoke results are recorded in issue #35 / PR #37 after completion. The smoke runner was corrected to use the requested wheel direction in its fallback and to permit enough steps to traverse the retained 10,000-line replay; the prior 120-step limit stopped partway through valid scrolling. The assertions remain intact. First corrected run passed the replay checks but failed a live repaint check; a fresh rerun is required before calling the broad gate green. + +A real phone OS keyboard is not simulated by Chromium or visualViewport mocks. These tests establish the corrected event ordering, focus preservation, state classification and delivered input, not native keyboard presentation. Actual phone confirmation remains necessary before closing #35. + +Temporary evidence: `/tmp/codeman-35-lifecycle-red.log`, `/tmp/codeman-35-lifecycle-green.log`, `/tmp/codeman-35-lifecycle-v4-browser.log`, `/tmp/codeman-35-v4-mobile.log`, `/tmp/codeman-35-v4-delivery.json`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 2e8e9d13e..7b1b14db5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -11635,7 +11635,7 @@ class CodemanApp { // Otherwise its focus guard immediately blurs the new input target. this._clearTouchSelection?.(); this._touchSelectionFocusGuardUntil = 0; - if (this._focusMobileTerminalInput) this._focusMobileTerminalInput(); + if (this._focusMobileTerminalInput) this._focusMobileTerminalInput('session-tab'); else this.terminal.focus(); } else this.terminal.focus(); }; diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index b488e489d..f3098ad2c 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -309,9 +309,17 @@ const KeyboardHandler = { // swallows a real keyboard. this.lastViewportWidth = window.visualViewport?.width || window.innerWidth; + this._traceKeyboardState('init'); + if (typeof _crashDiag !== 'undefined') { + _crashDiag.log( + `KEYBOARD_BUILD: lifecycle-v4 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 @@ -327,7 +335,8 @@ const KeyboardHandler = { // 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 = () => { + this._focusoutHandler = (e) => { + if (this._isTerminalInput(e?.target)) this._traceKeyboardState('focusout-terminal'); clearTimeout(this._focusSettleTimer); this._focusSettleTimer = setTimeout(() => { this._focusSettleTimer = null; @@ -347,6 +356,7 @@ const KeyboardHandler = { const active = document.activeElement; if (this._isTerminalInput(active)) active.blur?.(); this.forceKeyboardHidden(); + this._traceKeyboardState('foreground'); }; document.addEventListener('visibilitychange', this._visibilityHandler); @@ -457,6 +467,7 @@ const KeyboardHandler = { // xterm's bottom row and cursor sit above the OS keyboard. document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); 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, @@ -476,6 +487,7 @@ const KeyboardHandler = { 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(); @@ -685,13 +697,9 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { - // A native dismiss can leave the helper textarea focused. Release it on - // the hide transition, rather than blur/refocus within the next tap: mobile - // browsers can coalesce that pair and leave the keyboard closed (#35). - // Do not disturb another input the user has selected in the meantime. - if (typeof app !== 'undefined' && app._isMobileTerminalInputFocused?.()) { - app._blurMobileTerminalInput(); - } + // A delayed closing resize can belong to an earlier dismiss, after the + // user has already requested a new open. Geometry must never blur input; + // explicit dismissal and stale-focus recovery own that decision (#35). // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { @@ -884,20 +892,32 @@ const KeyboardHandler = { /** Check if element is an input that triggers keyboard (excludes terminal) */ /** * COD-467: leave keyboard mode NOW, whatever the resize detector last saw. - * The baseline becomes the current height, so if the keyboard is still - * animating closed the next resize only grows past it (no false re-show). + * Keep the last keyboard-free baseline: a focus or visibility event can + * arrive before the closing viewport animation. Using that shrunken height + * as the baseline makes the next open undetectable (#35). */ forceKeyboardHidden() { if (!this.keyboardVisible) return; this.keyboardVisible = false; this._keyboardMinHeight = 0; document.body.classList.remove('keyboard-visible'); - this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.onKeyboardHide(); + this._traceKeyboardState('force-hidden'); MobileDetection.updateAppHeight(); this.updateLayoutForKeyboard(); }, + /** Content-free observations only: never focus or blur from a diagnostic. */ + _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'; + _crashDiag.log( + `KEYBOARD_STATE: v4 ${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)} page=${document.visibilityState}` + ); + }, + /** The terminal's own text inputs (xterm's helper textarea, the CJK textarea). */ _isTerminalInput(el) { return !!el && (el.classList?.contains('xterm-helper-textarea') || el.id === 'cjkInput'); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 8fd43cb76..0d60e8aa3 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7523,7 +7523,7 @@ Object.assign(CodemanApp.prototype, { return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine); }, - _focusMobileTerminalInput() { + _focusMobileTerminalInput(source = 'terminal-tap') { this._syncMobileHelperTextareaToCursor(); // COD-467: WebKit ignores focus() on the element that already has focus, so // a textarea that kept focus after the keyboard went away (app/tab switch, @@ -7541,6 +7541,15 @@ Object.assign(CodemanApp.prototype, { 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} 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); + } } }, diff --git a/test/mobile-keyboard-stale-state.test.ts b/test/mobile-keyboard-stale-state.test.ts index d59225b58..61dec5376 100644 --- a/test/mobile-keyboard-stale-state.test.ts +++ b/test/mobile-keyboard-stale-state.test.ts @@ -84,7 +84,11 @@ function loadKeyboardHandler() { }; const context = vm.createContext({ console, - app: { relayoutMobileSubagentWindows: () => {} }, + app: { + relayoutMobileSubagentWindows: () => {}, + _isMobileTerminalInputFocused: () => !!doc.activeElement?.classList.contains('xterm-helper-textarea'), + _blurMobileTerminalInput: () => doc.activeElement?.blur(), + }, navigator: { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/130 Mobile/15E148', maxTouchPoints: 5, @@ -227,6 +231,70 @@ describe('COD-467 stale FOCUS: coming back to the page releases the hidden texta }); }); +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 } = loadKeyboardHandler(); + const blur = vi.fn(() => { + doc.activeElement = null; + }); + const input = terminalTextarea(blur); + doc.activeElement = input; + resize(FULL - KEYBOARD, FULL); + // Explicit dismiss, then a new focus before the old close animation ends. + input.blur(); + fire('focusout'); + advance(delay); + doc.activeElement = 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('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) {}; @@ -281,7 +349,7 @@ describe('COD-467 stale FOCUS: a tap opens the keyboard when the textarea is foc expect(loadTapApp({ keyboardVisible: true }).app._mobileKeyboardIsUp()).toBe(true); }); - it('prompt-row tap: re-focus is blur THEN focus, so WebKit shows the keyboard', () => { + 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']); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 017132089..87acb2c33 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); @@ -205,11 +213,11 @@ describe('header-menu workaround and stale keyboard visibility', () => { content: ` window.MobileDetection={isTouchDevice:()=>true,updateAppHeight(){}}; window.hideEvents=0;window.focusEvents=0; - window.KeyboardHandler={${method('mobile-handlers.js', 'handleViewportResize')},${method('mobile-handlers.js', 'onKeyboardHide')}, + window.KeyboardHandler={${['init', 'handleViewportResize', 'onKeyboardHide', '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,_releaseHeldRows(){return false},resetLayout(){hideEvents++},_scheduleViewportSettle(){}, - onKeyboardShow(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; + KEYBOARD_GONE_GROWTH_PX:150,KEYBOARD_FOCUS_SETTLE_MS:350,_releaseHeldRows(){return false},resetLayout(){hideEvents++},_scheduleViewportSettle(){}, + onKeyboardShow(){},_traceKeyboardState(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; window.app={${['_mobileKeyboardIsUp', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; @@ -236,14 +244,14 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.close(); } }); - it('releases retained terminal focus after each of 25 native dismiss cycles', async () => { + 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}); - window.cycleResults=[]; + KeyboardHandler.keyboardVisible=false;KeyboardHandler.init(); document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); `); for (let cycle = 0; cycle < 25; cycle++) { @@ -252,10 +260,14 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.evaluate(` cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize(); if (!KeyboardHandler.keyboardVisible) throw Error('show not detected'); - cycleHeight=fullHeight;KeyboardHandler.handleViewportResize(); + 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)).not.toBe('terminal'); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); } // A hide notification must not take focus from a different editor. await page.evaluate(` @@ -267,6 +279,55 @@ describe('header-menu workaround and stale keyboard visibility', () => { 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('does not blur/refocus an actually shrunken keyboard viewport', async () => { const page = await keyboardPage(); try { From 0a8bf46efc04639890736705e2ade7b93ef1e7b3 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 03:07:05 +0400 Subject: [PATCH 07/15] chore(mobile): trace input clipping around native keyboard and header menu (#35) --- .../lifecycle-rca/menu-native-followup.md | 11 ++++ src/web/public/app.js | 4 ++ src/web/public/mobile-handlers.js | 55 ++++++++++++++++++- .../mobile-tab-keyboard-focus.browser.test.ts | 27 +++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/menu-native-followup.md diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/menu-native-followup.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/menu-native-followup.md new file mode 100644 index 000000000..eafc32611 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/menu-native-followup.md @@ -0,0 +1,11 @@ +# Header-menu workaround persists after v4 (#35) + +The reporter still needs to tap the header menu before the keyboard can open. Fresh production breadcrumbs confirm the current v4 build on iPhone Chrome (CriOS/156, iPhone OS 27.2 UA), not only an older cached page. + +In the latest retained phone trace, request 18 obtains terminal focus from a trusted gesture at height/baseline 763. The input remains focused with a keyboard-free viewport after both 350 ms and 1000 ms. No delayed blur or bad baseline appears in that attempt. A later request 19 is followed by a visual viewport shrink to 449 after about 120 ms. The reporter associates the successful retry with the menu tap; v4 did not record menu events, so its exact menu timestamp cannot be inferred from those logs alone. + +The menu changes header classes and app padding, dispatches window resize, and participates in native focus transfer. Existing evidence does not distinguish layout/clip repair from browser editing-session behavior. The two v4 lifecycle corrections remain valid, but do not explain this remaining native refusal. + +This follow-up adds diagnostic observations only: input rectangle, intersection with the visual viewport and clipping ancestors, active element type, readonly/disabled/input mode, app height, and header menu state. Menu open/close and settled state are explicitly traced. No typed input/value is logged, and no diagnostic changes focus or retries keyboard opening. A browser regression checks clipping detection and verifies private textarea contents are excluded and another editor keeps focus. + +This is not described as a fix for the remaining menu workaround. After deploying this diagnostic candidate, a failed terminal tap followed by menu open/close and another terminal tap is needed on the actual phone. Chromium/viewport simulations cannot establish the native keyboard result. diff --git a/src/web/public/app.js b/src/web/public/app.js index 7b1b14db5..bc1abc2a1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -9751,6 +9751,10 @@ class CodemanApp { appShell.classList.toggle('mobile-header-utilities-expanded', expanded); toggle.setAttribute('aria-expanded', String(expanded)); window.dispatchEvent(new Event('resize')); + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler._traceKeyboardState?.(expanded ? 'header-menu-open' : 'header-menu-close'); + setTimeout(() => KeyboardHandler._traceKeyboardState?.('header-menu-settled'), 350); + } } /** diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f3098ad2c..f35735510 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -312,7 +312,7 @@ const KeyboardHandler = { this._traceKeyboardState('init'); if (typeof _crashDiag !== 'undefined') { _crashDiag.log( - `KEYBOARD_BUILD: lifecycle-v4 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v5 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -913,11 +913,62 @@ const KeyboardHandler = { 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: v4 ${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)} page=${document.visibilityState}` + `KEYBOARD_STATE: v5 ${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)} page=${document.visibilityState}` ); }, + _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: v5 ${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', + readonly: input.readOnly, + disabled: input.disabled, + inputMode: input.inputMode, + appHeight: getComputedStyle(document.documentElement).getPropertyValue('--app-height').trim(), + menu: !!document.querySelector('.header.mobile-utilities-expanded'), + })}` + ); + } catch { + _crashDiag.log(`KEYBOARD_BOX: v5 ${reason} unavailable`); + } + }, + /** The terminal's own text inputs (xterm's helper textarea, the CJK textarea). */ _isTerminalInput(el) { return !!el && (el.classList?.contains('xterm-helper-textarea') || el.id === 'cjkInput'); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 87acb2c33..a78819244 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -328,6 +328,33 @@ describe('header-menu workaround and stale keyboard visibility', () => { 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('does not blur/refocus an actually shrunken keyboard viewport', async () => { const page = await keyboardPage(); try { From c8c8508dbe14dd48fd47c4b59292fbe1fbf758d5 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 03:29:22 +0400 Subject: [PATCH 08/15] fix(mobile): release owned native keyboard focus before reopening (#35) --- .../native-handoff-v6-evidence.json | 31 ++++++++ .../lifecycle-rca/native-handoff-v6.md | 13 +++ src/web/public/mobile-handlers.js | 36 +++++++-- src/web/public/terminal-ui.js | 25 ++++-- test/mobile-keyboard-stale-state.test.ts | 79 ++++++++++++++++--- .../mobile-tab-keyboard-focus.browser.test.ts | 63 ++++++++++++++- 6 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6-evidence.json create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6.md diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6-evidence.json b/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6-evidence.json new file mode 100644 index 000000000..d2a78d5d6 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6-evidence.json @@ -0,0 +1,31 @@ +{ + "nativeKeyboardVerified": false, + "phoneTraceObserved": true, + "comparison": { + "inputRect": [ + 12, + 640, + 13, + 641 + ], + "clip": [ + 0, + 74, + 440, + 723 + ], + "readonly": false, + "disabled": false, + "menuExpanded": true, + "failedRequest": { + "id": 14, + "blurFocusGapMs": 1, + "keyboardVisibleAfter350ms": false + }, + "successfulRequest": { + "id": 15, + "blurFocusGapMs": 108, + "keyboardVisibleAfter350ms": true + } + } +} \ No newline at end of file diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6.md new file mode 100644 index 000000000..eea40a471 --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/native-handoff-v6.md @@ -0,0 +1,13 @@ +# Native editing-focus handoff correction (#35) + +The reporter's v5 phone trace shows failed and successful opens with identical input geometry: rectangle [12,640,13,641], clip [0,74,440,723], editable/not disabled, app height 763, menu open. Request 14 has focusout/focusin about 1 ms apart and no keyboard at 350 ms; request 15 follows a focus release about 108 ms before refocus and the viewport shrinks roughly 102 ms later. Request 12 also retains focus for 1000 ms without opening while onscreen and editable. The reporter separately confirms opening and closing the menu enables reopening. + +This excludes clipping/readonly/bad calibration in those recorded failures. It is consistent with a native editing-focus handoff problem; it does not expose WebKit's internal keyboard policy or prove all failures have one cause. + +The correction releases native-dismissed terminal focus before the next gesture, with ownership protection: a newly observed keyboard shrink records the focus generation and exact input node. Hide releases only that same node if no newer explicit focus request exists. A late closing resize cannot blur a newer request, another input, or a new input whose old shrunken geometry was merely reconciled after foreground recovery. Reconciliation without an observed shrink does not claim ownership. + +A terminal touchstart also releases leftover focused input when the keyboard is already down, before touchend performs the new focus. A visible keyboard is preserved. No timer retries focus. Calibration and late-focus protections from the previous correction remain intact. + +Validation: the native-dismiss ownership regression first failed on v5 and passes with this correction; 214 related tests pass, including all previous race/calibration cases, newer-focus protection at 0/80/200/349 ms, old-geometry reconciliation and a different terminal node receiving native focus. Seventeen browser regressions pass, including real touchstart/touchend separation with the actual compatibility-mouse guard, 20 owned dismiss cycles and 25 overlapping closing transitions. The full mobile keyboard suite passed 56 tests. Realistic smoke passed 86 checks with zero failures and one unconfigured external-remote skip. A separate disposable real shell passed 30 touch reopen/command-execution cycles (normal owned dismiss and late closing transitions), with no browser page errors. Static checks/build are recorded on the issue after completion. + +Chromium and synthetic viewport changes verify event ordering, focus ownership and delivered input, not actual OS keyboard presentation. The deployed candidate still needs the reporter's native phone confirmation. Keep #35 open and #37 draft until that result. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f35735510..69cd1f848 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -312,7 +312,7 @@ const KeyboardHandler = { this._traceKeyboardState('init'); if (typeof _crashDiag !== 'undefined') { _crashDiag.log( - `KEYBOARD_BUILD: lifecycle-v5 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v6 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -466,6 +466,14 @@ 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'); } @@ -697,9 +705,23 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { - // A delayed closing resize can belong to an earlier dismiss, after the - // user has already requested a new open. Geometry must never blur input; - // explicit dismissal and stale-focus recovery own that decision (#35). + // 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; + if ( + Number.isSafeInteger(owner) && + typeof app !== 'undefined' && + owner === app._mobileKeyboardFocusGeneration && + ownerInput === document.activeElement && + app._isMobileTerminalInputFocused?.() + ) { + app._blurMobileTerminalInput(); + this._traceKeyboardState('owned-hide-release'); + } // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { @@ -915,7 +937,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v5 ${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)} page=${document.visibilityState}` + `KEYBOARD_STATE: v6 ${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} page=${document.visibilityState}` ); }, @@ -945,7 +967,7 @@ const KeyboardHandler = { } const rounded = (value) => Math.round(value); _crashDiag.log( - `KEYBOARD_BOX: v5 ${reason} ${JSON.stringify({ + `KEYBOARD_BOX: v6 ${reason} ${JSON.stringify({ rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded), clip: [left, top, right, bottom].map(rounded), intersects: @@ -965,7 +987,7 @@ const KeyboardHandler = { })}` ); } catch { - _crashDiag.log(`KEYBOARD_BOX: v5 ${reason} unavailable`); + _crashDiag.log(`KEYBOARD_BOX: v6 ${reason} unavailable`); } }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0d60e8aa3..5156b6299 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1647,6 +1647,9 @@ Object.assign(CodemanApp.prototype, { // made the next transcript tap run the tap-to-CLOSE toggle on a // keyboard that was not there, so it never opened. tapStartedWithTerminalFocus = this._mobileKeyboardIsUp(); + // A dismissed keyboard can leave native editing focus behind. Release + // it at gesture start, before touchend spends this tap on reopening. + 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 @@ -7523,14 +7526,21 @@ Object.assign(CodemanApp.prototype, { return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine); }, + _prepareMobileTerminalFocusGesture() { + if (!this._isMobileTerminalInputFocused() || this._mobileKeyboardIsUp()) return false; + this._blurMobileTerminalInput(); + if (typeof KeyboardHandler !== 'undefined') KeyboardHandler._traceKeyboardState?.('gesture-release'); + return true; + }, + _focusMobileTerminalInput(source = 'terminal-tap') { + // Every explicit focus request supersedes the keyboard's prior owner. Set + // this BEFORE focus/geometry callbacks, including synchronous re-entrancy. + const request = this._mobileKeyboardFocusGeneration = (this._mobileKeyboardFocusGeneration || 0) + 1; this._syncMobileHelperTextareaToCursor(); - // COD-467: WebKit ignores focus() on the element that already has focus, so - // a textarea that kept focus after the keyboard went away (app/tab switch, - // lock) could never bring the keyboard back. Blur first, in the same tap - // handler, so the focus() below is a real focus change inside a user - // gesture, which is what iOS requires to show the keyboard. Only when the - // keyboard is actually down: with it up this would make it bounce. + // Fallback for callers without a terminal touchstart (e.g. tab selection). + // Terminal taps release stale focus in the earlier gesture phase; an OS + // hide normally releases its own focus before any new gesture begins. if (this._isMobileTerminalInputFocused() && !this._mobileKeyboardIsUp()) this._blurMobileTerminalInput(); const cjkInput = document.getElementById('cjkInput'); if (cjkInput?.classList.contains('cjk-input-visible')) { @@ -7542,11 +7552,10 @@ Object.assign(CodemanApp.prototype, { 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} 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; + if (request !== this._mobileKeyboardFocusGeneration || document.visibilityState === 'hidden') return; handler?._traceKeyboardState?.(`request-${request}-after-${delay}ms`); }, delay); } diff --git a/test/mobile-keyboard-stale-state.test.ts b/test/mobile-keyboard-stale-state.test.ts index 61dec5376..1b080c108 100644 --- a/test/mobile-keyboard-stale-state.test.ts +++ b/test/mobile-keyboard-stale-state.test.ts @@ -82,13 +82,15 @@ function loadKeyboardHandler() { 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: { - relayoutMobileSubagentWindows: () => {}, - _isMobileTerminalInputFocused: () => !!doc.activeElement?.classList.contains('xterm-helper-textarea'), - _blurMobileTerminalInput: () => doc.activeElement?.blur(), - }, + app, navigator: { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/130 Mobile/15E148', maxTouchPoints: 5, @@ -131,7 +133,11 @@ function loadKeyboardHandler() { else timers.push(t); } }; - return { handler, resize, fire, advance, doc, bodyClasses, hides }; + const requestFocus = (input: FakeEl) => { + app._mobileKeyboardFocusGeneration++; + doc.activeElement = input; + }; + return { handler, resize, fire, advance, doc, bodyClasses, hides, requestFocus }; } const terminalTextarea = (onBlur = () => {}): FakeEl => ({ @@ -234,18 +240,18 @@ describe('COD-467 stale FOCUS: coming back to the page releases the hidden texta 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 } = loadKeyboardHandler(); + const { handler, resize, fire, advance, doc, requestFocus } = loadKeyboardHandler(); const blur = vi.fn(() => { doc.activeElement = null; }); const input = terminalTextarea(blur); - doc.activeElement = input; + requestFocus(input); resize(FULL - KEYBOARD, FULL); // Explicit dismiss, then a new focus before the old close animation ends. input.blur(); fire('focusout'); advance(delay); - doc.activeElement = input; + requestFocus(input); resize(FULL, FULL); expect(doc.activeElement).toBe(input); expect(blur).toHaveBeenCalledTimes(1); @@ -259,6 +265,61 @@ describe('keyboard lifecycle interleavings (#35)', () => { }); } + 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('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(() => { diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index a78819244..a08bff209 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -213,12 +213,12 @@ describe('header-menu workaround and stale keyboard visibility', () => { content: ` window.MobileDetection={isTouchDevice:()=>true,updateAppHeight(){}}; window.hideEvents=0;window.focusEvents=0; - window.KeyboardHandler={${['init', 'handleViewportResize', 'onKeyboardHide', 'forceKeyboardHidden', '_isTerminalInput', '_editableHasFocus', 'isInputElement'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')}, + window.KeyboardHandler={${['init', '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(){}, - onKeyboardShow(){},_traceKeyboardState(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; - window.app={${['_mobileKeyboardIsUp', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, + _traceKeyboardState(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}}; + window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); @@ -244,6 +244,61 @@ describe('header-menu workaround and stale keyboard visibility', () => { 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('releases an owned native dismiss before the next of 20 reopen gestures', 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 < 20; cycle++) { + await page.locator('#menu').tap(); + await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()'); + 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('preserves a new focus through each of 25 delayed closing viewport transitions', async () => { const page = await keyboardPage(); try { @@ -360,7 +415,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { try { await page.setViewportSize({ width: 440, height: 552 }); await page.evaluate( - 'KeyboardHandler._keyboardMinHeight=visualViewport.height;focusEvents=0;app._focusMobileTerminalInput()' + 'KeyboardHandler._keyboardMinHeight=visualViewport.height;focusEvents=0;app._prepareMobileTerminalFocusGesture();app._focusMobileTerminalInput()' ); expect(await page.evaluate('focusEvents')).toBe(0); expect(await page.evaluate('hideEvents')).toBe(0); From 2c5bc3ea930255dbcd3506f30f417c0927534480 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 12:59:24 +0400 Subject: [PATCH 09/15] fix(mobile): preserve keyboard dismissal ownership across prompt taps and reconnects (#35) --- .../lifecycle-rca/dismiss-reopen-v7.md | 14 +++++++ src/web/public/mobile-handlers.js | 32 ++++++++++------ src/web/public/terminal-ui.js | 22 ++++++----- test/mobile-keyboard-stale-state.test.ts | 33 ++++++++++++++++ .../mobile-tab-keyboard-focus.browser.test.ts | 38 ++++++++++++++++++- 5 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 docs/reports/mobile-tab-keyboard/lifecycle-rca/dismiss-reopen-v7.md diff --git a/docs/reports/mobile-tab-keyboard/lifecycle-rca/dismiss-reopen-v7.md b/docs/reports/mobile-tab-keyboard/lifecycle-rca/dismiss-reopen-v7.md new file mode 100644 index 000000000..00f548f6b --- /dev/null +++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/dismiss-reopen-v7.md @@ -0,0 +1,14 @@ +# Dismiss/reopen coverage correction (#35) + +The reporter clarifies that opening initially works, then closing the keyboard makes reopening unreliable. Latest v6 phone traces still contain failed opens after gesture release, plus successful owned dismiss/reopen flows; native reliability is not established. + +Two additional source defects are reproduced before the correction: + +- A prompt tap while the keyboard is already open and its input remains focused advanced the opening generation. This unnecessarily invalidated the shown keyboard's owner; a later native dismissal could leave editing focus retained. Merely retaining the same open input now keeps its generation. A real open or transfer to another input still supersedes the prior request before focus callbacks. Diagnostic attempt IDs remain separate from ownership. +- SSE initialization explicitly cleans up and reinstalls KeyboardHandler listeners. Reinitialization while the keyboard was open adopted the shrunken visual height as the keyboard-free baseline. Same-width listener reconnection now retains the calibrated baseline and current ownership, allowing the subsequent dismissal and reopen to classify correctly. + +The new unit cases and repeated prompt-tap browser cycle failed on v6. Final checks: 217 related unit tests, 18 browser regressions, full 56-test mobile keyboard suite, static checks and build pass. The browser cycle includes 20 open/dismiss/reopen sequences with zero/one/two actual prompt touch gestures while open, plus reconnect while open followed by dismissal/reopen. Previous 25 delayed-close and newer-focus protections remain covered. A full-page disposable real shell passed 30 mixed dismiss/late-close cycles, each with two actual prompt taps while open and a verified executed command. No page errors. Realistic smoke passed 86 checks, zero failures, one unconfigured external-remote skip. + +The last hide decision (owner, current generation, input identity match and release result) is retained in future breadcrumbs, so repeated failed taps cannot erase why the preceding close did or did not release focus. No input contents are logged. + +The latest phone trace does not retain every original open/close event, so the two reproduced defects are not claimed as the complete explanation for every native refusal. Native keyboard presentation still requires the reporter's actual phone check. Issue #35 stays open and PR #37 draft. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 69cd1f848..53cd8ec1d 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -303,16 +303,21 @@ const KeyboardHandler = { if (this._initialized) return; this._initialized = true; - 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; + 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-v6 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v7 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -712,13 +717,16 @@ const KeyboardHandler = { const ownerInput = this._keyboardFocusOwnerInput; this._keyboardFocusOwner = undefined; this._keyboardFocusOwnerInput = undefined; - if ( + const release = Number.isSafeInteger(owner) && typeof app !== 'undefined' && owner === app._mobileKeyboardFocusGeneration && ownerInput === document.activeElement && - app._isMobileTerminalInputFocused?.() - ) { + 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'); } @@ -937,7 +945,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v6 ${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} page=${document.visibilityState}` + `KEYBOARD_STATE: v7 ${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}` ); }, @@ -967,7 +975,7 @@ const KeyboardHandler = { } const rounded = (value) => Math.round(value); _crashDiag.log( - `KEYBOARD_BOX: v6 ${reason} ${JSON.stringify({ + `KEYBOARD_BOX: v7 ${reason} ${JSON.stringify({ rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded), clip: [left, top, right, bottom].map(rounded), intersects: @@ -987,7 +995,7 @@ const KeyboardHandler = { })}` ); } catch { - _crashDiag.log(`KEYBOARD_BOX: v6 ${reason} unavailable`); + _crashDiag.log(`KEYBOARD_BOX: v7 ${reason} unavailable`); } }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5156b6299..5d4b8d2d7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7534,15 +7534,18 @@ Object.assign(CodemanApp.prototype, { }, _focusMobileTerminalInput(source = 'terminal-tap') { - // Every explicit focus request supersedes the keyboard's prior owner. Set - // this BEFORE focus/geometry callbacks, including synchronous re-entrancy. - const request = this._mobileKeyboardFocusGeneration = (this._mobileKeyboardFocusGeneration || 0) + 1; this._syncMobileHelperTextareaToCursor(); - // Fallback for callers without a terminal touchstart (e.g. tab selection). - // Terminal taps release stale focus in the earlier gesture phase; an OS - // hide normally releases its own focus before any new gesture begins. - if (this._isMobileTerminalInputFocused() && !this._mobileKeyboardIsUp()) this._blurMobileTerminalInput(); 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 { @@ -7550,12 +7553,13 @@ Object.assign(CodemanApp.prototype, { } 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} height=${Math.round(window.visualViewport?.height || 0)} baseline=${Math.round(handler?.initialViewportHeight || 0)} min=${Math.round(handler?._keyboardMinHeight || 0)}`); + _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._mobileKeyboardFocusGeneration || document.visibilityState === 'hidden') return; + if (request !== this._keyboardFocusRequestId || document.visibilityState === 'hidden') return; handler?._traceKeyboardState?.(`request-${request}-after-${delay}ms`); }, delay); } diff --git a/test/mobile-keyboard-stale-state.test.ts b/test/mobile-keyboard-stale-state.test.ts index 1b080c108..21258d21e 100644 --- a/test/mobile-keyboard-stale-state.test.ts +++ b/test/mobile-keyboard-stale-state.test.ts @@ -301,6 +301,21 @@ describe('keyboard lifecycle interleavings (#35)', () => { 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(() => { @@ -410,6 +425,24 @@ describe('COD-467 stale FOCUS: a tap opens the keyboard when the textarea is foc 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'); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index a08bff209..8d6c23de7 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -213,7 +213,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { content: ` window.MobileDetection={isTouchDevice:()=>true,updateAppHeight(){}}; window.hideEvents=0;window.focusEvents=0; - window.KeyboardHandler={${['init', 'handleViewportResize', 'onKeyboardHide', 'onKeyboardShow', 'forceKeyboardHidden', '_isTerminalInput', '_editableHasFocus', 'isInputElement'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')}, + 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(){}, @@ -275,7 +275,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { } }); - it('releases an owned native dismiss before the next of 20 reopen gestures', async () => { + it('retains dismiss ownership after prompt taps while the keyboard is open', async () => { const page = await keyboardPage(); try { await page.evaluate(` @@ -283,10 +283,18 @@ describe('header-menu workaround and stale keyboard visibility', () => { 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 ); @@ -299,6 +307,32 @@ describe('header-menu workaround and stale keyboard visibility', () => { } }); + 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 { From 59055588b1d6b0d0ff27565147201d18ec787b2b Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 14:37:12 +0400 Subject: [PATCH 10/15] fix(mobile): transfer settled tab keyboard dismissal ownership (#35) --- src/web/public/mobile-handlers.js | 2 +- src/web/public/terminal-ui.js | 24 +++++ .../mobile-tab-keyboard-focus.browser.test.ts | 89 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 53cd8ec1d..c0ebbb618 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -945,7 +945,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v7 ${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}` + `KEYBOARD_STATE: v8 ${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}` ); }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5d4b8d2d7..0255d46b1 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7551,6 +7551,30 @@ Object.assign(CodemanApp.prototype, { } 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); + } 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)}`); diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 8d6c23de7..e7187eb94 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -307,6 +307,95 @@ describe('header-menu workaround and stale keyboard visibility', () => { } }); + 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 { From 8e2d26fdcacd691ff9a0b0cfdd5526c1c62532d9 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 15:47:10 +0400 Subject: [PATCH 11/15] fix(mobile): revalidate native input layout on iOS keyboard reopen (#35) --- src/web/public/mobile-handlers.js | 13 ++-- src/web/public/terminal-ui.js | 23 ++++++ .../mobile-tab-keyboard-focus.browser.test.ts | 72 ++++++++++++++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index c0ebbb618..faf4194f0 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -317,7 +317,7 @@ const KeyboardHandler = { this._traceKeyboardState('init'); if (typeof _crashDiag !== 'undefined') { _crashDiag.log( - `KEYBOARD_BUILD: lifecycle-v7 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v9 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -691,6 +691,7 @@ const KeyboardHandler = { /** Called when keyboard appears */ onKeyboardShow() { + this._nativeTerminalDismissedInput = undefined; // Show keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.show(); @@ -727,6 +728,7 @@ const KeyboardHandler = { // 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) { + this._nativeTerminalDismissedInput = ownerInput; app._blurMobileTerminalInput(); this._traceKeyboardState('owned-hide-release'); } @@ -945,7 +947,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v8 ${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}` + `KEYBOARD_STATE: v9 ${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}` ); }, @@ -975,7 +977,7 @@ const KeyboardHandler = { } const rounded = (value) => Math.round(value); _crashDiag.log( - `KEYBOARD_BOX: v7 ${reason} ${JSON.stringify({ + `KEYBOARD_BOX: v9 ${reason} ${JSON.stringify({ rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded), clip: [left, top, right, bottom].map(rounded), intersects: @@ -987,6 +989,9 @@ const KeyboardHandler = { 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, @@ -995,7 +1000,7 @@ const KeyboardHandler = { })}` ); } catch { - _crashDiag.log(`KEYBOARD_BOX: v7 ${reason} unavailable`); + _crashDiag.log(`KEYBOARD_BOX: v9 ${reason} unavailable`); } }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0255d46b1..39d75e10e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7533,6 +7533,28 @@ Object.assign(CodemanApp.prototype, { return true; }, + _revalidateMobileTerminalInputLayout(target) { + const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null; + if (!MobileDetection.isIOS?.() || !target?.isConnected || + handler?.keyboardVisible || handler?._nativeTerminalDismissedInput !== target || + document.activeElement === target || target.readOnly || target.disabled) return; + // A native close can leave WebKit's editable view stale although DOM focus + // and its rectangle look valid. A menu reflow revives it on the phone (#35). + // Rebuild only this unfocused input's layout, synchronously in the opening + // gesture. Keep the node, listeners, value, selection and terminal grid. + const display = target.style.getPropertyValue('display'); + const priority = target.style.getPropertyPriority('display'); + try { + target.style.setProperty('display', 'none', 'important'); + target.getBoundingClientRect(); + } finally { + if (display) target.style.setProperty('display', display, priority); + else target.style.removeProperty('display'); + } + target.getBoundingClientRect(); + handler._traceKeyboardState?.('native-input-layout-revalidated'); + }, + _focusMobileTerminalInput(source = 'terminal-tap') { this._syncMobileHelperTextareaToCursor(); const cjkInput = document.getElementById('cjkInput'); @@ -7546,6 +7568,7 @@ Object.assign(CodemanApp.prototype, { 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 (!keyboardUp) this._revalidateMobileTerminalInputLayout(target); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index e7187eb94..265ee6f5b 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -218,7 +218,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { _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(',')}, + window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_revalidateMobileTerminalInputLayout', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); @@ -533,6 +533,76 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.close(); } }); + it('rebuilds only the dismissed iOS input layout before refocusing, preserving its state', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + MobileDetection.isIOS=()=>true; + 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(); + cycleHeight=fullHeight;KeyboardHandler.handleViewportResize(); + const input=app.terminal.textarea; + window.originalInput=input;input.value='keep this draft';input.setSelectionRange(2,6); + input.style.setProperty('display','block','important'); + window.layoutSamples=[];window.inputEvents=0; + input.addEventListener('input',()=>inputEvents++); + const rect=input.getBoundingClientRect.bind(input); + input.getBoundingClientRect=()=>{layoutSamples.push(getComputedStyle(input).display);return rect()}; + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + input.addEventListener('focus',()=>layoutSamples.push('focus')); + `); + await page.locator('#menu').tap(); + expect(await page.evaluate('layoutSamples')).toEqual(['none', 'block', 'focus']); + expect(await page.evaluate('app.terminal.textarea===originalInput')).toBe(true); + expect(await page.evaluate('originalInput.value')).toBe('keep this draft'); + expect(await page.evaluate('[originalInput.selectionStart,originalInput.selectionEnd]')).toEqual([2, 6]); + expect(await page.evaluate('originalInput.style.getPropertyPriority("display")')).toBe('important'); + await page.keyboard.type('X'); + expect(await page.evaluate('inputEvents')).toBe(1); + await page.evaluate('originalInput.blur()'); + await page.waitForTimeout(500); + expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); + } finally { + await page.close(); + } + }); + + for (const state of ['keyboard-open', 'non-ios', 'other-editor']) { + it(`does not rebuild or change focus for ${state}`, async () => { + const page = await keyboardPage(); + try { + await page.evaluate((state) => { + const w = window as any; + w.MobileDetection.isIOS = () => state !== 'non-ios'; + w.KeyboardHandler.keyboardVisible = state === 'keyboard-open'; + w.KeyboardHandler._nativeTerminalDismissedInput = w.app.terminal.textarea; + w.samples = 0; + const input = w.app.terminal.textarea; + input.getBoundingClientRect = () => { + w.samples++; + return { width: 1, height: 1 }; + }; + if (state === 'other-editor') { + const editor = document.createElement('textarea'); + editor.id = 'editor'; + document.body.append(editor); + editor.focus(); + w.app._revalidateMobileTerminalInputLayout(editor); + } else w.app._revalidateMobileTerminalInputLayout(input); + }, state); + expect(await page.evaluate('samples')).toBe(0); + expect(await page.evaluate(() => document.activeElement?.id)).toBe( + state === 'other-editor' ? 'editor' : 'terminal' + ); + } finally { + await page.close(); + } + }); + } + it('does not blur/refocus an actually shrunken keyboard viewport', async () => { const page = await keyboardPage(); try { From 5ee9a001d6718c2c369675eeebe8767b45ca1c6d Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 17:51:44 +0400 Subject: [PATCH 12/15] fix(mobile): restore page focus before reopening the iOS keyboard (#35) --- src/web/public/mobile-handlers.js | 10 +-- src/web/public/terminal-ui.js | 36 ++++---- .../mobile-tab-keyboard-focus.browser.test.ts | 82 ++++++++++--------- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index faf4194f0..fe370a483 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -317,7 +317,7 @@ const KeyboardHandler = { this._traceKeyboardState('init'); if (typeof _crashDiag !== 'undefined') { _crashDiag.log( - `KEYBOARD_BUILD: lifecycle-v9 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v10 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -691,7 +691,6 @@ const KeyboardHandler = { /** Called when keyboard appears */ onKeyboardShow() { - this._nativeTerminalDismissedInput = undefined; // Show keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.show(); @@ -728,7 +727,6 @@ const KeyboardHandler = { // 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) { - this._nativeTerminalDismissedInput = ownerInput; app._blurMobileTerminalInput(); this._traceKeyboardState('owned-hide-release'); } @@ -947,7 +945,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v9 ${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}` + `KEYBOARD_STATE: v10 ${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}` ); }, @@ -977,7 +975,7 @@ const KeyboardHandler = { } const rounded = (value) => Math.round(value); _crashDiag.log( - `KEYBOARD_BOX: v9 ${reason} ${JSON.stringify({ + `KEYBOARD_BOX: v10 ${reason} ${JSON.stringify({ rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded), clip: [left, top, right, bottom].map(rounded), intersects: @@ -1000,7 +998,7 @@ const KeyboardHandler = { })}` ); } catch { - _crashDiag.log(`KEYBOARD_BOX: v9 ${reason} unavailable`); + _crashDiag.log(`KEYBOARD_BOX: v10 ${reason} unavailable`); } }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 39d75e10e..942c99c27 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7533,29 +7533,28 @@ Object.assign(CodemanApp.prototype, { return true; }, - _revalidateMobileTerminalInputLayout(target) { - const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null; - if (!MobileDetection.isIOS?.() || !target?.isConnected || - handler?.keyboardVisible || handler?._nativeTerminalDismissedInput !== target || - document.activeElement === target || target.readOnly || target.disabled) return; - // A native close can leave WebKit's editable view stale although DOM focus - // and its rectangle look valid. A menu reflow revives it on the phone (#35). - // Rebuild only this unfocused input's layout, synchronously in the opening - // gesture. Keep the node, listeners, value, selection and terminal grid. - const display = target.style.getPropertyValue('display'); - const priority = target.style.getPropertyPriority('display'); + _restoreMobileTerminalPageFocus() { + // Native iOS keyboard dismissal can unfocus the page while a later touch + // focuses the textarea only. The phone then reports activeElement correctly + // but document.hasFocus() stays false and no keyboard appears (#35). + // Request page focus FIRST, only in a live user gesture on this visible page. + // Browser focus requests are best-effort; never retry from a timer. + if (!MobileDetection.isIOS?.() || document.visibilityState !== 'visible' || + typeof document.hasFocus !== 'function' || document.hasFocus() || + typeof navigator === 'undefined' || navigator.userActivation?.isActive !== true) return; + let accepted = false; try { - target.style.setProperty('display', 'none', 'important'); - target.getBoundingClientRect(); - } finally { - if (display) target.style.setProperty('display', display, priority); - else target.style.removeProperty('display'); + window.focus(); + accepted = document.hasFocus(); + } catch { + // A browser can reject focus; the normal input gesture still proceeds. } - target.getBoundingClientRect(); - handler._traceKeyboardState?.('native-input-layout-revalidated'); + if (typeof _crashDiag !== 'undefined') + _crashDiag.log(`KEYBOARD_PAGE_FOCUS: v10 requested=true after=${accepted}`); }, _focusMobileTerminalInput(source = 'terminal-tap') { + this._restoreMobileTerminalPageFocus(); this._syncMobileHelperTextareaToCursor(); const cjkInput = document.getElementById('cjkInput'); const target = cjkInput?.classList.contains('cjk-input-visible') ? cjkInput : this.terminal?.textarea; @@ -7568,7 +7567,6 @@ Object.assign(CodemanApp.prototype, { 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 (!keyboardUp) this._revalidateMobileTerminalInputLayout(target); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 265ee6f5b..15de5d82f 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -218,7 +218,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { _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', '_revalidateMobileTerminalInputLayout', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, + window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_restoreMobileTerminalPageFocus', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); @@ -533,70 +533,74 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.close(); } }); - it('rebuilds only the dismissed iOS input layout before refocusing, preserving its state', async () => { + it('requests page focus before input focus when iOS native dismissal unfocused the page', async () => { const page = await keyboardPage(); try { await page.evaluate(` - MobileDetection.isIOS=()=>true; - 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(); - cycleHeight=fullHeight;KeyboardHandler.handleViewportResize(); + MobileDetection.isIOS=()=>true;KeyboardHandler.keyboardVisible=false; + window.pageFocused=false;window.focusOrder=[]; + Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>pageFocused}); + window.focus=()=>{focusOrder.push('page');pageFocused=true}; + const original=app.terminal.focus.bind(app.terminal); + app.terminal.focus=()=>{focusOrder.push('input');original()}; const input=app.terminal.textarea; window.originalInput=input;input.value='keep this draft';input.setSelectionRange(2,6); - input.style.setProperty('display','block','important'); - window.layoutSamples=[];window.inputEvents=0; - input.addEventListener('input',()=>inputEvents++); - const rect=input.getBoundingClientRect.bind(input); - input.getBoundingClientRect=()=>{layoutSamples.push(getComputedStyle(input).display);return rect()}; + window.inputEvents=0;input.addEventListener('input',()=>inputEvents++); document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); - input.addEventListener('focus',()=>layoutSamples.push('focus')); `); await page.locator('#menu').tap(); - expect(await page.evaluate('layoutSamples')).toEqual(['none', 'block', 'focus']); + expect(await page.evaluate('focusOrder')).toEqual(['page', 'input']); + expect(await page.evaluate('document.hasFocus()')).toBe(true); expect(await page.evaluate('app.terminal.textarea===originalInput')).toBe(true); expect(await page.evaluate('originalInput.value')).toBe('keep this draft'); expect(await page.evaluate('[originalInput.selectionStart,originalInput.selectionEnd]')).toEqual([2, 6]); - expect(await page.evaluate('originalInput.style.getPropertyPriority("display")')).toBe('important'); await page.keyboard.type('X'); expect(await page.evaluate('inputEvents')).toBe(1); - await page.evaluate('originalInput.blur()'); + await page.evaluate('pageFocused=false;focusOrder=[];originalInput.blur()'); await page.waitForTimeout(500); + expect(await page.evaluate('focusOrder')).toEqual([]); expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); } finally { await page.close(); } }); - for (const state of ['keyboard-open', 'non-ios', 'other-editor']) { - it(`does not rebuild or change focus for ${state}`, async () => { + it('does not loop or retry if the browser declines page focus', async () => { + const page = await keyboardPage(); + try { + await page.evaluate(` + MobileDetection.isIOS=()=>true;KeyboardHandler.keyboardVisible=false; + Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>false}); + window.pageRequests=0;window.focus=()=>pageRequests++; + document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + `); + await page.locator('#menu').tap(); + await page.waitForTimeout(1100); + expect(await page.evaluate('pageRequests')).toBe(1); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); + } finally { + await page.close(); + } + }); + + for (const state of ['page-focused', 'non-ios', 'hidden', 'no-gesture']) { + it(`does not request window focus for ${state}`, async () => { const page = await keyboardPage(); try { await page.evaluate((state) => { const w = window as any; w.MobileDetection.isIOS = () => state !== 'non-ios'; - w.KeyboardHandler.keyboardVisible = state === 'keyboard-open'; - w.KeyboardHandler._nativeTerminalDismissedInput = w.app.terminal.textarea; - w.samples = 0; - const input = w.app.terminal.textarea; - input.getBoundingClientRect = () => { - w.samples++; - return { width: 1, height: 1 }; - }; - if (state === 'other-editor') { - const editor = document.createElement('textarea'); - editor.id = 'editor'; - document.body.append(editor); - editor.focus(); - w.app._revalidateMobileTerminalInputLayout(editor); - } else w.app._revalidateMobileTerminalInputLayout(input); + Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => state === 'page-focused' }); + if (state === 'hidden') + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + if (state === 'no-gesture') + Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: false } }); + w.pageRequests = 0; + w.focus = () => w.pageRequests++; + document.getElementById('menu')!.onclick = () => w.app._restoreMobileTerminalPageFocus(); }, state); - expect(await page.evaluate('samples')).toBe(0); - expect(await page.evaluate(() => document.activeElement?.id)).toBe( - state === 'other-editor' ? 'editor' : 'terminal' - ); + await page.locator('#menu').tap(); + expect(await page.evaluate('pageRequests')).toBe(0); } finally { await page.close(); } From 15dc1424151026c7b59f666e44a025ced0fccc53 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 1 Nov 2026 19:28:12 +0400 Subject: [PATCH 13/15] fix(mobile): allow native page activation during keyboard recovery (#35) --- src/web/public/mobile-handlers.js | 8 +- src/web/public/terminal-ui.js | 60 +++++--- .../mobile-tab-keyboard-focus.browser.test.ts | 131 +++++++++++------- test/terminal-touch-tap.test.ts | 21 +++ 4 files changed, 140 insertions(+), 80 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index fe370a483..32321a4ef 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -317,7 +317,7 @@ const KeyboardHandler = { this._traceKeyboardState('init'); if (typeof _crashDiag !== 'undefined') { _crashDiag.log( - `KEYBOARD_BUILD: lifecycle-v10 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` + `KEYBOARD_BUILD: lifecycle-v11 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}` ); } @@ -945,7 +945,7 @@ const KeyboardHandler = { const focus = this._isTerminalInput(active) ? 'terminal' : this._editableHasFocus() ? 'editor' : 'none'; this._traceKeyboardInputBox(reason); _crashDiag.log( - `KEYBOARD_STATE: v10 ${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}` + `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}` ); }, @@ -975,7 +975,7 @@ const KeyboardHandler = { } const rounded = (value) => Math.round(value); _crashDiag.log( - `KEYBOARD_BOX: v10 ${reason} ${JSON.stringify({ + `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: @@ -998,7 +998,7 @@ const KeyboardHandler = { })}` ); } catch { - _crashDiag.log(`KEYBOARD_BOX: v10 ${reason} unavailable`); + _crashDiag.log(`KEYBOARD_BOX: v11 ${reason} unavailable`); } }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 942c99c27..d5aa45e4d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -622,6 +622,8 @@ Object.assign(CodemanApp.prototype, { }, _handleForegroundTuiClick(ev) { + if (ev.isTrusted && this._mobileNativeClickFocusRequest && + performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return false; // Keep the fork's click support independent of the scrolling comparison. if (!this._shouldForwardClickToForegroundTui() || ev.button !== 0) return false; ev.preventDefault(); @@ -1660,7 +1662,10 @@ Object.assign(CodemanApp.prototype, { // Cancel xterm/browser focus before the compatibility click can // open the OS keyboard. Content taps are re-emitted as SGR on // touchend. History taps remain focusable. - ev.preventDefault(); + // An unfocused iOS page needs the browser's native activation + // event. The mouse guard still blocks CLI actions, and arms a + // trusted-click focus recovery only if this tap wants input (#35). + if (!MobileDetection.isIOS?.() || document.hasFocus()) ev.preventDefault(); this._blurMobileTerminalInput(); } // Hold still and this press becomes a text selection. Cancelled by any @@ -7527,34 +7532,14 @@ Object.assign(CodemanApp.prototype, { }, _prepareMobileTerminalFocusGesture() { + this._mobileNativeClickFocusRequest = null; if (!this._isMobileTerminalInputFocused() || this._mobileKeyboardIsUp()) return false; this._blurMobileTerminalInput(); if (typeof KeyboardHandler !== 'undefined') KeyboardHandler._traceKeyboardState?.('gesture-release'); return true; }, - _restoreMobileTerminalPageFocus() { - // Native iOS keyboard dismissal can unfocus the page while a later touch - // focuses the textarea only. The phone then reports activeElement correctly - // but document.hasFocus() stays false and no keyboard appears (#35). - // Request page focus FIRST, only in a live user gesture on this visible page. - // Browser focus requests are best-effort; never retry from a timer. - if (!MobileDetection.isIOS?.() || document.visibilityState !== 'visible' || - typeof document.hasFocus !== 'function' || document.hasFocus() || - typeof navigator === 'undefined' || navigator.userActivation?.isActive !== true) return; - let accepted = false; - try { - window.focus(); - accepted = document.hasFocus(); - } catch { - // A browser can reject focus; the normal input gesture still proceeds. - } - if (typeof _crashDiag !== 'undefined') - _crashDiag.log(`KEYBOARD_PAGE_FOCUS: v10 requested=true after=${accepted}`); - }, - _focusMobileTerminalInput(source = 'terminal-tap') { - this._restoreMobileTerminalPageFocus(); this._syncMobileHelperTextareaToCursor(); const cjkInput = document.getElementById('cjkInput'); const target = cjkInput?.classList.contains('cjk-input-visible') ? cjkInput : this.terminal?.textarea; @@ -7596,6 +7581,18 @@ Object.assign(CodemanApp.prototype, { 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)}`); @@ -8339,11 +8336,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-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts index 15de5d82f..34869dc69 100644 --- a/test/mobile-tab-keyboard-focus.browser.test.ts +++ b/test/mobile-tab-keyboard-focus.browser.test.ts @@ -218,7 +218,7 @@ describe('header-menu workaround and stale keyboard visibility', () => { _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', '_restoreMobileTerminalPageFocus', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, + window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')}, ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){}, terminal:{textarea:document.getElementById('terminal'),focus(){this.textarea.focus()}}}; document.getElementById('menu').onclick=()=>app.toggleMobileHeaderUtilities(); @@ -533,74 +533,99 @@ describe('header-menu workaround and stale keyboard visibility', () => { await page.close(); } }); - it('requests page focus before input focus when iOS native dismissal unfocused the page', async () => { + 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.focusOrder=[]; + window.pageFocused=false;window.cliMouseEvents=0;window.nativeDefaults=[]; Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>pageFocused}); - window.focus=()=>{focusOrder.push('page');pageFocused=true}; - const original=app.terminal.focus.bind(app.terminal); - app.terminal.focus=()=>{focusOrder.push('input');original()}; - const input=app.terminal.textarea; - window.originalInput=input;input.value='keep this draft';input.setSelectionRange(2,6); - window.inputEvents=0;input.addEventListener('input',()=>inputEvents++); - document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); + 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('#menu').tap(); - expect(await page.evaluate('focusOrder')).toEqual(['page', 'input']); + 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('app.terminal.textarea===originalInput')).toBe(true); - expect(await page.evaluate('originalInput.value')).toBe('keep this draft'); - expect(await page.evaluate('[originalInput.selectionStart,originalInput.selectionEnd]')).toEqual([2, 6]); - await page.keyboard.type('X'); - expect(await page.evaluate('inputEvents')).toBe(1); - await page.evaluate('pageFocused=false;focusOrder=[];originalInput.blur()'); - await page.waitForTimeout(500); - expect(await page.evaluate('focusOrder')).toEqual([]); - expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal'); - } finally { - await page.close(); - } - }); - - it('does not loop or retry if the browser declines page focus', async () => { - const page = await keyboardPage(); - try { - await page.evaluate(` - MobileDetection.isIOS=()=>true;KeyboardHandler.keyboardVisible=false; - Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>false}); - window.pageRequests=0;window.focus=()=>pageRequests++; - document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput(); - `); - await page.locator('#menu').tap(); - await page.waitForTimeout(1100); - expect(await page.evaluate('pageRequests')).toBe(1); + expect(await page.evaluate('cliMouseEvents')).toBe(0); expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal'); } finally { await page.close(); } }); - for (const state of ['page-focused', 'non-ios', 'hidden', 'no-gesture']) { - it(`does not request window focus for ${state}`, async () => { + 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((state) => { + await page.evaluate((kind) => { const w = window as any; - w.MobileDetection.isIOS = () => state !== 'non-ios'; - Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => state === 'page-focused' }); - if (state === 'hidden') - Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); - if (state === 'no-gesture') - Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: false } }); - w.pageRequests = 0; - w.focus = () => w.pageRequests++; - document.getElementById('menu')!.onclick = () => w.app._restoreMobileTerminalPageFocus(); - }, state); - await page.locator('#menu').tap(); - expect(await page.evaluate('pageRequests')).toBe(0); + 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(); } diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index a234af94f..4925cc07a 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -357,6 +357,27 @@ describe('terminal touch tap mouse guard', () => { expect(event.stopImmediatePropagation).toHaveBeenCalledOnce(); }); + it('retains OpenCode click fallback except during native focus recovery', () => { + const { app } = loadTerminalUiHarness(); + app._shouldForwardClickToForegroundTui = () => true; + app._sendForegroundTuiClick = vi.fn(); + app._suppressTrustedTapMouseEvents(); + const event = { + isTrusted: true, + button: 0, + clientX: 10, + clientY: 20, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + expect(app._handleForegroundTuiClick(event)).toBe(true); + expect(app._sendForegroundTuiClick).toHaveBeenCalledOnce(); + app._sendForegroundTuiClick.mockClear(); + app._mobileNativeClickFocusRequest = {}; + expect(app._handleForegroundTuiClick(event)).toBe(false); + expect(app._sendForegroundTuiClick).not.toHaveBeenCalled(); + }); + it('allows the app synthetic mouse event through the tap window', () => { const { app } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness(); From 3227bf39318f8a158dd7802c497f5523fe9a136a Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Mon, 2 Nov 2026 14:34:23 +0400 Subject: [PATCH 14/15] fix(cases): enable default groups when creating or linking cases (#38) --- src/web/public/session-ui.js | 43 ++++++- test/case-default-group-ui.browser.test.ts | 133 +++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index df62239bf..08eea71fb 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4215,6 +4215,7 @@ Object.assign(CodemanApp.prototype, { }, resetCaseEditState() { + this._caseEditDefaultGroupLoadToken = (this._caseEditDefaultGroupLoadToken || 0) + 1; this.caseEditOriginalName = null; this.caseEditOriginalLocation = null; this.caseEditOriginalHostId = null; @@ -4278,10 +4279,13 @@ Object.assign(CodemanApp.prototype, { void this.loadRemoteHostsForCaseModal(); modal.classList.add('active'); document.getElementById('newCaseName').focus(); + void this.loadCaseEditDefaultGroup(null); }, switchCaseModalTab(tabName) { this.caseModalTab = tabName; + const groupRow = document.getElementById('caseEditDefaultGroupRow'); + if (groupRow) groupRow.style.display = tabName === 'case-manage' ? 'none' : ''; const modal = document.getElementById('createCaseModal'); const isEditing = Boolean(this.caseEditOriginalName); // Toggle active class on tab buttons @@ -4347,6 +4351,10 @@ Object.assign(CodemanApp.prototype, { }, async submitCaseModal() { + if (!this.caseEditOriginalName && document.getElementById('caseEditDefaultGroup')?.disabled) { + this.showToast('Default session groups are not loaded yet. Reopen Add Case to retry.', 'error'); + return; + } // Both submit buttons move together: whichever one the user pressed, the // other must show the same pending state and be equally unclickable. const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean); @@ -4454,6 +4462,7 @@ Object.assign(CodemanApp.prototype, { }, async createCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('newCaseName').value.trim(); const description = document.getElementById('newCaseDescription').value.trim(); @@ -4492,12 +4501,14 @@ Object.assign(CodemanApp.prototype, { const data = await res.json(); if (data.success) { + const groupSaved = await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); // Reload cases and select the new one await this.loadQuickStartCases(name); // Save as last used case await this.saveLastUsedCase(name); if (inDocker) { + if (!groupSaved) return; const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : ''; this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success'); // Start a session INSIDE the container (routes through quick-start). @@ -4558,6 +4569,7 @@ Object.assign(CodemanApp.prototype, { }, async linkCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('linkCaseName').value.trim(); const path = document.getElementById('linkCasePath').value.trim(); @@ -4585,6 +4597,7 @@ Object.assign(CodemanApp.prototype, { const data = await res.json(); if (data.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); this.showToast(`Case "${name}" linked to ${path}`, 'success'); // Reload cases and select the new one @@ -4768,6 +4781,7 @@ Object.assign(CodemanApp.prototype, { }, async cloneCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const url = document.getElementById('cloneRepoUrl').value.trim(); const name = document.getElementById('cloneCaseName').value.trim(); const ref = document.getElementById('cloneRepoRef').value.trim(); @@ -4813,12 +4827,13 @@ Object.assign(CodemanApp.prototype, { // Setting the brain before the tab closes means the Run button is already // pointing at the chosen CLI, whether or not a session starts now. if (brain) this.setRunMode(brain); + const groupSaved = await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); await this.loadQuickStartCases(name); await this.saveLastUsedCase(name); this.showToast(`Cloned into case "${name}"`, 'success'); for (const warning of data.data?.warnings || []) this.showToast(warning, 'warning'); - if (startSession) await this.run(); + if (startSession && groupSaved) await this.run(); } catch (err) { // A proxy/idle timeout can kill the request while git keeps going: the // case:created broadcast is what makes the case show up regardless. @@ -4906,6 +4921,7 @@ Object.assign(CodemanApp.prototype, { }, async linkRemoteCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('remoteCaseName').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim(); const hostId = document.getElementById('remoteHostId').value.trim() @@ -4978,6 +4994,7 @@ Object.assign(CodemanApp.prototype, { }); const caseData = await caseRes.json(); if (caseData.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); this.showToast(`Remote case "${name}" linked`, 'success'); await this.loadQuickStartCases(name); @@ -5554,6 +5571,7 @@ Object.assign(CodemanApp.prototype, { }, async linkDockerCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('dockerCaseName').value.trim(); const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; @@ -5646,6 +5664,7 @@ Object.assign(CodemanApp.prototype, { }); const caseData = await caseRes.json(); if (caseData.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); @@ -6200,6 +6219,23 @@ Object.assign(CodemanApp.prototype, { return select.value || null; }, + async _saveNewCaseDefaultGroup(caseName, groupId) { + if (!groupId) return true; // New cases already default to Ungrouped. + try { + const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/default-group`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ groupId }), + }); + const json = await res.json(); + if (!res.ok || json.success === false) throw new Error(json.error || 'Save refused'); + return true; + } catch { + // The case already exists: do not report creation failure or retry creation. + this.showToast('Case created, but its default group could not be saved. Set it in Case settings.', 'error'); + return false; + } + }, + async loadCaseEditDefaultGroup(caseName) { const token = (this._caseEditDefaultGroupLoadToken || 0) + 1; this._caseEditDefaultGroupLoadToken = token; @@ -6207,13 +6243,14 @@ Object.assign(CodemanApp.prototype, { const row = document.getElementById('caseEditDefaultGroupRow'); if (row) row.classList.remove('hidden'); try { - const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/default-group`); + const res = await fetch(caseName ? `/api/cases/${encodeURIComponent(caseName)}/default-group` : '/api/tab-layout'); const json = await res.json(); if (!res.ok || json.success === false) throw new Error(json.error || 'Failed to load default group'); if (token !== this._caseEditDefaultGroupLoadToken || this.caseEditOriginalName !== caseName) return; const select = document.getElementById('caseEditDefaultGroup'); if (!select) return; - const groups = Array.isArray(json.data?.groups) ? json.data.groups : []; + const availableGroups = caseName ? json.data?.groups : json.data?.layout?.groups; + const groups = Array.isArray(availableGroups) ? availableGroups : []; const selected = typeof json.data?.groupId === 'string' && groups.some((group) => group.id === json.data.groupId) ? json.data.groupId : ''; diff --git a/test/case-default-group-ui.browser.test.ts b/test/case-default-group-ui.browser.test.ts index df23f4bdd..3895d7c43 100644 --- a/test/case-default-group-ui.browser.test.ts +++ b/test/case-default-group-ui.browser.test.ts @@ -74,6 +74,139 @@ describe('COD-343 Case Management default groups in Chromium', () => { await browser.close(); }); + async function newCaseForm() { + await page.route('**/api/tab-layout', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { layout: { groups } } }), + }) + ); + await page.evaluate(() => { + const app = (window as any).__app; + app.caseEditOriginalName = null; + app.loadQuickStartCases = async () => {}; + app.saveLastUsedCase = async () => {}; + document.body.insertAdjacentHTML( + 'beforeend', + '' + ); + }); + await page.evaluate(() => (window as any).__app.loadCaseEditDefaultGroup(null)); + expect(await page.locator('#caseEditDefaultGroup').isEnabled()).toBe(true); + await page.selectOption('#caseEditDefaultGroup', 'group-tcc'); + } + + it('loads selectable owner groups for a new case and saves the choice before closing Link Case', async () => { + await newCaseForm(); + const requests: unknown[] = []; + await page.route('**/api/cases/link', async (route) => { + requests.push(route.request().postDataJSON()); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.route('**/api/cases/fresh/default-group', async (route) => { + requests.push(route.request().postDataJSON()); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.evaluate(() => (window as any).__app.linkCase()); + expect(requests).toEqual([ + { name: 'fresh', path: '/tmp/fresh', codexCommandSelection: { kind: 'default' } }, + { groupId: 'group-tcc' }, + ]); + expect(await page.evaluate(() => (window as any).__app.caseEditOriginalName)).toBeNull(); + }); + + it('saves a remote case group after linking without contacting a real host', async () => { + await newCaseForm(); + await page.evaluate(() => { + const values: Record = { + remoteCaseName: 'fresh', + remoteCasePath: '/srv/fresh', + remoteHostId: 'test', + remoteHostAddress: 'example.test', + remoteHostUsername: 'test', + remoteHostPort: '', + remoteHostIdentityFile: '', + remoteHostSocksProxy: '', + remoteHostJumpHost: '', + remoteHostWakeMac: '', + remoteHostWakeCommand: '', + remoteHostExtraSshOptions: '', + }; + for (const [id, value] of Object.entries(values)) { + const input = document.createElement('input'); + input.id = id; + input.value = value; + document.body.append(input); + } + }); + for (const endpoint of ['remote-hosts', 'cases/remote-link']) + await page.route(`**/api/${endpoint}`, (route) => + route.fulfill({ contentType: 'application/json', body: '{"success":true}' }) + ); + let saved: unknown; + await page.route('**/api/cases/fresh/default-group', async (route) => { + saved = route.request().postDataJSON(); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.evaluate(() => (window as any).__app.linkRemoteCase()); + expect(saved).toEqual({ groupId: 'group-tcc' }); + }); + + it('does not start a Docker session if case creation succeeds but saving the group fails', async () => { + await newCaseForm(); + await page.locator('#newCaseDocker').check(); + await page.evaluate(() => { + const app = (window as any).__app; + app._collectDockerQuickSettings = () => ({}); + (window as any).__started = false; + app.runClaude = async () => { + (window as any).__started = true; + }; + }); + await page.route('**/api/cases/docker-quickcreate', (route) => + route.fulfill({ contentType: 'application/json', body: '{"success":true,"data":{}}' }) + ); + await page.route('**/api/cases/fresh/default-group', (route) => + route.fulfill({ status: 400, contentType: 'application/json', body: '{"success":false,"error":"Group removed"}' }) + ); + await page.evaluate(() => (window as any).__app.createCase()); + expect(await page.evaluate(() => (window as any).__started)).toBe(false); + expect(await page.evaluate(() => (window as any).__toasts)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'error', message: expect.stringContaining('Case created, but') }), + ]) + ); + }); + + it('ignores a new-case group response after the dialog has switched to an existing case', async () => { + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + await page.route('**/api/tab-layout', async (route) => { + await waiting; + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { layout: { groups } } }), + }); + }); + const requested = page.waitForRequest('**/api/tab-layout'); + await page.evaluate(() => { + const a = (window as any).__app; + a.caseEditOriginalName = null; + void a.loadCaseEditDefaultGroup(null); + }); + await requested; + await page.evaluate(async () => { + const a = (window as any).__app; + a.caseEditOriginalName = 'sandbox'; + await a.loadCaseEditDefaultGroup('sandbox'); + }); + release(); + await page.waitForTimeout(150); + expect(await page.locator('#caseEditDefaultGroup').inputValue()).toBe('group-tcc'); + }); + it('loads the saved default into the Edit Case control and exposes its selected ID for the case update', async () => { await page.evaluate(() => (window as any).__app.loadCaseEditDefaultGroup('nzbdav')); From 0ce8fd2eab84c6e82210d44e2b89145f884339eb Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Mon, 2 Nov 2026 14:39:47 +0400 Subject: [PATCH 15/15] docs: show selectable new-case groups on desktop and mobile (#38) --- docs/reports/new-case-default-group/README.md | 9 +++++++++ docs/reports/new-case-default-group/desktop.png | Bin 0 -> 61996 bytes docs/reports/new-case-default-group/mobile.png | Bin 0 -> 47164 bytes 3 files changed, 9 insertions(+) create mode 100644 docs/reports/new-case-default-group/README.md create mode 100644 docs/reports/new-case-default-group/desktop.png create mode 100644 docs/reports/new-case-default-group/mobile.png diff --git a/docs/reports/new-case-default-group/README.md b/docs/reports/new-case-default-group/README.md new file mode 100644 index 000000000..85d4c84aa --- /dev/null +++ b/docs/reports/new-case-default-group/README.md @@ -0,0 +1,9 @@ +# New-case default session groups (#38) + +The Add Case dialog now loads the owner's available groups for new cases, including Link Remote. The choice persists across creation tabs and is saved before automatically starting a session. Failed saves report that the case exists and prevent automatic startup in the wrong group. + +Rendered desktop/mobile validation used the production markup and styles with the candidate session UI module. Session connections and writes were intercepted. These screenshots use sample group names. Browser regressions cover local/remote linking, stale responses, existing-case editing, and failed group saves. + +![Desktop](desktop.png) + +![Mobile](mobile.png) diff --git a/docs/reports/new-case-default-group/desktop.png b/docs/reports/new-case-default-group/desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..8ce66c112766b70f5ea740c0943ccab2d0bcdef7 GIT binary patch literal 61996 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 )fYh6{4@ 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 literal 0 HcmV?d00001 diff --git a/docs/reports/new-case-default-group/mobile.png b/docs/reports/new-case-default-group/mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..fa458c4eaf30f18a1538d65e767536ab4e62f68b GIT binary patch literal 47164 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 VSIQv(