Skip to content

fix(scroll): keep the swipe above the keyboard, refuse when it cannot - #2503

Open
thymikee wants to merge 4 commits into
feat/scroll-keyboard-contract-2500from
fix/scroll-keyboard-viewport-2500
Open

fix(scroll): keep the swipe above the keyboard, refuse when it cannot#2503
thymikee wants to merge 4 commits into
feat/scroll-keyboard-contract-2500from
fix/scroll-keyboard-viewport-2500

Conversation

@thymikee

@thymikee thymikee commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

iOS half of #2500, stacked on #2537, where the shared rule, the golden table, the typed refusal and the response keys are stated once for every platform.

A directional scroll planned a viewport-center-symmetric swipe, so a focused field put the swipe's lower endpoint under the keyboard: the gesture hit keys, the surface never moved, and the edge loop reported a stuck container. The runner now reads its own live keyboard frame, clips the swipe band against the shared rule, reports keyboardAvoided / keyboardMinY, refuses with scroll_keyboard_occludes_surface when the keys leave under 15% of the band, and never dismisses the keyboard. The superseded scroll keyboard path was deleted, not guarded.

Closes #2500 once #2537 and #2514 land. Android goes on top of this in #2514. main now carries #2499, so both halves of the issue are in: the clip stops the wasted gesture, and #2499's no-progress stop ends the loop.

Review pass

The width-fraction gate is gone: the clip asks whether the keyboard reaches the line the swipe runs along, not what share of the viewport's width it covers, because a vertical swipe travels the centre line and a floating keyboard straddling that line blocks it however narrow it is. The row that proves it (centred floating keyboard under half the viewport width sits in the swipe's path) is in the shared table landed by #2537, and the Swift lane asserts that same JSON row here.

Two things found while checking for reuse, both left alone deliberately:

  • frameAvoidingKeyboard (RunnerTests+Interaction.swift:789) still gates on intersection.width / frame.width >= 0.5, i.e. the same defect for tap/drag reference frames. Fixing it changes the tap family, so it belongs in its own change; flagged rather than bundled.
  • keyboardAvoidingDragPoints (same file, :702) keeps a local padding: Double = 12 that names the same fact as accessoryAllowance, and fails open where scroll refuses. Sharing the constant means editing the drag rule, and the two failure policies differ on purpose: a drag that cannot fit still wants to happen, a scroll entirely under the keys does not.

Reused visibleKeyboardFrame and the iOS lane's existing -only-testing list.

A second pass found the clip leaking into the coordinate rotation. resolvedScrollViewport returned one frame and the command used it for two jobs: planning the swipe, and re-basing the touch context. nativeSynthesizedPoint derives a landscapeRight native x from that frame's HEIGHT, so clipping an 834pt landscape viewport to 576pt moved the dispatched gesture 258pt sideways off the lane the plan had just been built for. The viewport now carries planFrame and coordinateFrame separately and the gesture comes from one gestureDispatch decision, so the two cannot be swapped; testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis runs the landscape case through that decision and fails on the swap.

A third pass found that fix had moved the reported frame too. Splitting the frames left the response naming the synthesis frame: executeSynthesizedDragGesture builds its DragVisualizationFrame from the frame the coordinates rotate in, so a swipe planned inside a 571pt band answered with referenceHeight: 874, and pixels — which is a fraction of the band — was measured against an axis the caller was never told about. The keyboard evidence now travels with the dispatch instead of being bolted on afterwards: ScrollGestureDispatch.attachingEvidence(to:) names planFrame in referenceWidth / referenceHeight and keeps coordinateFrame for synthesis, and the old attachingScrollViewportEvidence helper is gone. The regression above now asserts the assembled response as well, so reporting the synthesis frame is a red test rather than a reviewer's job.

Validation

pnpm check green at 8a67c377cf (exit 0).

Reproducing the runner tests locally needs AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 on the build: xcodebuild test-without-building -only-testing: executes 0 tests and still reports the suite as passed without it, because that env var is what defines AGENT_DEVICE_RUNNER_UNIT_TESTS. The iOS lane always sets it; my own earlier local runs did not and were therefore empty. With AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios the scroll and policy tests genuinely execute (Executed 4 tests, with 0 failures), and each guard bites when neutered: handing the clipped band to the rotation basis fails on a 0.0 vs 258.0 shift, and dropping the two evidence lines fails with ("Optional(834.0)") is not equal to ("Optional(576.0)") — the symptom reported in review, now pinned.

