Skip to content

fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598) - #1606

Merged
thymikee merged 4 commits into
mainfrom
fix/ios-keyboard-dismiss-safe-area-fallback-1598
Aug 4, 2026
Merged

fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598)#1606
thymikee merged 4 commits into
mainfrom
fix/ios-keyboard-dismiss-safe-area-fallback-1598

Conversation

@thymikee

@thymikee thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member

Important

Revised after review round 2: the safe-area tap fallback described below was REMOVED (14ad…): background-tap dismissal is deliberately unsupported — no query can prove a coordinate side-effect-free. dismissKey is the only mechanism; when absent, keyboard dismiss reports UNSUPPORTED_OPERATION with recovery guidance. The live matrix below stands as the evidence that motivated both the attempt and its removal.

Summary

  • iOS keyboard dismiss already tapped the keyboard's own Hide/Dismiss/Done key when the AX tree exposed one, but iPhone's default software keyboard has no such key, so the command returned UNSUPPORTED_OPERATION on the common case. Adds a new snapshot-derived safe-area tap as a disclosed last-resort fallback, computed to land outside both the keyboard and every currently-hittable element the runner knows about — a safe no-op even when it fails to dismiss.
  • The response now discloses which mechanism actually fired: mechanism: 'dismissKey' | 'safeAreaTap', threaded through the CLI/daemon dispatch path (src/core/dispatch.ts), the SDK runtime.backend surface (src/commands/system/runtime/system.ts), and session-event transcript summaries (src/daemon/session-event-action-presentation.ts). UNSUPPORTED_OPERATION now says both mechanisms were tried.
  • Motivated by a deeper benchmark classification (see "Corroborating evidence" below): the missing dismiss reproducibly let agents publish with a live QuickType predictive-text bar still up, corrupting the published text.

Live-validation matrix

Before picking a design I live-tested every candidate mechanism against real apps on throwaway simulators (ad-kbd-fix iPhone 17 Pro, ad-kbd-fix-ipad iPad Pro — both deleted, see Cleanup below), since this codebase's own gesture-viewport code (frameAvoidingKeyboard in RunnerTests+Interaction.swift) already deliberately avoids touching the keyboard region, which was a signal that touching the keyboard is not straightforward.

Candidate Test Result
1. Tap the keyboard's own dismiss key (pre-existing) iPad Contacts "New Contact" form, First name field Works. iPad's split/floating keyboard exposes a "Hide keyboard" key; keyboard dismiss reported Keyboard dismissed via its dismiss key, keyboard fully gone. Kept as the primary mechanism.
2a. Hardware Escape key (app.typeKey(.escape, modifierFlags: []), public XCTest API) iPhone Settings search field Sent without exception, keyboard stayed visible. Escape-to-dismiss is a hardware-keyboard behavior; the simulator's software keyboard doesn't wire it.
2b/3. Swipe-down starting on the keyboard (interactive dismissal, via the existing RunnerSynthesizedGesture continuous-drag bridge) iPhone Settings search, iPhone Contacts "New Contact" Gesture reported "performed" both times; keyboard stayed visible both times. Interactive keyboard dismissal is an opt-in UIScrollView.keyboardDismissMode behavior, not a system-wide guarantee — neither screen had it enabled.
3. Simulator hardware-keyboard toggle (defaults write com.apple.iphonesimulator ConnectHardwareKeyboard) Not executed against a live window Written then immediately reverted without relaunching Simulator.app, because this is a shared-host, global preference and other sessions had simulator windows open (bench-golden-bsky-latest, ad-bsky-repro, another iPhone 17 Pro) — toggling it live could have altered their typing behavior. Rejected as a runner-side fix regardless: it's Simulator-only (no physical-device equivalent) and the runner process can't reach the host Simulator.app UI to toggle it itself.
2c. Private AX performAction:onElement:value:error: with a guessed action name (AXHideKeyboard) iPhone Contacts "New Contact" Runtime reflection found a real _accessibilityHideKeyboard selector and the AX client's performAction:onElement:value:error: method, so I tried invoking it — the call hung for the full 90s daemon timeout and the runner process was force-killed. App state was undamaged afterward, but a wrong guess on a private, undocumented action name costs a full timeout + forced runner restart per attempt. Rejected as too risky to ship without official documentation of the correct action name/target.
4. Snapshot-derived safe-area tap (shipped) iPhone Settings search, Safari address bar, Contacts form Did not dismiss on any of the three (none of these particular system screens wire a background-tap-to-dismiss handler), but never touched any interactive element and never altered field text. Shipped anyway because (a) it's provably safe by construction — the pure RunnerKeyboardDismissSafeArea.safePoint only returns a point outside the keyboard and outside every obstacle frame — and (b) many RN screens do implement Keyboard.dismiss()/Pressable-wraps-screen on background tap, which this fallback correctly benefits from even though the specific stock apps tested here don't.

