Skip to content

fix(scroll): keep adb-backed Android directional swipes inside the resolved scrollable - #3746

Open
airowe wants to merge 2 commits into
callstack:mainfrom
airowe:fix/scroll-clamp-to-container
Open

airowe wants to merge 2 commits into
callstack:mainfrom
airowe:fix/scroll-clamp-to-container

Conversation

@airowe

@airowe airowe commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Clamps directional scroll endpoints on adb-backed Android to the resolved scrollable (#2714), per the rule agreed with #1821. Endpoints stay strictly inside the container's half-open rect, inset 8 px, and within the 10% padding along the scrolled axis. A shortened swipe reports pixels (dispatched) beside requestedPixels.

  • The daemon reads the container from the current stored tree before the gesture. There is no container for a stale tree, a horizontal scroll, or desktop/web.
  • buildScrollGesturePlan takes an optional container. A span that fits is unchanged.
  • Android composes it with the keyboard clip.

9 files, CHANGELOG included. The planner vectors are TS-only because the Swift twin doesn't clamp yet.

Not covered yet: iOS (including the Oct 7 keyboard-clip repro) and WebDriver/Appium. I'd like those as stacked follow-ups.

Validation

check:affected --run --head 86214adf3 (exactCommit): format, lint, typecheck, layering, fallow and build pass. vitest-related: 856/857 files pass. The one failure is a new URL(import.meta.url).pathname test that breaks on this host's space-containing path and fails identically on main. After that, run directly: unit has 14,575 passing and 6 failures that also fail on main; provider-integration and eager-closure-budgets pass.

Live on Pixel_7_API_36:

  • Settings scroll up 0.8 (list top y=480): main starts on the search bar → scroll_no_progress; branch 480→2808, moved.
  • Clock scroll down 0.8 (list 368..2756 above the nav bar) at 86214ad: y1 2747→376, pixels 2371 < requestedPixels 2496, moved. Main: scroll_no_progress.

…lved scrollable

A directional scroll centred its swipe on the window, so on an inset
scrollable the drag could start on a header above the list and the
command failed with scroll_no_progress. The daemon now resolves the
scroll container from the current stored tree before the gesture and
hands its rect to the owner. The shared planner keeps both endpoints
inside the container's intersection with the padded frame: a span
that fits is unchanged, one that leaves the band moves into it, and
one longer than the band shrinks to it and reports `requestedPixels`
beside the dispatched `pixels`. Android applies it after the keyboard
clip, so the two compose. iOS plans in the runner and follows separately.

Refs callstack#2714
@airowe

airowe commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Live evidence: Pixel_7_API_36, Android Settings. The list is inset at y=480 under the search bar. Steps: scroll down, then scroll up 0.8.

Comparison strip, left to right: main before, main after (no movement, scroll_no_progress), branch before, branch after (scrolled back to the top).

comparison-strip

main (0149438): scroll up 0.8 drags from y=312 on the search bar, and the list never moves.

base-scroll-up-0.8.mp4

branch: scroll up 0.8 drags 480→2808 inside the list (pixels 2328, requestedPixels 2496, movement: moved).

head-scroll-up-0.8.mp4

@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

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

View guided diff | Re-trigger cubic

Comment thread packages/contracts/src/scroll-gesture.ts Outdated
Comment thread packages/contracts/src/scroll-gesture.ts
Comment thread packages/daemon/src/execution/scroll-movement.ts
Comment thread packages/contracts/src/scroll-gesture.ts Outdated
Comment thread packages/contracts/src/scroll-gesture-container.test.ts
@thymikee

Copy link
Copy Markdown
Member

At 95ba7e8 the lane clamp can start a scroll down one pixel below the list, so this needs one change before merge. In scroll-gesture.ts, bandMax is round(container.y + container.height), which is the container's exclusive bottom edge (Android bounds are [l,t][r,b], and View hit-testing uses localY < height). When the container ends above the 90% padded band, a down or right scroll starts at bandMax, so ACTION_DOWN lands just outside the list. On an inset list with a bottom bar or footer, scroll down 0.8 would drag the wrong surface and end in scroll_no_progress or container-outside-swipe. That is the #2714 defect in mirror form. The new test in input-actions.test.ts encodes it by asserting swipeSpan == [list.y, list.y + list.height]. The rule to satisfy is that every dispatched endpoint lies inside the container's half-open rect, with a small inset so the drag does not start on the edge row. Please apply it in keepSwipeInContainer, since every caller goes through it (for example bandMax = floor(y + h) - 1 - inset and bandMin = ceil(y) + inset, intersected with the padded band). The test should then assert that both endpoints are strictly inside the rect for down and up.

Not blocking, and fine to take or leave: webdriver-interactor.ts:268 drops containerRect, so the Appium/WebDriver path that serves Android on cloud providers does not get this fix. Either pass container into the buildScrollGesturePlan call at line 275, or name ADB-backed Android in the title, CHANGELOG and #2714's follow-up list.

All 12 checks pass. I did not run the tests or a device, and the half-open edge reasoning above is not checked live. The existing live evidence predates the rebase and covered only scroll up, which starts at the inclusive top edge. Please post a live ADB Android run at 95ba7e8 or later of a shortened scroll down (amount 0.8 or more) on an inset list that has a bottom bar or footer below it. The response should show y1 strictly less than containerRect.y + height, pixels below requestedPixels, and movement: 'moved'.

The open inline threads still apply: the container-outside-padding return and the cross-axis centre padding. Three lower-priority threads also still apply. None of the open threads can be resolved yet.

Once both endpoints stay inside the container's rect and the live scroll down run is posted, this is ready for another look.

The lane clamp treated the container's bottom edge as reachable, but
Android bounds are half-open, so a shortened `scroll down` above a
footer could start one pixel below the list. The usable band is now
the container's half-open range inset by 8 px on both sides, on both
axes. Along the scrolled axis it is intersected with the padded frame,
which still wins when it leaves nothing of the container. Across it,
the padded range is preferred, but a rail or chip row lying wholly in
that padding is swiped along its own inset range rather than missed.
A lane that only loses the rounding pixel of an odd span is no longer
reported as shortened.

Refs callstack#2714
@airowe airowe changed the title fix(scroll): keep Android directional swipe endpoints inside the resolved scrollable fix(scroll): keep adb-backed Android directional swipes inside the resolved scrollable Oct 17, 2026
@airowe

airowe commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@thymikee Thanks. Fixed in 86214ad.

  • keepSwipeInContainer now treats the container as half-open and inset: ceil(start) + 8 to floor(start + size) - 1 - 8, on both axes, intersected with the padded band along the scrolled axis. Every caller goes through it.
  • The tests assert both endpoints are strictly inside the rect, for up and down, including a list with a footer below it, in the planner and in scrollAndroid. Putting the inclusive edge back fails 11 of them.
  • WebDriver: I took the second option. The title, CHANGELOG and PR body now name adb-backed Android, and Appium is listed as a follow-up.
  • An independent review of this round also found that the new cross-axis padding could miss a rail or chip row that sits wholly inside it. That case now falls back to the container's own inset range.

Live ADB run at 86214ad (Pixel_7_API_36): Clock's world-clock list, rect (0,368,1440,2388) (bottom edge 2756, nav bar below), scroll down 0.8:
y1 2747 (< 2756) → y2 376, pixels 2371 < requestedPixels 2496, movement: 'moved'. The same command on main 0149438 fails with scroll_no_progress.

Strip, left to right: main before, main after (a Clock widget snackbar appeared; the list didn't move), branch before, branch after.

comparison-strip

main, scroll down 0.8:

base-scroll-down-0.8.mp4

branch at 86214ad, scroll down 0.8:

head-scroll-down-0.8.mp4

@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.

4 issues found across 4 files (changes from recent commits).

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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/platform-android/src/__tests__/input-actions.test.ts">

<violation number="1" location="packages/platform-android/src/__tests__/input-actions.test.ts:181">
P3: This weakened check no longer verifies the 8 px edge inset. Assert the exact inset start, `top === list.y + 8`, so a regression to 401 fails.</violation>
</file>

<file name="CHANGELOG.md">

<violation number="1" location="CHANGELOG.md:5">
P3: `scroll_no_progress` is documented as a consequence of endpoint placement alone. State that it requires an unchanged surface, an inside-container swipe, and hidden content remaining in that direction.</violation>

<violation number="2" location="CHANGELOG.md:5">
P3: This bullet overstates the guarantee. When the 10% padding leaves none of the container on the scrolled axis, the swipe keeps the padded-frame lane, so endpoints can fall outside the container. The cross-axis fallback can also leave the padding. Qualify the sentence so users don't rely on containment in those cases.</violation>
</file>

<file name="packages/contracts/src/scroll-gesture-container.test.ts">

<violation number="1" location="packages/contracts/src/scroll-gesture-container.test.ts:42">
P2: The shrink test no longer pins the 8 px inset. `strictlyInside` only excludes the edge row, so endpoints at y=179 or y=668 still pass. Restore an exact check on the band, for example `[186, 787]` for `INSET_CONTAINER` and `[186, 669]` for `FOOTERED_CONTAINER`.</violation>
</file>

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

View guided diff | Re-trigger cubic

const plan = buildScrollGesturePlan({ direction, amount: 0.75, ...FRAME, container });

assert.ok(strictlyInside(plan, container), JSON.stringify(plan));
assert.ok(Math.max(plan.y1, plan.y2) <= PADDED_BOTTOM, JSON.stringify(plan));

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.

P2: The shrink test no longer pins the 8 px inset. strictlyInside only excludes the edge row, so endpoints at y=179 or y=668 still pass. Restore an exact check on the band, for example [186, 787] for INSET_CONTAINER and [186, 669] for FOOTERED_CONTAINER.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. 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. At packages/contracts/src/scroll-gesture-container.test.ts, line 42:

<comment>The shrink test no longer pins the 8 px inset. `strictlyInside` only excludes the edge row, so endpoints at y=179 or y=668 still pass. Restore an exact check on the band, for example `[186, 787]` for `INSET_CONTAINER` and `[186, 669]` for `FOOTERED_CONTAINER`.</comment>

<file context>
@@ -4,44 +4,81 @@ import type { Rect } from '@agent-device/kernel/snapshot';
-    );
-    assert.equal(plan.pixels, PADDED_BOTTOM - INSET_CONTAINER.y);
+    assert.ok(strictlyInside(plan, container), JSON.stringify(plan));
+    assert.ok(Math.max(plan.y1, plan.y2) <= PADDED_BOTTOM, JSON.stringify(plan));
+    assert.equal(plan.pixels, Math.abs(plan.y2 - plan.y1));
+    assert.ok(plan.pixels < unclamped.pixels);
</file context>

const [top, bottom] = swipeSpan(result);

assert.equal(result.keyboardAvoided, true);
assert.ok(top > list.y, `swipe start ${top} is on the list's top edge or above it`);

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.

P3: This weakened check no longer verifies the 8 px edge inset. Assert the exact inset start, top === list.y + 8, so a regression to 401 fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. 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. At packages/platform-android/src/__tests__/input-actions.test.ts, line 181:

<comment>This weakened check no longer verifies the 8 px edge inset. Assert the exact inset start, `top === list.y + 8`, so a regression to 401 fails.</comment>

<file context>
@@ -167,7 +178,7 @@ test('scrollAndroid keeps the swipe inside a container that runs under the keybo
 
       assert.equal(result.keyboardAvoided, true);
-      assert.equal(top, list.y);
+      assert.ok(top > list.y, `swipe start ${top} is on the list's top edge or above it`);
       assert.ok(bottom <= LOWER_HALF_KEYBOARD.y, `swipe endpoint ${bottom} landed on the keyboard`);
       assert.equal(result.pixels, bottom - top);
</file context>
Suggested change
assert.ok(top > list.y, `swipe start ${top} is on the list's top edge or above it`);
assert.equal(top, list.y + 8, 'swipe start sits at the 8 px inset below the list top edge');

Comment thread CHANGELOG.md

## Unreleased

- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).

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.

P3: scroll_no_progress is documented as a consequence of endpoint placement alone. State that it requires an unchanged surface, an inside-container swipe, and hidden content remaining in that direction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. 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. At CHANGELOG.md, line 5:

<comment>`scroll_no_progress` is documented as a consequence of endpoint placement alone. State that it requires an unchanged surface, an inside-container swipe, and hidden content remaining in that direction.</comment>

<file context>
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).
 - Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
 - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
