Skip to content

fix(settle): keep iOS keyboard chrome classified through interactive capture - #3482

Merged
thymikee merged 5 commits into
mainfrom
fix/3469-ios-keyboard-chrome-interactive
Oct 13, 2026
Merged

thymikee merged 5 commits into
mainfrom
fix/3469-ios-keyboard-chrome-interactive

Conversation

@thymikee

@thymikee thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Summary

--settle on iOS lost keyboard-chrome classification in interactive captures: the regular projection dropped the Keyboard container as ineligible, re-parenting ~40 Key nodes to the app root where collectKeyboardChrome could not anchor them, and the interactive projection flattens QuickType/dock siblings beside app content entirely. Every keyboard-raising fill paid the 1.5s broad-transition confirmation window on a keyboard pop read as a screen replacement.

  • Add Keyboard to both projection eligibility sets (TS REGULAR_ELIGIBLE_TYPES + Swift eligibleInteractiveTypes, pinned by a shared swift: true golden case).

  • Classify the flattened surfaces from one iOS keyboard-role table in @agent-device/contracts/android-input-ownership (shared with tap-keyboard-occlusion.ts): surface roles chrome per node, subtree claims only under system-only hosts (UIKeyboardLayoutStar, TUIPredictionView), and MARKER roles (inputView placeholder, assistant view + page view) that own no subtree — an app can fill those hosts, so app content under them survives.

  • The broad-transition check now compares a chrome-free candidate signal (withoutSettleChrome), so the diff and the quiet-window verdict share one classifier.

Closes #3469.

13 files. Live evidence, iPhone 17 sim (iOS 27), Catalog fill id=catalog-search <kit|loaf> --settle, 10 alternating runs each:

before (main) after
median waitedMs 1734 754
captures 5 2–3
[key] diff lines 38 0
noise survivors 0 2 (floating toolbar, inert QuickType scroll wrapper)
[keyboard] line 0 1

Validation

Tested at faf754aa6. pnpm check:affected --run passed there (unit, layering, fallow, Swift differential with the new swift: true golden case, mutation-lane selection); the device table below was captured at e2ad8997e; a fresh live re-run at faf754aa6 is in the comments (median 1734 -> 786 ms, captures 5 -> 2, zero [key] lines). Live device run above executed the changed path end-to-end via the rebuilt CLI. No user-facing flags or help change. Known adjacency: the replacementSignal wiring touches the same call site #3470 proposes to restructure; no rule/default-window change is included here.

View guided diff Turn on auto-fix

…capture

The regular snapshot projection dropped the Keyboard container as
ineligible, re-parenting ~40 Key nodes to the app root where the settle
chrome classifier could no longer anchor them, and the interactive
projection flattens QuickType/dock siblings beside app content entirely.
Every keyboard-raising fill paid the 1.5s broad-transition confirmation
window on a keyboard pop counted as a screen replacement.

- Add Keyboard to both projection eligibility sets (TS + Swift, parity)
- Classify keyboard chrome by exact UIKit owned-class roles and the
  inputView placeholder marker (which owns no subtree)
- Compare the broad-transition check against a chrome-free candidate
  signal so a keyboard pop no longer disarms the quiet window
… format style

The shared keyboard capture fixture joins the capture-kit exports map, so
the layering exports manifest gains its subpath, and oxfmt ignores the
fixture file the way it already ignores runner-requests.json: one
captured node per reviewable diff line.
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.17 MB 5.17 MB -2.7 kB
Package (unpacked) 5.17 MB 5.17 MB -2.7 kB
Package (download) 1.55 MB 1.55 MB -1.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 24.9 ms 24.7 ms -0.2 ms
CLI --help 69.6 ms 68.2 ms -1.5 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 13 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/capture-kit/src/snapshot-chrome.ts">

<violation number="1" location="packages/capture-kit/src/snapshot-chrome.ts:165">
P2: This re-adds app-provided `UITextInputAssistantItem` buttons under `TUISystemInputAssistantView` after the above-keyboard pass exempted them, so settle filtering hides real app controls. Preserve the accessory exemption when adding owner-subtree indexes, or scope the walk to system-owned children.</violation>
</file>

<file name="packages/capture-kit/src/snapshot-chrome.fixtures.ts">

<violation number="1" location="packages/capture-kit/src/snapshot-chrome.fixtures.ts:12">
P3: The docstring's parenthetical node breakdown doesn't account for node 31. It enumerates app nodes 0-30, inputView host (32), QuickType (33-39), keyboard plane (40-79), and dock buttons (80-82), which sums to 82 nodes — but the array holds 83 nodes (0-82). Node 31 (`_UIFloatingBarContainerView`, label "Toolbar") is present in the array but missing from the breakdown, so the next paragraph's "83 nodes" doesn't reconcile with the enumeration. Add node 31 to the breakdown.</violation>
</file>