Byte-exactness evidence (text preservation)

Addressing the benchmark corruption case directly: filled a multi-word string, then ran keyboard dismiss and re-snapshotted to confirm the field text is byte-exact afterward, on both the success and failure paths.

  • iPad, dismissKey succeeds: typed hello world testing into Contacts' First-name field → keyboard dismissKeyboard dismissed via its dismiss key → re-snapshot shows hello world testing unchanged, keyboard fully gone.
  • iPhone, safeAreaTap attempted, no native control available: typed hello from the benchmark into Contacts' First-name field (the coordinator's own repro string) → keyboard dismissUNSUPPORTED_OPERATION: Unable to dismiss the iOS keyboard: no dismiss key was exposed and a safe-area tap did not resign it → re-snapshot shows hello from the benchmark unchanged — no corruption, no spurious appended word.

I was not able to reproduce the exact QuickType-predictive-bar corruption from the bsky repro (bsky wasn't available in this environment, and Settings/Contacts' text fields don't surface a live predictive-completion bar the way the bsky compose view does), so I can't directly confirm "resigning does not commit a pending QuickType suggestion" for that specific UI. What I can confirm: the fix's failure path no longer lets an agent believe dismiss succeeded when it didn't — it now honestly reports UNSUPPORTED_OPERATION, which is the behavior that would let an agent-side workflow choose not to publish immediately. Closing the loop on the QuickType-commit question specifically (does resignFirstResponder via the dismiss-key tap commit vs. discard a pending predictive suggestion) is filed as a follow-up — it needs the actual bsky compose view (or an RN fixture with a live predictive bar) to observe.

Response shape

// success, dismiss-key tap
{ "message": "Keyboard dismissed via its dismiss key", "wasVisible": true, "dismissed": true, "visible": false, "mechanism": "dismissKey" }

// success, safe-area tap (app implements background-tap dismiss)
{ "message": "Keyboard dismissed via a safe-area tap (no dismiss key was available)", "wasVisible": true, "dismissed": true, "visible": false, "mechanism": "safeAreaTap" }

// both mechanisms exhausted
{ "code": "UNSUPPORTED_OPERATION", "message": "Unable to dismiss the iOS keyboard: no dismiss key was exposed and a safe-area tap did not resign it" }

mechanism is threaded through BackendKeyboardResult (src/backend.ts), the public KeyboardCommandResult contract (packages/contracts/src/keyboard.ts), and surfaced in session-event summaries/details ("Dismissed keyboard (safe-area tap)").

Also updated

  • keyboard dismiss and keyboard --help text (src/cli/parser/cli-help.ts, src/commands/system/index.ts) now describe the fallback and mechanism disclosure.
  • apple/runner/RUNNER_PROTOCOL.md example error message updated to match.

Test plan

  • New Swift pure-logic unit tests for RunnerKeyboardDismissSafeArea.safePoint (7 cases: clear center, obstructed-candidate fallback, all-candidates-obstructed → nil, keyboard-covers-screen → nil, empty window → nil, missing-keyboard-frame tolerance, obstacle-margin near-miss) — run via xcodebuild test-without-building against a real simulator (AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1): 9/9 passed (7 new + 2 pre-existing regression checks selected from the same file).
  • New TS tests: src/core/__tests__/dispatch-keyboard.test.ts (dismissKey/safeAreaTap/no-mechanism message+field cases), src/daemon/__tests__/session-event-action.test.ts (transcript summary/details disclosure), src/commands/system/runtime/system.test.ts (SDK runtime.backend surface).
  • pnpm check:quick (lint + typecheck across workspace) — clean.
  • pnpm test:unit (full suite, run synchronously) — 603 test files / 5297 tests passed, 0 failures. (Two tests flagged by the slow-test gate as within the 2x load-variance band, not failing — pre-existing timing noise unrelated to this change: input-actions.test.ts fillAndroid retry, request-router-replay-scope.test.ts device retention.)
  • Live device evidence: iPad dismiss-key success (byte-exact text before/after) and iPhone safe-area-tap-exhausted failure path (byte-exact text before/after, honest UNSUPPORTED_OPERATION).
  • Follow-up (not in this PR): reproduce the bsky QuickType-commit corruption directly and confirm resignFirstResponder does not commit a pending predictive suggestion.

Cleanup

Both throwaway simulators created for live validation were deleted at the end of the session: ad-kbd-fix (iPhone 17 Pro) and ad-kbd-fix-ipad (iPad Pro). Confirmed via xcrun simctl list devices — neither appears. No sessions, daemons, or state under this repo's default ~/.agent-device dir were left bound to them.

The runner already tapped a keyboard's own Hide/Dismiss/Done key when the
AX tree exposed one, but iPhone's default software keyboard has no such
key, so `keyboard dismiss` returned UNSUPPORTED_OPERATION on the common
case and agents proceeded with the keyboard (and any live QuickType
predictive-text bar) still up.

Live-validated on throwaway simulators before choosing a design: hardware
escape key (no effect without a connected hardware keyboard), swipe-down
starting on the keyboard (does not trigger UIKit's interactive dismissal
on Settings/Safari/Contacts), and a private
`performAction:onElement:value:error:` AX call (hung the runner for 90s on
a guessed action name, force-killed by the daemon timeout) were all ruled
out. The dismiss-key tap remains the primary mechanism (iPad, or any app
with an inputAccessoryView Done/Cancel button); a new snapshot-derived
safe-area tap is added as the disclosed last resort, computed to land
outside both the keyboard and every currently-hittable element so it is a
safe no-op even when it fails to dismiss.

The response now discloses which mechanism actually fired
(`mechanism: 'dismissKey' | 'safeAreaTap'`) across the CLI/daemon dispatch
path, the SDK runtime.backend surface, and session-event summaries, so
callers can tell a real dismiss-key press apart from a best-effort tap.
UNSUPPORTED_OPERATION now says both mechanisms were tried.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.96 MB 1.96 MB +712 B
JS gzip 627.1 kB 627.4 kB +288 B
npm tarball 748.7 kB 750.2 kB +1.5 kB
npm unpacked 2.62 MB 2.63 MB +5.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 27.3 ms +0.8 ms
CLI --help 63.1 ms 64.0 ms +0.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/cli-help.js +317 B +136 B
dist/src/context.js +187 B +62 B
dist/src/screenshot-result.js +68 B +37 B
dist/src/runtime.js +96 B +35 B
dist/src/registry.js +44 B +18 B

@thymikee

thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

P1: The safe-area tap is not safe by construction. keyboardDismissObstacleFrames excludes hittable .other/generic containers and multiple actionable roles/ancestors. A React Native Pressable without an explicit role commonly appears as a hittable Other; a tappable parent around static text can likewise cover the selected (50% width, y=50) point. The coordinate tap can therefore invoke arbitrary app behavior while this command claims it only taps an AX-empty area—contradicting the keyboard-dismiss safe-control invariant and the help warning about tappable parents. An allowlist of element types cannot establish emptiness: reject a candidate covered by any hittable .any element/ancestor and pin Other/tappable-parent cases, or keep generic background-tap dismissal unsupported if that proof cannot coexist with recognizing background Pressables.

Exact-head Lint & Format and Fallow are also owner-action red.

oxfmt on three touched files; buildKeyboardActionSummary split so the
dismiss wording (incl. the safeAreaTap mechanism disclosure) lives in its
own helper below the complexity threshold.
@thymikee

thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

CI fixes pushed in 5676e81: oxfmt on the three touched files, and buildKeyboardActionSummary split so the dismiss wording (incl. the safeAreaTap mechanism disclosure) sits in its own helper under the complexity threshold — which also let fallow's inherited-finding exclusion correctly absorb the untouched handleSettingsCommand flag. Fallow now reports no issues in the 16 changed files; lint and format clean.

🤖 Addressed by Claude Code

…eview P1)