iOS, iPhone 17 Pro, Form screen, field focused:

  • scroll down --json -> referenceWidth: 402, referenceHeight: 571, keyboardAvoided: true, keyboardMinY: 583, swipe y1: 472 -> y2: 100, pixels: 371, and the runner logged decision=avoided keyboardMinY=583.0 swipeHeight=571.0. Same command with no keyboard up reported referenceHeight: 874, y1: 721 -> y2: 153, pixels: 568, so the two answers differ exactly by what the keys took.
  • scroll top --json on the notes field earlier in the same build returned referenceHeight: 571, keyboardMinY: 583, y2=472.
  • First scroll after runner relaunch logs axHealth=unknown keyboardPolicy=requiredWhenAvailable decision=avoided; the old whenAccessibilityHealthy gate skipped the probe there.
  • No keyboard -> no evidence keys, referenceHeight: 874, decision=noKeyboard.

iOS, iPadOS 26 windowed Safari (address bar focused): referenceWidth: 834, referenceHeight: 913, keyboardAvoided: true, keyboardMinY: 925. The window is 913pt tall inside a 1219pt display, so this is the clip reading the app window rather than the display. That simulator was created for the attempt and deleted afterwards, and the host's ConnectHardwareKeyboard was returned to its original value after the soft-keyboard run.

Unreproduced live: the occluded refusal. The band above the keys cannot fall under 15% on an iPhone (583 of 874 = 65%), and iPadOS 26 keeps an app window clear of the keyboard (window bottom 913, keyboard top 925), so reaching it needs an iPad app window resized to roughly the keyboard's own height, dragged from the host desktop; the macOS desktop target timed out at the daemon budget here before the drag landed. The command that would prove it is agent-device scroll down --json on such a window, on a host window server session that answers desktop snapshots. The risk left open is narrow: the refusal branch is reached only from the same clip decision that the live runs above exercised, it returns before any gesture is dispatched, and the parity table row adjustPan window with only 88px of 900px left above the IME is refused plus the platform tests assert no swipe is planned when the keyboard owns the window.

Size

6 files, 536 gross lines, one command family, 4 commits: the runner clip, its lane wiring, and the response evidence. The shared rule and golden table are #2537 (615 gross) and the Android helper read and clip are #2514 (612 gross), which is what the budget needed and also where those facts belong.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2503/

Built to branch gh-pages at 2026-09-12 19:09 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.50 MB 4.51 MB +9.2 kB
Package (unpacked) 4.50 MB 4.51 MB +9.2 kB
Package (download) 1.33 MB 1.33 MB +2.5 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 31.1 ms 31.9 ms +0.7 ms
CLI --help 81.2 ms 86.0 ms +4.8 ms

@thymikee

thymikee commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

The clipping rule still allows swipes into a floating keyboard at 0fb3f39. Both clipScrollViewportAboveKeyboard and its Swift counterpart ignore keyboards covering less than half the viewport width, but vertical scrolling uses the viewport's center line. A centered 320px keyboard in an 834px viewport covers that line despite being only 38% wide. Check whether the planned swipe intersects the keyboard, and add that centered case to the shared fixture.

Required live evidence is also incomplete: please exercise Android with an adjustPan activity and the iOS scroll_keyboard_occludes_surface refusal, including confirmation that no gesture was dispatched. The reported iOS clipping run and unit parity table do not cover those paths.

The change has 1,561 gross lines and adds 8.2 kB unpacked. Please split it below the repository's 1,000-line PR budget and explain why the smaller owning change is insufficient, or obtain an explicit budget exception. The iOS smoke failed while waiting for the Automation lab screen, before exercising keyboard clipping; that appears unrelated. The separately reported mutation-model failure was reproduced at the base and also appears unrelated.

@thymikee
thymikee force-pushed the fix/scroll-keyboard-viewport-2500 branch from 0fb3f39 to c7f4bd2 Compare September 12, 2026 06:16
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at c7f4bd2b7b.

Centre-line gate. The width fraction is gone from clipScrollViewportAboveKeyboard and its Swift twin; the gate is now swipeCenterX inside the keyboard's x-range, which is what a vertical swipe actually travels. Your case is a shared fixture row, centred floating keyboard under half the viewport width sits in the swipe's path (834x1112 viewport, 320x332 keyboard centred at 417, expected keyboardMinY: 780), asserted by vitest and by testScrollViewportKeyboardClipMatchesGoldenParityTable against the same JSON. Two near-neighbours kept out of scope and named in the body: frameAvoidingKeyboard (RunnerTests+Interaction.swift:789) still uses intersection.width / frame.width >= 0.5 for tap/drag reference frames, and keyboardAvoidingDragPoints (:702) hard-codes the same 12pt allowance and fails open where scroll refuses.