<file name="src/commands/interaction/runtime/stable-capture.ts">

<violation number="1" location="src/commands/interaction/runtime/stable-capture.ts:256">
P3: `stableCaptureReplacementSignal` runs a full `withoutSettleChrome` classification (window/container subtree walks plus ancestor scans over the whole tree) and builds a second `StableCaptureSignal` on every poll iteration, even though the screen usually hasn't changed. The loop computes `requiredQuietMs` before the existing `stableCaptureSignalsEqual(lastSignal, signal)` short-circuit fires, so the expensive chrome-free projection is rebuilt per poll while the result can only change when the candidate signal changed. Since the quiet window is computed from `baseline`, `snapshot`, `appBundleId`, `quietMs`, and the current `currentQuietMs` — all constant for an unchanged candidate — the result is identical until the signal differs. On this latency-sensitive settle path (300 ms cadence), the duplicate per-iteration tree pass is wasted work in the common stable case.</violation>
</file>

Comment thread packages/capture-kit/src/snapshot-chrome.ts
Comment thread packages/capture-kit/src/snapshot-chrome.fixtures.ts
Comment thread src/commands/interaction/runtime/stable-capture.ts
…hrome walk

An app's `UITextField.inputAssistantItem` bar buttons render under the
system assistant view, above the keys; the window pass exempted them and
the owned-role subtree walk re-claimed them, hiding real app controls
from the settle diff. An exempted node now re-joins chrome only when it
declares a keyboard-owned role itself, so a system prediction cell still
collapses while an app button under the same host survives.

Also from review: hoist the chrome-free replacement projection behind a
baseline/candidate-change guard so the 300ms poll of an unchanged screen
pays no second tree pass, and count node 31 in the fixture breakdown.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/commands/interaction/runtime/stable-capture.ts Outdated
Keying the memo on stableCaptureSignalsEqual is unsound: that identity
omits role, parent links, and sub-pixel geometry, all of which
withoutSettleChrome reads, so a newly broad replacement could reuse a
non-broad verdict and skip the confirmation window. Keying it on every
classifier input would cost more than running the classifier, and the
measurement says the memo was never owed: one full chrome-free pass is
~42us on the 83-node real keyboard capture and ~1.4ms on a 5k-node
tree, against a 300ms poll cadence. The verdict is recomputed per
capture again, and the doc-comment forbids re-memoizing on that key.
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the fix. At 41c8a82 one problem remains, so this is not ready to merge yet. CI is green (19 checks, none failing), and there are no conflicts.