A role allowlist cannot prove a point is AX-empty: an unlabeled RN
Pressable surfaces as a hittable Other, and a tappable parent can cover a
point its static-text child does not. Every known element frame now counts
as an obstacle regardless of role or hittability, with only ~window-sized
structural frames exempt (isStructuralRootFrame, 95% coverage) — exempting
those is what keeps the rule satisfiable, and a genuinely tappable
full-screen backdrop staying exempt is the disclosed, accepted behavior of
this fallback. One .any resolution replaces ten typed queries (single tree
snapshot, no per-element isHittable round trips), so the stricter rule is
also cheaper.
@thymikee

thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

P1 addressed in 4deb5dc. The allowlist is gone: every known element frame is now an obstacle, regardless of role or hittability — one .any resolution (a single tree snapshot, cheaper than the ten typed queries it replaces, with no per-element isHittable round trips) feeds the same pure safePoint geometry. An unlabeled RN Pressable (hittable Other), a tappable parent over static text, images, cells — all veto candidates now.

The one deliberate exemption, and why it must exist: ~window-sized structural frames (isStructuralRootFrame, ≥95% coverage both axes — Application/Window roots, full-bleed backdrops). Every candidate lies inside those by construction, so counting them would make the rule unsatisfiable and the fallback dead code. That leaves exactly one honest residual: a genuinely tappable full-screen backdrop is not routed around — which is the disclosed, accepted behavior of this fallback (on many RN screens that backdrop tap is precisely what resigns the keyboard), and the mechanism: safeAreaTap disclosure exists so callers can weigh it.