</file context>
Suggested change
- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).
- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it. `scroll_no_progress` remains limited to an unchanged surface after an inside-container swipe when the relevant container still has hidden content in that direction. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).

Comment thread CHANGELOG.md

## Unreleased

- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).

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.

P3: This bullet overstates the guarantee. When the 10% padding leaves none of the container on the scrolled axis, the swipe keeps the padded-frame lane, so endpoints can fall outside the container. The cross-axis fallback can also leave the padding. Qualify the sentence so users don't rely on containment in those cases.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. 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. At CHANGELOG.md, line 5:

<comment>This bullet overstates the guarantee. When the 10% padding leaves none of the container on the scrolled axis, the swipe keeps the padded-frame lane, so endpoints can fall outside the container. The cross-axis fallback can also leave the padding. Qualify the sentence so users don't rely on containment in those cases.</comment>

<file context>
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).
 - Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
 - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
</file context>
Suggested change
- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).
- On adb-backed Android, a directional `scroll` keeps both swipe endpoints inside the scroll container the current snapshot resolves, inset from its edges and, along the scrolled axis, within the 10% edge padding, so a list under a header or above a footer no longer receives a drag that starts outside it and ends in `scroll_no_progress`. When the padding leaves none of the container on the scrolled axis, the swipe stays on the padded frame instead. A swipe that already fits is unchanged; one longer than the container is shortened, and the response reports the dispatched `pixels` beside `requestedPixels`. Horizontal scrolls, scrolls after a mutation that has not been re-snapshotted, iOS, and WebDriver (Appium) sessions keep the unclamped swipe for now (#2714).

This branch has not been deployed

No deployments
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.

2 participants