Android adjustPan, live. Pixel 7 emulator, Contacts editor (sim={adjust=pan} in dumpsys window windows), LatinIME bound: helper reports the IME window [0,1517][1080,2400] against a 1080x2400 app window, and scroll down --json returns referenceHeight: 1505, keyboardAvoided: true, keyboardMinY: 1517, swipe 1205 -> 301 — it used to swipe 1920 -> 480 into the keys. Two things only came out on device: UiAutomation.getWindows() returns an empty list until interactive window retrieval is on (c7f4bd2b7b), and open re-activates agent-device's headless imehelper, which draws no keyboard, so a real IME has to be bound after open or there is no IME window to avoid. Also added an iPadOS 26 windowed-Safari reading (834x913 window in a 1219pt display, keyboardAvoided: true, keyboardMinY: 925) to show the clip tracks the app window and not the display.

Still open: the live occluded refusal. Not reachable where I can reach: iPhone leaves 583 of 874 above the keys (65%), and iPadOS 26 keeps a window clear of the keyboard (window bottom 913 vs keyboard top 925), so it needs a window dragged down to about the keyboard's height from the host desktop — the macOS desktop session timed out on this host before I could drag. Table coverage on both sides for now; say the word and I'll keep pushing on the host-side resize.

Size. 1,443 gross (817 production) after the trims listed in the body, so still over 1,000. The clean split is this PR minus Android (~260 lines: helper window read, input-actions clip, their tests) as a follow-up. Prefer the split or an exception?

@thymikee

Copy link
Copy Markdown
Member Author

Split per the size ask. This branch is now the contract plus the Apple runner path: 17 files, 980 gross lines, under the 1,000 budget. The Android half (helper IME read, input-actions clip, their tests, 463 lines) is #2514, stacked on this branch.

pnpm check green at faa75e7860 on this branch and on the Android branch. History was rebuilt for the split, so the earlier head is gone; nothing else changed in content — the two branches together differ from the previous head only by the split itself and by one commands.md sentence that now claims iOS only until #2514 lands.

One thing the rebuild surfaced: the previous head also deleted a nine-line comment in interaction-guarantees.ts about the offscreen rescue override's separate-request read (#2448). That was collateral damage in my own edit, not intent, and it is not carried into either branch — main's comment is intact here.

Still open and unchanged from my last note: the live scroll_keyboard_occludes_surface refusal needs an iOS window dragged down to about the keyboard's height from the host desktop (iPhone leaves 65% above the keys, iPadOS 26 holds the window clear at 913 vs 925), and the macOS desktop session timed out here. Table coverage on both sides meanwhile.

@thymikee

Copy link
Copy Markdown
Member Author

The floating-keyboard width check and size split are resolved at faa75e7, but the clipped frame now also changes the native coordinate-rotation basis. RunnerTests+CommandExecution.swift:2004 passes the shortened frame into scrollContext.withReferenceFrame; synthesizedDragAt uses its height for landscapeRight rotation. Clipping an 834-point frame to 588 points shifts native x by 246 points, so the gesture misses its planned path. Preserve the original coordinate frame for rotation and use the clipped frame only for swipe planning; cover a landscape keyboard case through dispatch. Current-head checks pass and there are no conflicts. The reported live iOS refusal/no-dispatch validation is still missing.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed at b6ea4e6e5d. You had the mechanism exactly: resolvedScrollViewport returned one frame and dispatch used it for both jobs, so the clip reached the rotation basis.

The resolved viewport now names both frames — .swipe(planFrame:coordinateFrame:keyboardMinY:) — and the gesture comes out of one gestureDispatch(direction:amount:pixels:) decision, so the band the plan is built inside and the frame the coordinates rotate against are different values the dispatch cannot swap. ScrollViewportPolicy.frames(referenceFrame:clip:) is where they are split, and the plan keeps running against the band.

testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis runs a landscape viewport through that dispatch: 1210x834 with a 246pt key plane, band 576, coordinateFrame still 834, planned swipe ends at or above keyboardMinY - 12, and the native x mapped through the coordinate frame differs from the band-basis one by exactly the 258pt the keyboard took. Wired into the iOS lane's -only-testing list.

Proved the test bites rather than asserting my own shape: handing the band to coordinateFrame makes it fail with ("(0,0,1210,576)") is not equal to ("(0,0,1210,834)") and a 0.0 vs 258.0 shift.

One verification correction worth recording, because it wasted my earlier confidence: xcodebuild test-without-building -only-testing:... executes 0 tests and still reports the suite as passed unless the build was made with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1, which is what defines AGENT_DEVICE_RUNNER_UNIT_TESTS. My earlier local "Swift tests passed" lines were those empty runs; the iOS lane was always real, since .github/workflows/ios.yml sets the flag. Rebuilt with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios and these 4 tests genuinely executed and passed:

Executed 4 tests, with 0 failures (0 unexpected) in 0.051 seconds

pnpm check green at b6ea4e6e5d. Android has no analogue: its swipe goes to the helper as absolute screen pixels with no orientation rotation on the path.

@thymikee

Copy link
Copy Markdown
Member Author

Both halves of your comment are in: the rotation-basis fix is b6ea4e6e5d (detail in my previous comment) and the Android multi-window selection is 67c6ba6bbb on #2514.

Size needs your call, and I would rather ask than degrade the fix. The split head you cleared was 980 gross. The rotation-basis fix lands 1,125 (+145): ~55 lines of the landscape dispatch test you asked for, and the rest the planFrame/coordinateFrame pair plus the single gestureDispatch decision that makes them unswappable. #2514 is 612, so the pair is 1,737 against a 1,000-per-PR budget.

Three ways through, priced:

  1. Exception for the +125. The test is what makes this defect impossible to reintroduce silently, and the typed pair is where the reviewer-visible invariant lives. This is my recommendation.
  2. Minimal variant, ~1,003 gross. Stop re-basing the context at all (delete the withReferenceFrame(frame) call, ~4 lines) and keep only a plan-geometry test. Under budget, and the bug is gone — but nothing guards the basis, so the next person who threads a clipped frame into the context reintroduces the 258pt shift with green tests. That is the failure mode your comment caught, so I do not recommend trading the guard for the line count.
  3. A third PR for the contract alone. I built and measured this: it is not independently green. check:production-exports reports clipScrollViewportAboveKeyboard and scrollKeyboardOccludesSurfaceError as unused production exports, because a shared rule with no runtime consumer has no consumer. Suppressing that is suppressing the signal, so the contract has to land beside at least one runtime.

Say 1 and I leave it as is; say 2 and I will push the reduced variant in one commit.

@thymikee

Copy link
Copy Markdown
Member Author

The iOS Smoke lane failed at b6ea4e6e5d on one alert test, retried three times:

RunnerTests.testAlertAcceptDoesNotActivateAReplacementWithASharedButton()
Executed 94 tests, with 3 failures (0 unexpected) in 295.754 seconds

Not this diff:

  • fix(android): keep a scroll's swipe out of the IME window #2514 carries the identical Swift tree (this branch is its base) and its three Smoke lanes all passed, same -only-testing list, which includes that alert test.
  • main's own most recent ios.yml run fails the same alert-replacement family: RunnerTests.testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt.
  • Nothing here touches alert resolution; the scroll dispatch and the rotation-basis change are the only Swift edits, and the four scroll/policy tests in this lane are green.

Reran the failed job for the record.

@thymikee

Copy link
Copy Markdown
Member Author

The rotation basis is fixed at b6ea4e6, but the response now reports the coordinate frame instead of the clipped planning frame. executeSynthesizedDragGesture builds its DragVisualizationFrame from plan.referenceFrame (RunnerTests+CommandExecution.swift:2492-2513), while attachingScrollViewportEvidence only adds keyboard fields. A swipe planned in a 571-point band therefore reports the full 874-point referenceHeight. Keep the full frame for synthesis and report the clipped plan dimensions; assert the returned scroll response in the regression. Current checks pass and there are no conflicts. Live iOS refusal/no-dispatch evidence remains missing, and the diff is now 1,125 gross lines, so the size budget needs a split or explicit exception again.

…nnot

The runner owns the live keyboard frame, so it does the clip and reports what it left: a scroll
answers with `keyboardAvoided` and `keyboardMinY` beside its plan, and refuses with
`SCROLL_KEYBOARD_OCCLUDES_SURFACE` when the keys leave too little band to swipe in instead of flinging
into them. It never dismisses the keyboard, which would drop focus and mutate state that
session-action provenance does not record.

Scroll's keyboard policy moves to `requiredWhenAvailable`. The probe costs a live AX fetch, but
gating it on a healthy tree left the first scroll of a session swiping under the keys, which is the
failure this is for. Every scroll logs its decision, including the two ways it avoids reading the
keyboard at all.