New in-bundle test pins the boundary (roots and full-bleed frames exempt; banners, half-screen sheets, and small controls are obstacles); all 7 keyboard safe-area tests pass on a live simulator build.

🤖 Addressed by Claude Code

@thymikee

thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

P1 remains at current head 4deb5dcef: replacing the role allowlist with .any fixes the omitted-Other case, but isStructuralRootFrame then excludes every frame covering at least 95% of the window regardless of element type or actionability. A full-screen RN Pressable/button/backdrop is therefore deliberately removed from the obstacle set; the coordinate tap can navigate, submit, or otherwise mutate the app (and may report success merely because that mutation hid the keyboard) while the command and help still promise a safe AX-empty tap. mechanism: safeAreaTap discloses the route after the side effect; it does not make that side effect safe.

Please distinguish inert Application/Window roots from actionable full-screen elements rather than inferring structure from geometry, or keep generic background-tap dismissal unsupported. Pin the production obstacle mapping with a full-screen actionable Other/Pressable counterexample, including counterfactual-red evidence. The live matrix currently proves only safe-area-tap exhaustion on stock apps, not a successful safe-area dismissal on the claimed RN path; exact-head iOS Smoke is also still pending.

…#1606 review P1, round 2)

No geometry or role query can prove a coordinate is side-effect-free: after
the any-element rule, the structural-root exemption still deliberately
removed full-screen actionable elements (RN Pressable backdrops) from the
obstacle set, so the tap could navigate or submit — and report success
because the mutation hid the keyboard. Per review, generic background-tap
dismissal is now explicitly unsupported: the dismiss key is the only
mechanism the runner vouches for, UNSUPPORTED_OPERATION says so and steers
callers to press-the-next-target / keyboard enter, and the mechanism field
narrows to 'dismissKey'. Unrecognized wire mechanisms degrade to the bare
message and are dropped from event details.
@thymikee

thymikee commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Round-2 P1 accepted in full — resolved by taking the review's second option in a58640f: the safe-area tap is removed entirely; generic background-tap dismissal is now explicitly unsupported.

The deciding logic: after the any-element rule, the only way to keep the fallback satisfiable was the structural-root exemption — and as you showed, geometry cannot distinguish an inert Window root from a full-screen actionable Pressable, so the exemption reintroduced exactly the unsafe tap, worse: one that can "succeed" because its mutation hid the keyboard. Meanwhile the live matrix already showed zero successful safe-area dismissals on stock apps — the mechanism's only theorized win (RN backdrops) is precisely what the safety invariant forbids tapping. A fallback that is only effective when it is unsafe should not exist.

What remains: dismissKey (live-verified on iPad, byte-exact text) as the sole mechanism; mechanism narrowed to 'dismissKey' in the contracts; unrecognized wire mechanisms degrade to the bare message and are dropped from event details (pinned by tests); UNSUPPORTED_OPERATION now states that background taps are never attempted and steers to pressing the next target / keyboard enter; help/protocol docs and the PR body updated to match. The counterexample-pin request is moot with the obstacle mapping deleted. Gates: full unit suite 5297/5297, fallow/lint/format clean, runner test-build compiles.

🤖 Addressed by Claude Code

@thymikee
thymikee merged commit 543e9f8 into main Aug 4, 2026
31 checks passed
@thymikee
thymikee deleted the fix/ios-keyboard-dismiss-safe-area-fallback-1598 branch August 4, 2026 20:30
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-04 20:30 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant