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..0be982790
--- /dev/null
+++ b/docs/reports/mobile-tab-keyboard/lifecycle-rca/README.md
@@ -0,0 +1,88 @@
+# Follow-up RCA: native keyboard still fails (#35)
+
+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
+
+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**; 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,
+ 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. 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/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/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/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/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/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
+}
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/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/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/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/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.
+
+
+
+
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 000000000..8ce66c112
Binary files /dev/null and b/docs/reports/new-case-default-group/desktop.png differ
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 000000000..fa458c4ea
Binary files /dev/null and b/docs/reports/new-case-default-group/mobile.png differ
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 0458d90a6..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);
+ }
}
/**
@@ -11627,8 +11631,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('session-tab');
+ else this.terminal.focus();
+ } else this.terminal.focus();
+ };
if (this._terminalViewerActivity?.runWithoutActivation) {
this._terminalViewerActivity.runWithoutActivation(focusTerminal);
} else {
@@ -11650,7 +11664,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 +11712,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 +11761,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 +12319,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/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js
index d8c11581c..32321a4ef 100644
--- a/src/web/public/mobile-handlers.js
+++ b/src/web/public/mobile-handlers.js
@@ -303,15 +303,28 @@ 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-v11 ua=${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}`
+ );
+ }
// Simple focus handler - scroll input into view after keyboard appears
this._focusinHandler = (e) => {
const target = e.target;
+ if (this._isTerminalInput(target)) this._traceKeyboardState('focusin-terminal');
if (!this.isInputElement(target)) return;
// Wait for keyboard animation, then scroll input into view
@@ -327,7 +340,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 +361,7 @@ const KeyboardHandler = {
const active = document.activeElement;
if (this._isTerminalInput(active)) active.blur?.();
this.forceKeyboardHidden();
+ this._traceKeyboardState('foreground');
};
document.addEventListener('visibilitychange', this._visibilityHandler);
@@ -456,7 +471,16 @@ const KeyboardHandler = {
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
+ // Reconciliation after foreground recovery can see the OLD shrunken
+ // viewport again. Only a newly observed shrink can own input focus;
+ // otherwise its eventual restore could cancel a newer focus request.
+ this._keyboardFocusOwner =
+ currentHeight < this.lastViewportHeight && typeof app !== 'undefined'
+ ? app._mobileKeyboardFocusGeneration
+ : undefined;
+ this._keyboardFocusOwnerInput = this._keyboardFocusOwner !== undefined ? document.activeElement : undefined;
this.onKeyboardShow();
+ this._traceKeyboardState('viewport-show');
}
// Keyboard hidden (viewport grew back close to initial)
// Use 100px threshold (not 50) to handle iOS address bar drift,
@@ -476,6 +500,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,6 +710,27 @@ const KeyboardHandler = {
/** Called when keyboard hides */
onKeyboardHide() {
+ // Native dismiss keeps xterm focused on iOS. Release that old editing
+ // session before the next tap, but never a newer open request: its focus
+ // generation differs from the request that produced this visible keyboard.
+ const owner = this._keyboardFocusOwner;
+ const ownerInput = this._keyboardFocusOwnerInput;
+ this._keyboardFocusOwner = undefined;
+ this._keyboardFocusOwnerInput = undefined;
+ const release =
+ Number.isSafeInteger(owner) &&
+ typeof app !== 'undefined' &&
+ owner === app._mobileKeyboardFocusGeneration &&
+ ownerInput === document.activeElement &&
+ app._isMobileTerminalInputFocused?.();
+ // Retain the last close decision even if subsequent failed taps fill the
+ // breadcrumb ring. No input contents are recorded.
+ this._lastKeyboardHide = `owner:${owner},generation:${typeof app !== 'undefined' ? app._mobileKeyboardFocusGeneration : undefined},same:${ownerInput === document.activeElement},release:${!!release}`;
+ if (release) {
+ app._blurMobileTerminalInput();
+ this._traceKeyboardState('owned-hide-release');
+ }
+
// Hide keyboard accessory bar
if (typeof KeyboardAccessoryBar !== 'undefined') {
KeyboardAccessoryBar.hide();
@@ -876,20 +922,86 @@ 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';
+ this._traceKeyboardInputBox(reason);
+ _crashDiag.log(
+ `KEYBOARD_STATE: v11 ${reason} focus=${focus} visible=${this.keyboardVisible} height=${Math.round(viewport?.height || 0)} width=${Math.round(viewport?.width || 0)} offset=${Math.round(viewport?.offsetTop || 0)} scale=${viewport?.scale || 1} layout=${window.innerHeight} baseline=${Math.round(this.initialViewportHeight)} min=${Math.round(this._keyboardMinHeight)} owner=${this._keyboardFocusOwner} request=${typeof app !== 'undefined' ? app._mobileKeyboardFocusGeneration : undefined} hide=${this._lastKeyboardHide || 'none'} page=${document.visibilityState}`
+ );
+ },
+
+ _traceKeyboardInputBox(reason) {
+ if (typeof _crashDiag === 'undefined') return;
+ try {
+ const active = document.activeElement;
+ const input = this._isTerminalInput(active) ? active : typeof app !== 'undefined' ? app.terminal?.textarea : null;
+ if (!input?.getBoundingClientRect) return;
+ const rect = input.getBoundingClientRect();
+ const viewport = window.visualViewport;
+ let left = viewport?.offsetLeft || 0;
+ let top = viewport?.offsetTop || 0;
+ let right = left + (viewport?.width || window.innerWidth);
+ let bottom = top + (viewport?.height || window.innerHeight);
+ for (let parent = input.parentElement; parent; parent = parent.parentElement) {
+ const style = getComputedStyle(parent);
+ const box = parent.getBoundingClientRect();
+ if (/auto|scroll|hidden|clip/.test(style.overflowX)) {
+ left = Math.max(left, box.left);
+ right = Math.min(right, box.right);
+ }
+ if (/auto|scroll|hidden|clip/.test(style.overflowY)) {
+ top = Math.max(top, box.top);
+ bottom = Math.min(bottom, box.bottom);
+ }
+ }
+ const rounded = (value) => Math.round(value);
+ _crashDiag.log(
+ `KEYBOARD_BOX: v11 ${reason} ${JSON.stringify({
+ rect: [rect.left, rect.top, rect.right, rect.bottom].map(rounded),
+ clip: [left, top, right, bottom].map(rounded),
+ intersects:
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.right > left &&
+ rect.left < right &&
+ rect.bottom > top &&
+ rect.top < bottom,
+ active: active === input,
+ activeTag: active?.tagName || 'none',
+ pageFocus: document.hasFocus(),
+ rendered: input.getClientRects().length,
+ inert: !!input.closest('[inert]'),
+ readonly: input.readOnly,
+ disabled: input.disabled,
+ inputMode: input.inputMode,
+ appHeight: getComputedStyle(document.documentElement).getPropertyValue('--app-height').trim(),
+ menu: !!document.querySelector('.header.mobile-utilities-expanded'),
+ })}`
+ );
+ } catch {
+ _crashDiag.log(`KEYBOARD_BOX: v11 ${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/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/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 12a692dfa..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();
@@ -1647,6 +1649,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
@@ -1657,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
@@ -7523,21 +7531,81 @@ Object.assign(CodemanApp.prototype, {
return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine);
},
- _focusMobileTerminalInput() {
+ _prepareMobileTerminalFocusGesture() {
+ this._mobileNativeClickFocusRequest = null;
+ if (!this._isMobileTerminalInputFocused() || this._mobileKeyboardIsUp()) return false;
+ this._blurMobileTerminalInput();
+ if (typeof KeyboardHandler !== 'undefined') KeyboardHandler._traceKeyboardState?.('gesture-release');
+ return true;
+ },
+
+ _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,
- // 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.
- 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 {
this.terminal?.focus();
}
+ // A tab/search choice can reuse the visible keyboard without another
+ // viewport shrink. Transfer dismiss ownership only after that request has
+ // settled; a pending close must still leave a newer opening focused (#35).
+ const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null;
+ const viewport = window.visualViewport;
+ const transferGeneration = this._mobileKeyboardFocusGeneration;
+ const transferHeight = viewport?.height;
+ const transferWidth = viewport?.width;
+ if (source === 'session-tab' && !retainsFocus && handler?.keyboardVisible &&
+ Number.isSafeInteger(handler._keyboardFocusOwner) &&
+ transferHeight > 0 && transferHeight <= handler._keyboardMinHeight + 2 &&
+ handler.initialViewportHeight - transferHeight > 150) {
+ setTimeout(() => {
+ if (this._mobileKeyboardFocusGeneration !== transferGeneration ||
+ document.visibilityState !== 'visible' || !handler.keyboardVisible ||
+ !Number.isSafeInteger(handler._keyboardFocusOwner) ||
+ document.activeElement !== target || !this._isMobileTerminalInputFocused() ||
+ viewport?.height !== transferHeight || viewport?.width !== transferWidth ||
+ viewport.height > handler._keyboardMinHeight + 2) return;
+ handler._keyboardFocusOwner = transferGeneration;
+ handler._keyboardFocusOwnerInput = target;
+ handler._traceKeyboardState?.('tab-focus-owner-transfer');
+ }, handler.KEYBOARD_FOCUS_SETTLE_MS || 350);
+ }
+ // Script window.focus() was declined on the phone. Preserve the native
+ // compatibility mouse default that can activate its page, then focus from
+ // that trusted click. Never forward those mouse events to the CLI.
+ this._mobileNativeClickFocusRequest = source === 'terminal-tap' &&
+ MobileDetection.isIOS?.() && document.visibilityState === 'visible' &&
+ typeof document.hasFocus === 'function' && !document.hasFocus() &&
+ typeof navigator !== 'undefined' && navigator.userActivation?.isActive === true &&
+ !handler?.keyboardVisible && target ? {
+ target, element: this.terminal?.element, sessionId: this.activeSessionId,
+ generation: this._mobileKeyboardFocusGeneration,
+ expires: performance.now() + (window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450),
+ } : null;
+ if (typeof _crashDiag !== 'undefined') {
+ const handler = typeof KeyboardHandler !== 'undefined' ? KeyboardHandler : null;
+ _crashDiag.log(`KEYBOARD_FOCUS: focused=${this._isMobileTerminalInputFocused()} gesture=${typeof navigator !== 'undefined' ? navigator.userActivation?.isActive : undefined} cached=${handler?.keyboardVisible} retains=${retainsFocus} height=${Math.round(window.visualViewport?.height || 0)} baseline=${Math.round(handler?.initialViewportHeight || 0)} min=${Math.round(handler?._keyboardMinHeight || 0)}`);
+ // Observe delayed native viewport/focus changes without retrying focus.
+ const request = this._keyboardFocusRequestId = (this._keyboardFocusRequestId || 0) + 1;
+ handler?._traceKeyboardState?.(`request-${request}-${source}`);
+ for (const delay of [350, 1000]) {
+ setTimeout(() => {
+ if (request !== this._keyboardFocusRequestId || document.visibilityState === 'hidden') return;
+ handler?._traceKeyboardState?.(`request-${request}-after-${delay}ms`);
+ }, delay);
+ }
+ }
},
/**
@@ -7550,6 +7618,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;
},
@@ -8251,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/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'));
diff --git a/test/mobile-keyboard-stale-state.test.ts b/test/mobile-keyboard-stale-state.test.ts
index d59225b58..21258d21e 100644
--- a/test/mobile-keyboard-stale-state.test.ts
+++ b/test/mobile-keyboard-stale-state.test.ts
@@ -82,9 +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: () => {} },
+ app,
navigator: {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/130 Mobile/15E148',
maxTouchPoints: 5,
@@ -127,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 => ({
@@ -227,6 +237,140 @@ 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, requestFocus } = loadKeyboardHandler();
+ const blur = vi.fn(() => {
+ doc.activeElement = null;
+ });
+ const input = terminalTextarea(blur);
+ requestFocus(input);
+ resize(FULL - KEYBOARD, FULL);
+ // Explicit dismiss, then a new focus before the old close animation ends.
+ input.blur();
+ fire('focusout');
+ advance(delay);
+ requestFocus(input);
+ resize(FULL, FULL);
+ expect(doc.activeElement).toBe(input);
+ expect(blur).toHaveBeenCalledTimes(1);
+ expect(handler.keyboardVisible).toBe(false);
+ // The new keyboard's own opening resize is still classified correctly.
+ resize(FULL - KEYBOARD, FULL);
+ expect(handler.keyboardVisible).toBe(true);
+ advance(500);
+ expect(doc.activeElement).toBe(input);
+ expect(handler.keyboardVisible).toBe(true);
+ });
+ }
+
+ it('releases native-dismissed focus before the next gesture when its request still owns the keyboard', () => {
+ const { handler, resize, doc, requestFocus } = loadKeyboardHandler();
+ const blur = vi.fn(() => {
+ doc.activeElement = null;
+ });
+ const input = terminalTextarea(blur);
+ requestFocus(input);
+ resize(FULL - KEYBOARD, FULL);
+ // OS dismiss restores geometry but leaves the original editable DOM focus.
+ resize(FULL, FULL);
+ expect(doc.activeElement).toBeNull();
+ expect(blur).toHaveBeenCalledTimes(1);
+ expect(handler.initialViewportHeight).toBe(FULL);
+ // A subsequent gesture starts from released focus rather than same-task refocus.
+ requestFocus(input);
+ resize(FULL - KEYBOARD, FULL);
+ expect(doc.activeElement).toBe(input);
+ expect(handler.keyboardVisible).toBe(true);
+ });
+
+ it('does not release a different terminal input that acquired focus natively', () => {
+ const { resize, doc, requestFocus } = loadKeyboardHandler();
+ const first = terminalTextarea(() => {
+ doc.activeElement = null;
+ });
+ const second = terminalTextarea(() => {
+ doc.activeElement = null;
+ });
+ requestFocus(first);
+ resize(FULL - KEYBOARD, FULL);
+ // A tile or another native input can focus without using the main helper.
+ doc.activeElement = second;
+ resize(FULL, FULL);
+ expect(doc.activeElement).toBe(second);
+ });
+
+ it('preserves open-keyboard calibration when reconnect reinitializes the listeners', () => {
+ const { handler, resize, doc, requestFocus } = loadKeyboardHandler();
+ const input = terminalTextarea(() => {
+ doc.activeElement = null;
+ });
+ requestFocus(input);
+ resize(FULL - KEYBOARD, FULL);
+ handler.cleanup();
+ handler.init();
+ resize(FULL - KEYBOARD, FULL);
+ expect(handler.initialViewportHeight).toBe(FULL);
+ expect(handler.keyboardVisible).toBe(true);
+ expect(doc.activeElement).toBe(input);
+ });
+
+ it('does not let reconciliation of an old shrunken viewport own a newer focus request', () => {
+ const { handler, resize, fire, doc, requestFocus } = loadKeyboardHandler();
+ const input = terminalTextarea(() => {
+ doc.activeElement = null;
+ });
+ requestFocus(input);
+ resize(FULL - KEYBOARD, FULL);
+ doc.visibilityState = 'hidden';
+ fire('visibilitychange');
+ doc.visibilityState = 'visible';
+ fire('visibilitychange');
+ requestFocus(input);
+ // No fresh shrink occurred: this is still the previous keyboard geometry.
+ resize(FULL - KEYBOARD, FULL);
+ resize(FULL, FULL);
+ expect(doc.activeElement).toBe(input);
+ expect(handler.keyboardVisible).toBe(false);
+ });
+
+ it('preserves keyboard-free calibration across foreground recovery before closing resize', () => {
+ const { handler, resize, fire, doc } = loadKeyboardHandler();
+ doc.activeElement = terminalTextarea(() => {
+ doc.activeElement = null;
+ });
+ resize(FULL - KEYBOARD, FULL);
+ doc.visibilityState = 'hidden';
+ fire('visibilitychange');
+ doc.visibilityState = 'visible';
+ fire('visibilitychange');
+ expect(handler.initialViewportHeight).toBe(FULL);
+ expect(handler.keyboardVisible).toBe(false);
+ // Reopen before restoration: the same smaller viewport must remain detectable.
+ doc.activeElement = terminalTextarea();
+ resize(FULL - KEYBOARD, FULL);
+ expect(handler.keyboardVisible).toBe(true);
+ expect(handler.initialViewportHeight).toBe(FULL);
+ });
+
+ it('keeps calibration when the focus-out fallback fires before the closing animation', () => {
+ const { handler, resize, fire, advance, doc } = loadKeyboardHandler();
+ doc.activeElement = terminalTextarea();
+ resize(FULL - KEYBOARD, FULL);
+ doc.activeElement = null;
+ fire('focusout');
+ advance(500);
+ expect(handler.initialViewportHeight).toBe(FULL);
+ doc.activeElement = terminalTextarea();
+ resize(FULL - KEYBOARD, FULL);
+ expect(handler.keyboardVisible).toBe(true);
+ resize(FULL - TOOLBARS, FULL - TOOLBARS);
+ expect(handler.keyboardVisible).toBe(false);
+ expect(handler.initialViewportHeight).toBe(FULL - TOOLBARS);
+ });
+});
+
// ── terminal-ui.js: the tap decision ──────────────────────────────────────
function loadTapApp(opts: { keyboardVisible: boolean }) {
const CodemanApp = function CodemanApp(this: unknown) {};
@@ -281,7 +425,25 @@ 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('retains the opening generation when tapping an already-focused prompt with keyboard up', () => {
+ const { app, calls } = loadTapApp({ keyboardVisible: true });
+ app._mobileKeyboardFocusGeneration = 7;
+ app._focusMobileTerminalInput();
+ app._focusMobileTerminalInput();
+ expect(app._mobileKeyboardFocusGeneration).toBe(7);
+ expect(calls).toEqual(['focus', 'focus']);
+ });
+
+ it('advances the opening generation for a real focus transfer while keyboard remains up', () => {
+ const { app, doc } = loadTapApp({ keyboardVisible: true });
+ app._mobileKeyboardFocusGeneration = 7;
+ doc.activeElement = terminalTextarea(); // another terminal/CJK target owns focus
+ app._focusMobileTerminalInput();
+ expect(app._mobileKeyboardFocusGeneration).toBe(8);
+ expect(doc.activeElement).toBe(app.terminal.textarea);
+ });
+
+ it('prompt-row tap: requests fresh focus with blur followed by focus', () => {
const { app, calls } = loadTapApp({ keyboardVisible: false });
app._handleMobileTerminalTap(touch, app._mobileKeyboardIsUp(), 'input');
expect(calls).toEqual(['blur', 'focus']);
diff --git a/test/mobile-tab-keyboard-focus.browser.test.ts b/test/mobile-tab-keyboard-focus.browser.test.ts
index 8150865e0..34869dc69 100644
--- a/test/mobile-tab-keyboard-focus.browser.test.ts
+++ b/test/mobile-tab-keyboard-focus.browser.test.ts
@@ -6,12 +6,20 @@ import { chromium, type Browser, type Page } from 'playwright';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
-function method(file: string, name: string): string {
+function method(file: string, name: string, owner?: string): string {
const source = readFileSync(resolve(publicDir, file), 'utf8');
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
let result = '';
const visit = (node: ts.Node) => {
- if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) result = node.getText(tree);
+ if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) {
+ const declaration = ts.isObjectLiteralExpression(node.parent) ? node.parent.parent : null;
+ if (
+ !owner ||
+ (declaration && ts.isVariableDeclaration(declaration) && declaration.name.getText(tree) === owner)
+ ) {
+ result = node.getText(tree);
+ }
+ }
ts.forEachChild(node, visit);
};
visit(tree);
@@ -36,8 +44,8 @@ async function harness(browser: Browser, touch = true): Promise {
window._crashDiag={log(){}};window.focusCalls=0;
window.app={${methods},activeSessionId:'a',isSoloWindow:true,detachedSessions:new Set(),
sessions:new Map([['b',{name:'b'}]]),_selectGeneration:0,
- terminal:{focus(){focusCalls++;document.getElementById('terminal').focus()}},
- _clearTimer(){},_flushLocalEchoTo(){},_setTerminalLoadState(){},_cleanupPreviousSession(){throw Error('STOP_BEFORE_SESSION_IO')},markIdleAlertSeen(){}};
+ terminal:{options:{},focus(){focusCalls++;document.getElementById('terminal').focus()}},
+ _clearTimer(){},_flushLocalEchoTo(){},_setTerminalLoadState(){},_cleanupPreviousSession(){},_userScrollback(){return 100000},_renderHistoryTruncationBanner(){},_updateSseSubscription(){},_queueTransportActivationReconcile(){},_connectWs(){},hideWelcome(){},playTerminalEntrance(){throw Error('STOP_BEFORE_SESSION_IO')},markIdleAlertSeen(){}};
const select=app.selectSession;app.selectSession=(...args)=>select.apply(app,args).catch(e=>{
if(e.message!=='STOP_BEFORE_SESSION_IO')throw e;
});
@@ -82,12 +90,68 @@ describe('explicit mobile tab keyboard focus', () => {
try {
await page.evaluate('app.selectSession("b",{auto:true})');
expect(await page.evaluate('focusCalls')).toBe(0);
- await page.evaluate('KeyboardHandler.keyboardVisible=true;app.selectSession("b",{auto:true})');
+ await page.evaluate(
+ 'KeyboardHandler.keyboardVisible=true;app.activeSessionId="a";app.selectSession("b",{auto:true})'
+ );
expect(await page.evaluate('focusCalls')).toBe(1);
} finally {
await page.close();
}
});
+ it('reveals a webview-hidden terminal before spending the synchronous focus', async () => {
+ const page = await harness(browser);
+ try {
+ await page.evaluate(() => {
+ const input = document.getElementById('terminal')!;
+ const wrap = document.createElement('div');
+ wrap.id = 'hidden-terminal';
+ input.before(wrap);
+ wrap.append(input);
+ wrap.style.display = 'none';
+ (window as any).app._hideWebviewLayer = () => {
+ wrap.style.display = '';
+ };
+ });
+ await page.locator('#other').tap();
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+ it('retires the previous text selection guard on an explicit tab tap', async () => {
+ const page = await harness(browser);
+ try {
+ await page.evaluate(() => {
+ const app = (window as any).app;
+ app._touchSelecting = true;
+ app._clearTouchSelection = () => {
+ app._touchSelecting = false;
+ };
+ document.getElementById('terminal')!.addEventListener('focus', (e) => {
+ if (app._touchSelecting) (e.target as HTMLElement).blur();
+ });
+ });
+ await page.locator('#other').tap();
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+ it('uses the mobile input target after mode setup, including CJK input', async () => {
+ const page = await harness(browser);
+ try {
+ await page.evaluate(() => {
+ const field = document.createElement('textarea');
+ field.id = 'cjkInput';
+ document.body.append(field);
+ (window as any).app._focusMobileTerminalInput = () => field.focus();
+ });
+ await page.locator('#other').tap();
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('cjkInput');
+ } finally {
+ await page.close();
+ }
+ });
it('preserves desktop selection focus', async () => {
const page = await harness(browser, false);
try {
@@ -131,3 +195,455 @@ 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={${['init', 'cleanup', 'handleViewportResize', 'onKeyboardHide', 'onKeyboardShow', 'forceKeyboardHidden', '_isTerminalInput', '_editableHasFocus', 'isInputElement'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')},
+ keyboardVisible:true,initialViewportHeight:visualViewport.height,
+ _keyboardMinHeight:visualViewport.height-300,lastViewportWidth:visualViewport.width,
+ KEYBOARD_GONE_GROWTH_PX:150,KEYBOARD_FOCUS_SETTLE_MS:350,_releaseHeldRows(){return false},resetLayout(){hideEvents++},_scheduleViewportSettle(){},
+ _traceKeyboardState(){},updateLayoutForKeyboard(){},_deferViewportSettle(){},_scheduleViewportSettle(){}};
+ window.app={${['_mobileKeyboardIsUp', '_prepareMobileTerminalFocusGesture', '_installMobileTapMouseGuard', '_suppressTrustedTapMouseEvents', '_focusMobileTerminalInput', '_isMobileTerminalInputFocused', '_blurMobileTerminalInput'].map((n) => method('terminal-ui.js', n)).join(',')},
+ ${method('app.js', 'toggleMobileHeaderUtilities')},_syncMobileHelperTextareaToCursor(){},relayoutMobileSubagentWindows(){},
+ 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('releases stale input at touchstart and focuses only at touchend', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ const surface=document.createElement('div');surface.id='surface';
+ surface.style.cssText='height:80px;background:#ddd';document.body.append(surface);
+ app.terminal.element=surface;app._installMobileTapMouseGuard();
+ window.phases=[];
+ surface.addEventListener('touchstart',()=>{
+ app._prepareMobileTerminalFocusGesture();
+ phases.push(['touchstart',app._isMobileTerminalInputFocused()]);
+ });
+ surface.addEventListener('touchend',()=>{
+ app._suppressTrustedTapMouseEvents();
+ app._focusMobileTerminalInput();
+ phases.push(['touchend',app._isMobileTerminalInputFocused()]);
+ });
+ KeyboardHandler.keyboardVisible=false;
+ app.terminal.focus();
+ `);
+ await page.locator('#surface').tap();
+ expect(await page.evaluate('phases')).toEqual([
+ ['touchstart', false],
+ ['touchend', true],
+ ]);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+
+ it('retains dismiss ownership after prompt taps while the keyboard is open', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput();
+ const surface=document.createElement('div');surface.id='prompt-surface';
+ surface.style.cssText='height:60px;background:#ddd';document.body.append(surface);
+ app.terminal.element=surface;app._installMobileTapMouseGuard();
+ surface.addEventListener('touchstart',()=>app._prepareMobileTerminalFocusGesture());
+ surface.addEventListener('touchend',()=>{
+ app._suppressTrustedTapMouseEvents();app._focusMobileTerminalInput();
+ });
+ `);
+ for (let cycle = 0; cycle < 20; cycle++) {
+ await page.locator('#menu').tap();
+ await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()');
+ for (let tap = 0; tap < cycle % 3; tap++) await page.locator('#prompt-surface').tap();
+ expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===app._mobileKeyboardFocusGeneration')).toBe(
+ true
+ );
+ await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal');
+ expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true);
+ }
+ } finally {
+ await page.close();
+ }
+ });
+
+ it('transfers dismiss ownership after a settled tab choice with the keyboard open', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput('session-tab');
+ `);
+ for (let cycle = 0; cycle < 6; cycle++) {
+ await page.locator('#menu').tap();
+ await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()');
+ // A tab/search choice moves focus temporarily, while the OS keyboard stays up.
+ await page.locator('#menu').tap();
+ await page.waitForTimeout(420);
+ expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===app._mobileKeyboardFocusGeneration')).toBe(
+ true
+ );
+ await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal');
+ }
+ } finally {
+ await page.close();
+ }
+ });
+
+ for (const interruption of ['other-editor', 'new-request', 'closing-geometry']) {
+ it(`does not adopt a settled tab request after ${interruption}`, async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ app._focusMobileTerminalInput();
+ cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize();
+ window.oldOwner=KeyboardHandler._keyboardFocusOwner;
+ app.terminal.textarea.blur();app._focusMobileTerminalInput('session-tab');
+ `);
+ await page.evaluate((kind) => {
+ const w = window as any;
+ if (kind === 'other-editor') {
+ const other = document.createElement('textarea');
+ other.id = 'other-editor';
+ document.body.append(other);
+ other.focus();
+ } else if (kind === 'new-request') {
+ w.app.terminal.textarea.blur();
+ w.app._focusMobileTerminalInput();
+ } else {
+ w.cycleHeight += 50;
+ w.KeyboardHandler.handleViewportResize();
+ }
+ }, interruption);
+ await page.waitForTimeout(420);
+ expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner===oldOwner')).toBe(true);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe(
+ interruption === 'other-editor' ? 'other-editor' : 'terminal'
+ );
+ } finally {
+ await page.close();
+ }
+ });
+ }
+
+ for (const delay of [0, 80, 200, 349]) {
+ it(`protects a tab opening from the previous keyboard close at ${delay}ms`, async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ app._focusMobileTerminalInput();
+ cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize();
+ app.terminal.textarea.blur();
+ app._focusMobileTerminalInput('session-tab');
+ setTimeout(()=>{cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()},${delay});
+ `);
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false);
+ expect(await page.evaluate('KeyboardHandler._keyboardFocusOwner')).toBeUndefined();
+ } finally {
+ await page.close();
+ }
+ });
+ }
+
+ it('survives listener reconnect with the keyboard open, then dismisses and reopens', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ app._focusMobileTerminalInput();
+ cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize();
+ KeyboardHandler.cleanup();KeyboardHandler.init();KeyboardHandler.handleViewportResize();
+ `);
+ expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true);
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal');
+ await page.evaluate(`document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput()`);
+ await page.locator('#menu').tap();
+ await page.evaluate('cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+
+ it('preserves a new focus through each of 25 delayed closing viewport transitions', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;
+ window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput();
+ `);
+ for (let cycle = 0; cycle < 25; cycle++) {
+ await page.locator('#menu').tap();
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ await page.evaluate(`
+ cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize();
+ if (!KeyboardHandler.keyboardVisible) throw Error('show not detected');
+ app.terminal.textarea.blur();
+ `);
+ // Reopen before the preceding close has restored the visual viewport.
+ await page.waitForTimeout([0, 16, 80, 200][cycle % 4]);
+ await page.locator('#menu').tap();
+ await page.evaluate('cycleHeight=fullHeight;KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(false);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ }
+ // A hide notification must not take focus from a different editor.
+ await page.evaluate(`
+ const other=document.createElement('textarea');other.id='other-editor';
+ document.body.append(other);other.focus();KeyboardHandler.onKeyboardHide();
+ `);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('other-editor');
+ } finally {
+ await page.close();
+ }
+ });
+ it('retains calibration when returning before the close animation completes', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window.cycleHeight=visualViewport.height;window.fullHeight=cycleHeight;
+ Object.defineProperty(visualViewport,'height',{configurable:true,get:()=>cycleHeight});
+ KeyboardHandler.keyboardVisible=false;KeyboardHandler.init();
+ cycleHeight=fullHeight-300;KeyboardHandler.handleViewportResize();
+ window.fakeVisibility='hidden';
+ Object.defineProperty(document,'visibilityState',{configurable:true,get:()=>fakeVisibility});
+ document.dispatchEvent(new Event('visibilitychange'));
+ fakeVisibility='visible';document.dispatchEvent(new Event('visibilitychange'));
+ `);
+ expect(await page.evaluate('KeyboardHandler.initialViewportHeight===fullHeight')).toBe(true);
+ // A real resize may already have reconciled the still-obscured viewport.
+ expect(await page.evaluate(() => document.activeElement?.id)).not.toBe('terminal');
+ await page.evaluate(`
+ document.getElementById('menu').onclick=()=>app._focusMobileTerminalInput();
+ `);
+ await page.locator('#menu').tap();
+ await page.evaluate('KeyboardHandler.handleViewportResize()');
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+ it('observes a focus request without stealing a different editor after the tap', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ window._crashDiag={log(){}};window.observations=[];
+ KeyboardHandler._traceKeyboardState=reason=>observations.push(reason);
+ app._focusMobileTerminalInput('session-tab');
+ const editor=document.createElement('textarea');editor.id='other-editor';
+ document.body.append(editor);editor.focus();
+ `);
+ await page.waitForTimeout(1150);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('other-editor');
+ expect(await page.evaluate('observations')).toEqual([
+ 'viewport-hide',
+ 'request-1-session-tab',
+ 'request-1-after-350ms',
+ 'request-1-after-1000ms',
+ ]);
+ } finally {
+ await page.close();
+ }
+ });
+ it('records clipped input geometry without recording text or changing focus', async () => {
+ const page = await browser.newPage({ viewport: { width: 440, height: 852 } });
+ try {
+ await page.setContent(`
+
+
`);
+ await page.addScriptTag({
+ content: `
+ window.logs=[];window._crashDiag={log:message=>logs.push(message)};
+ window.KeyboardHandler={${['_traceKeyboardInputBox', '_isTerminalInput'].map((n) => method('mobile-handlers.js', n, 'KeyboardHandler')).join(',')}};
+ window.app={terminal:{textarea:document.getElementById('terminal')}};
+ document.getElementById('terminal').value='PRIVATE_TYPED_CONTENT';
+ document.getElementById('editor').focus();
+ KeyboardHandler._traceKeyboardInputBox('before-menu');
+ document.getElementById('terminal').style.top='20px';
+ KeyboardHandler._traceKeyboardInputBox('after-menu');
+ `,
+ });
+ const logs = (await page.evaluate('logs')) as string[];
+ expect(logs[0]).toContain('"intersects":false');
+ expect(logs[1]).toContain('"intersects":true');
+ expect(logs.join('')).not.toContain('PRIVATE_TYPED_CONTENT');
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('editor');
+ } finally {
+ await page.close();
+ }
+ });
+ it('permits native page activation on a lost-focus tap without duplicating CLI mouse actions', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate(`
+ MobileDetection.isIOS=()=>true;KeyboardHandler.keyboardVisible=false;
+ window.pageFocused=false;window.cliMouseEvents=0;window.nativeDefaults=[];
+ Object.defineProperty(document,'hasFocus',{configurable:true,value:()=>pageFocused});
+ window.focus=()=>{}; // Reproduce the phone declining script window.focus().
+ const surface=document.createElement('div');surface.id='native-surface';
+ surface.style.cssText='height:80px;background:#ddd';document.body.append(surface);
+ app.terminal.element=surface;app._installMobileTapMouseGuard();
+ surface.addEventListener('touchend',()=>{
+ app._suppressTrustedTapMouseEvents();app._focusMobileTerminalInput();
+ });
+ // Model only the native activation default: the cancelled v10 mousedown
+ // must not activate the page. This does not simulate an OS keyboard.
+ window.lastNativeMouseDown=null;
+ document.addEventListener('mousedown',event=>lastNativeMouseDown=event,true);
+ document.addEventListener('mouseup',()=>{
+ nativeDefaults.push(lastNativeMouseDown.defaultPrevented);
+ if(!lastNativeMouseDown.defaultPrevented)pageFocused=true;
+ },true);
+ for(const kind of ['mousedown','mouseup','click'])
+ surface.addEventListener(kind,()=>cliMouseEvents++);
+ `);
+ await page.locator('#native-surface').tap();
+ expect(await page.evaluate('nativeDefaults')).toEqual([false]);
+ expect(await page.evaluate('document.hasFocus()')).toBe(true);
+ expect(await page.evaluate('cliMouseEvents')).toBe(0);
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('terminal');
+ } finally {
+ await page.close();
+ }
+ });
+
+ for (const interruption of [
+ 'page-focused',
+ 'newer-request',
+ 'other-editor',
+ 'expired',
+ 'session-change',
+ 'selection',
+ ]) {
+ it(`cancels native-click recovery safely for ${interruption}`, async () => {
+ const page = await keyboardPage();
+ try {
+ await page.evaluate((kind) => {
+ const w = window as any;
+ w.MobileDetection.isIOS = () => true;
+ w.KeyboardHandler.keyboardVisible = false;
+ w.pageFocused = kind === 'page-focused';
+ w.cliMouseEvents = 0;
+ w.nativeDefaults = [];
+ Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => w.pageFocused });
+ const surface = document.createElement('div');
+ surface.id = 'native-surface';
+ surface.style.cssText = 'height:80px;background:#ddd';
+ document.body.append(surface);
+ w.app.terminal.element = surface;
+ w.app._installMobileTapMouseGuard();
+ surface.addEventListener('touchend', () => {
+ w.app._suppressTrustedTapMouseEvents();
+ w.app._focusMobileTerminalInput();
+ });
+ let down: MouseEvent;
+ document.addEventListener(
+ 'mousedown',
+ (event) => {
+ down = event;
+ if (kind === 'newer-request') w.app._mobileKeyboardFocusGeneration++;
+ if (kind === 'expired' && w.app._mobileNativeClickFocusRequest)
+ w.app._mobileNativeClickFocusRequest.expires = 0;
+ if (kind === 'session-change') w.app.activeSessionId = 'another-session';
+ if (kind === 'selection') w.app._touchSelectionActive = true;
+ if (kind === 'other-editor') {
+ const editor = document.createElement('textarea');
+ editor.id = 'editor';
+ document.body.append(editor);
+ editor.focus();
+ }
+ },
+ true
+ );
+ document.addEventListener('mouseup', () => w.nativeDefaults.push(down.defaultPrevented), true);
+ for (const type of ['mousedown', 'mouseup', 'click'])
+ surface.addEventListener(type, () => w.cliMouseEvents++);
+ }, interruption);
+ await page.locator('#native-surface').tap();
+ expect(await page.evaluate('nativeDefaults')).toEqual([true]);
+ expect(await page.evaluate('cliMouseEvents')).toBe(0);
+ expect(await page.evaluate('app._mobileNativeClickFocusRequest')).toBeNull();
+ if (interruption === 'other-editor')
+ expect(await page.evaluate(() => document.activeElement?.id)).toBe('editor');
+ } finally {
+ await page.close();
+ }
+ });
+ }
+
+ it('does not blur/refocus an actually shrunken keyboard viewport', async () => {
+ const page = await keyboardPage();
+ try {
+ await page.setViewportSize({ width: 440, height: 552 });
+ await page.evaluate(
+ 'KeyboardHandler._keyboardMinHeight=visualViewport.height;focusEvents=0;app._prepareMobileTerminalFocusGesture();app._focusMobileTerminalInput()'
+ );
+ expect(await page.evaluate('focusEvents')).toBe(0);
+ expect(await page.evaluate('hideEvents')).toBe(0);
+ expect(await page.evaluate('KeyboardHandler.keyboardVisible')).toBe(true);
+ } finally {
+ await page.close();
+ }
+ });
+});
diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts
index 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;
});
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();