The assistant-bar buttons are still dropped on the interactive capture that --settle uses. collectOwnedKeyboardChromeIndexes (https://github.com/callstack/agent-device/blob/41c8a82/packages/capture-kit/src/snapshot-chrome.ts#L168) claims the whole subtree under every keyboard-owned root, including the TUISystemInputAssistantView host. It only carves out accessoryExemptIndexes, and resolveKeyboardWindowIndexes builds those only when the Keyboard has a Window ancestor. The interactive projection has no Window node, so the exemption is empty there. An app's UITextField.inputAssistantItem bar buttons, or any non-editable app control under that view, then become chrome on the exact route this PR targets. After a fill or focus, the settle diffs, the settle ref tail and the replay-divergence screen.refs would hide real controls. The ab1f758 test passes only because its fixture includes a Window node. The rule should be this. In a host the app can also fill (the assistant view, the inputView placeholder), a node is chrome only if it declares a keyboard-owned role itself or sits under the key plane (Keyboard or UIKeyboardLayoutStar). So treat TUISystemInputAssistantView and its page view as markers, like _UIRemoteKeyboardPlaceholderView, and claim subtrees only for TUIPredictionView and the layout star. Please add a regression on a flattened fixture with no Window, where an app Button under TUISystemInputAssistantView survives.

Not blocking, and you can take or leave these: IOS_KEYBOARD_OWNED_ROLES repeats the concept in IOS_KEYBOARD_SURFACE_ROLES from tap-keyboard-occlusion.ts, with two shared entries, and the Android side already has one owner in @agent-device/contracts/android-input-ownership, so an iOS sibling would serve both files; right in stable-capture-signal.ts was widened to StableCaptureSignal | undefined, but the only caller always passes a defined signal, so the parameter can go back to StableCaptureSignal; the test in settle-keyboard-chrome.test.ts pins additions=2 because the full-screen _UIFloatingBarContainerView "Toolbar" survives as a second diff line, so either classify it or note it as accepted residue; and the PR body says two new golden cases where there is one.

Could this be simpler? The Keyboard eligibility change alone restores container-walk classification of every Key. If the remaining flattened surfaces (QuickType, dock, inputView host) were classified per node from one shared iOS keyboard-role table in contracts, also used by tap-keyboard-occlusion.ts, with subtree claims only for TUIPredictionView and the key plane, would collectOwnedKeyboardChromeIndexes, collectHostedEditableTextIndexes and the exemption re-join loop still be needed? No subtree walk could reach app content then. The role table would need to move into contracts first.

The live iOS numbers (median 1734 to 754 ms, zero [key] lines) come from the PR body, and I did not reproduce them. I also could not confirm that the run was on 41c8a82 and not an earlier commit. The same goes for the per-capture cost of the memo-free check, which is your measurement. I read the code and did not run the tests. I also have not checked on a device that iOS exposes inputAssistantItem buttons as children of TUISystemInputAssistantView. That claim comes from the review thread, and you accepted it.

On the open review threads, the first one still applies: #3482 (comment) (Window-less exemption). It is marked resolved, but the code at 41c8a82 still has the gap, which is the problem above. These three no longer apply, so please resolve them: #3482 (comment) (the fixture now names node 31 and the counts add up), #3482 (comment) (the memo was removed, and the per-poll cost is small against the 300 ms cadence), and #3482 (comment) (the unsound memo key was deleted and the result is recomputed per capture).

Before merge, the assistant-view subtree claim needs to become a marker, with the no-Window regression added.

@thymikee

thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member Author

Fixed at 744c8a3, following your rule exactly. TUISystemInputAssistantView and its page view joined _UIRemoteKeyboardPlaceholderView as MARKER roles: chrome themselves, claiming no subtree. Subtree claims stay with the system-only hosts only (UIKeyboardLayoutStar, TUIPredictionView); dock buttons, keys, and prediction cells are chrome per node by their own declared role. No subtree walk can reach app content, so — answering your "could this be simpler" directly — yes: collectOwnedKeyboardChromeIndexes, collectHostedEditableTextIndexes, and the exemption re-join loop are all deleted. The Window-less no-anchor case you described is exactly what the new regression pins: a flattened tree, no Window node, an app Button under the assistant view survives while a TUIPredictionViewCell beside it collapses. (The window-shape cubic test still passes too: Paste survives, cell and key collapse.)

Your non-blocking items, all taken:

  • Role tables shared with tap-keyboard-occlusion.ts. They live inside @agent-device/contracts/android-input-ownership as IOS_KEYBOARD_* — the module the settle classifier and the occlusion band already evaluate — because the eager-closure gate (ADR 0027) failed a dedicated ios-keyboard-ownership module: a new static edge grew src/daemon.ts and the replay-port entries' eager closures (393→394). The shared-home placement is exactly what that gate prescribes. tap-keyboard-occlusion.ts imports IOS_KEYBOARD_SURFACE_ROLES with byte-identical membership, so its golden-table behavior is unchanged (suite green). TUIPredictionViewCell sits in a settle-only chrome table so QuickType geometry never enters the occlusion band.
  • right back to non-optional StableCaptureSignal in stableCaptureSignalsHaveBroadReplacement.
  • Accepted residue is now explicit: the flattened capture keeps two inert noise survivors (floating Toolbar, QuickType UIScrollView wrapper under the assistant markers — no keyboard-owned role of their own). settle-keyboard-chrome.test.ts pins additions=3 with the reason in the comment. Noise fails loudly; a subtree claim through an app-fillable host would not.
  • PR body corrected: one golden case, not two (the interactive-projection case covers the claim; the regular shape is what the existing plane cases pin).

On the inputAssistantItem device question: agreed, it is unverified on device, and I did not add a role for it — the fix does not depend on it. The marker rule is built for exactly this uncertainty: if iOS never renders app controls there, the markers only cost two inert noise lines per keyboard appearance; if it does, they survive. The live re-run at 744c8a3 will capture the current flattened shape again for the record; the shared test device is momentarily held by the parallel #3470 worker, and the numbers comment follows as soon as it frees. The original 1734→754 table came from the e2ad899 build; the classifier change cannot restore the old key-line inflation (container-walk classification of keys is untouched), but I will report the re-measured medians rather than assert it.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/capture-kit/src/snapshot-chrome.ts Outdated
@thymikee
thymikee force-pushed the fix/3469-ios-keyboard-chrome-interactive branch from 744c8a3 to b3ff868 Compare October 12, 2026 16:21
…sts are markers

The assistant view and its page view are keyboard hosts the APP can also
fill (UITextField.inputAssistantItem bar buttons ride them), so they join
the inputView placeholder as markers: chrome themselves, claiming no
subtree. Subtree claims stay with the system-only hosts — the key plane
(UIKeyboardLayoutStar) and the prediction bar — where no app content can
ever sit. Surface roles are chrome per node. With no subtree walk able
to reach app content, the accessory re-join guard and the hosted
editable-text carve-out are dead weight and are removed.

The IOS_KEYBOARD_* tables live in android-input-ownership — the module
the settle classifier and the tap-occlusion band already evaluate, as
the eager-closure gate (ADR 0027) requires for shared symbols; a
dedicated module would grow every shared entry's eager closure. The
prediction cell stays out of the surface table so QuickType geometry
never enters the occlusion band.

Accepts two inert noise survivors in the flattened capture (floating
toolbar, QuickType scroll wrapper) — noise fails loudly, a subtree claim
through an app-fillable host would not.

Also reverts the broad-replacement right parameter to non-optional: the
memo is gone and the only caller always passes a defined signal.
@thymikee
thymikee force-pushed the fix/3469-ios-keyboard-chrome-interactive branch from b3ff868 to faf754a Compare October 12, 2026 17:06
@thymikee

Copy link
Copy Markdown
Member Author

Live re-run at the shipped head faf754a (iPhone 17 sim, iOS 27, com.callstack.agentdevicelab, Catalog fill id=catalog-search kit/loaf --settle, 10 alternating runs, keyboard-enter reset between fills; the device was momentarily held by the parallel #3470 worker, hence the follow-up):

metric before (main, 10 runs) faf754a (10 runs)
median waitedMs 1734 786
captures 5 2
[key] diff lines 38 0
[keyboard] line 0 1

The 1734→786 confirmation stands. One honest delta vs the e2ad899 build's first re-run (diffLines 15, captures 3): the marker-rule rebuild trades three key-plane lines for the two accepted inert residues (QuickType UIScrollView + assistant page view are no longer subtree-claimed), so per-fill diffLines is 18 rather than 15 — still 38 fewer lines than main, and captures dropped to 2. The keyboard arrival costs one visible [keyboard] line plus two labeled-acceptable noise lines.

Session closed and simulator shut down after the run.

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready on the code. At faf754a, the keyboard-chrome marker no longer claims an app subtree, the breakdown fixture lists node 31, and the early return now requires no subtree chrome roots, with a regression test. Those were the points from the earlier review at 41c8a82 (#3482 (comment)). The memo on the stable-capture verdict is gone, so the verdict is recomputed for each capture. That cost is small next to the 300ms poll.

Not blocking: the comment at packages/contracts/src/android-input-ownership.ts:157 says the role classes are private to the keyboard surfaces and that an app cannot host one in its own view tree, but the fourth bullet (IOS_KEYBOARD_CHROME_MARKER_ROLES) describes hosts the app can fill, so you could limit that sentence to the classes themselves (an app cannot instantiate them) and leave the subtree question to the marker bullet, or leave it as is.

All five earlier inline threads from the other reviewer no longer apply, so you can resolve them: #3482 (comment), #3482 (comment), #3482 (comment), #3482 (comment) and #3482 (comment). The first, second, fourth and fifth are fixed at this commit. The third is benign now that the memo is deleted.

18 of 19 checks pass, and Smoke Tests is still running. If the iOS smoke covers fill or --settle, treat a failure there as possibly related, because this change keeps the Keyboard node in regular snapshots and feeds the settle chrome filter into the quiet window. Check the logs before calling it unrelated. Smoke Tests must finish green before merge.

On evidence, I read the code and tests but did not run them. The live numbers at faf754a (1734 to 786 ms, zero [key] lines) and the 393 to 394 eager-closure count are yours, and I did not reproduce them. I also did not check on a device that iOS renders inputAssistantItem buttons as children of TUISystemInputAssistantView. The marker rule is safe either way. From reading the old code, the new regression test would fail on 41c8a82.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 12, 2026
@thymikee
thymikee merged commit a5deacd into main Oct 13, 2026
19 checks passed
@thymikee
thymikee deleted the fix/3469-ios-keyboard-chrome-interactive branch October 13, 2026 10:18
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-13 10:25 UTC

thymikee added a commit that referenced this pull request Oct 13, 2026
…3572)

The IOS_KEYBOARD_* docblock said the classes are "private to the keyboard
surfaces — an app cannot host one in its own view tree", while the marker
bullet below it describes hosts the app CAN fill. Limit the sentence to what
is true of the classes themselves (an app cannot instantiate them) and leave
the subtree question to the per-table bullets, which already answer it.

Follow-up to #3482's final review note; no behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ios): keyboard chrome is unclassified in interactive captures, inflating settle diffs and the broad-transition rule

1 participant