Scroll no longer shares `frameAvoidingKeyboard`, whose 25% fail-open was a tap-reference-frame rule;
that path is unchanged for its remaining callers.
The parity table only detects drift if both halves run in CI. Two of these three were reachable by no
lane, so the Swift half of the table was a local assertion.
`resolvedScrollViewport` handed the command one frame for both jobs, and the coordinate rotation reads
a frame's HEIGHT to map a `landscapeRight` native x. Clipping an 834pt landscape viewport to 576pt
therefore moved the dispatched gesture 258pt sideways off the lane the plan had just been built for:
the clip fixed the keyboard and broke the gesture.

The resolved viewport now names both frames, and the gesture comes from one dispatch decision, so the
band the plan is planned inside and the frame its coordinates rotate against cannot be swapped. The
landscape case asserts through that decision and fails on the swap.
@thymikee
thymikee force-pushed the fix/scroll-keyboard-viewport-2500 branch from b2d153e to 8a67c37 Compare September 12, 2026 19:06
@thymikee
thymikee changed the base branch from main to feat/scroll-keyboard-contract-2500 September 12, 2026 19:07
@thymikee

Copy link
Copy Markdown
Member Author

All three points addressed.

Reported frame — fixed in 8a67c377cf. The keyboard evidence travels with the dispatch instead of being bolted onto a response that had already chosen its frame: ScrollGestureDispatch.attachingEvidence(to:) names planFrame in referenceWidth/referenceHeight and leaves coordinateFrame to synthesis, and attachingScrollViewportEvidence is deleted, so there is one place that decides what a scroll's axis is. Synthesis still rotates in the full frame — the landscape assertion for that is untouched.

The regression now asserts the assembled response, not just the plan: testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis builds the landscape dispatch (1210x834 viewport, keyboard top 588, band 576) and checks referenceHeight == 576, referenceWidth, keyboardMinY, keyboardAvoided. Deleting the two assignment lines reproduces your symptom as a red test — XCTAssertEqual failed: ("Optional(834.0)") is not equal to ("Optional(576.0)") - the payload names the band the plan ran inside, not the synthesis frame.

Live iPhone 17 Pro, Form screen, field focused, scroll down --json: referenceWidth: 402, referenceHeight: 571, keyboardAvoided: true, keyboardMinY: 583, swipe y1: 472 -> y2: 100, pixels: 371, and the runner logged decision=avoided keyboardMinY=583.0 swipeHeight=571.0. The same command with no keyboard up: referenceHeight: 874, y1: 721 -> y2: 153, pixels: 568. Coordinates and reference frame are the same space in both answers.

Size — resolved by splitting rather than by asking you for an exception I could have avoided. My earlier claim that a contract-only PR fails check:production-exports was a false failure: I read a stale dist from another branch, and that gate also prints a pre-existing 63-finding report while exiting 0. Measured properly, the seam is the language boundary:

The split is tree-exact: the two branches reassemble to the same tree the 1,151-line branch had (git diff between them is empty), and each half was gated on its own branch after a rebuild. Also rebased onto current main (#2511) while I was in there.

Live occlusion refusal — still not reproduced, and I want to be explicit that this one remains open rather than implied. The band above the keys cannot fall under 15% on an iPhone (583 of 874 = 65%), and iPadOS 26 keeps an app window clear of the keyboard (window bottom 913, keyboard top 925), so reaching it needs an iPad app window dragged down to roughly the keyboard's own height from the host desktop; the macOS desktop target timed out at the daemon budget here before the drag landed. The command that would close it is agent-device scroll down --json against such a window, on a host session that answers desktop snapshots. Risk left open is narrow: the refusal returns from the same clip decision the runs above exercised, it returns before any gesture is dispatched, and the table row adjustPan window with only 88px of 900px left above the IME is refused plus the platform tests pin that no swipe is planned.

@thymikee

Copy link
Copy Markdown
Member Author

The reported-frame correction is clean on 8a67c37: successful responses now report the planned clipped frame while rotation retains the full coordinate frame. The split also brings this layer to 536 changed lines. The reported live iPhone keyboard-clipping run supports the successful path. Readiness still needs live iOS keyboard-occlusion refusal evidence showing no gesture dispatch; contract tests do not establish that device path. This also depends on #2537.

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.

scroll flings into the keyboard; keep the swipe in the visible surface and fail closed

1 participant