From 370e88aa79d16338cfa5e4cae5632d447da093af Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 09:29:03 -0400 Subject: [PATCH 1/6] fix(scroll): take edge hints from a same-rect scrollable ancestor On Android Settings, the RecyclerView nested in main_content_scrollable_container offers ACTION_SCROLL_BACKWARD even at the top, so `scroll top` kept swiping a list that could not move and failed with scroll_edge_no_progress. When a scroller has the same rect as its nearest scrollable ancestor, the edge verdict now reads the ancestor's hints. The snapshot's own per-node hints are unchanged. Closes #3737 Co-Authored-By: Claude Opus 5.5 --- ...oll-edge-state-android-settings-fixture.ts | 85 +++++++++++++++++++ ...roll-edge-state-same-rect-ancestor.test.ts | 55 ++++++++++++ .../snapshot/scroll-edge-state/selection.ts | 85 ++++++++++++++----- 3 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-android-settings-fixture.ts create mode 100644 packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-android-settings-fixture.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-android-settings-fixture.ts new file mode 100644 index 0000000000..4207066f2b --- /dev/null +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-android-settings-fixture.ts @@ -0,0 +1,85 @@ +import { formatRole } from '@agent-device/kernel/snapshot'; +import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; + +/** + * The scroll-container chain of the Android 16 Settings home screen (Pixel 7, API 36), trimmed from + * live `snapshot --json` captures for #3737. `main_content_scrollable_container` and the + * `recycler_view` nested inside it share one rect. The RecyclerView offers ACTION_SCROLL_BACKWARD + * even at the top, so it reports `hiddenContentAbove` in both states; only the outer ScrollView's + * hint tracks where the content actually is. + */ +export type SettingsHomeState = 'at-top' | 'scrolled-down'; + +const SCREEN: Rect = { x: 0, y: 0, width: 1440, height: 3120 }; +const LIST_FRAME: Rect = { x: 0, y: 480, width: 1440, height: 2556 }; +const ROW_TOPS: Record = { + 'at-top': [480, 807, 1078, 1405], + 'scrolled-down': [493, 764, 1035, 1362], +}; +const ROW_LABELS: Record = { + 'at-top': ['Google', 'Network & internet', 'Connected devices', 'Apps'], + 'scrolled-down': ['Network & internet', 'Connected devices', 'Apps', 'Notifications'], +}; +const RECYCLER_VIEW_INDEX = 23; + +function node( + index: number, + type: string, + rect: Rect, + overrides: Partial = {}, +): SnapshotNode { + return { ref: `e${index + 1}`, index, type, kind: formatRole(type), rect, ...overrides }; +} + +export function settingsHomeTree(state: SettingsHomeState): SnapshotNode[] { + const rows = ROW_TOPS[state].map((y, row) => + node( + RECYCLER_VIEW_INDEX + 1 + row, + 'android.widget.LinearLayout', + { x: 0, y, width: 1440, height: 271 }, + { parentIndex: RECYCLER_VIEW_INDEX, label: ROW_LABELS[state][row] }, + ), + ); + return [ + node(8, 'android.widget.FrameLayout', SCREEN), + node(10, 'android.widget.FrameLayout', SCREEN, { + parentIndex: 8, + identifier: 'android:id/content', + }), + node( + 11, + 'android.widget.ScrollView', + { x: 0, y: 0, width: 1440, height: 3036 }, + { + parentIndex: 10, + identifier: 'com.android.settings:id/settings_homepage_container', + }, + ), + node( + 12, + 'android.widget.TextView', + { x: 252, y: 251, width: 518, height: 93 }, + { + parentIndex: 11, + identifier: 'com.android.settings:id/search_bar_title', + label: 'Search Settings', + }, + ), + node(18, 'android.widget.ScrollView', LIST_FRAME, { + parentIndex: 11, + identifier: 'com.android.settings:id/main_content_scrollable_container', + hiddenContentBelow: true, + ...(state === 'scrolled-down' ? { hiddenContentAbove: true } : {}), + }), + node(22, 'android.widget.FrameLayout', LIST_FRAME, { + parentIndex: 18, + identifier: 'android:id/list_container', + }), + node(RECYCLER_VIEW_INDEX, 'androidx.recyclerview.widget.RecyclerView', LIST_FRAME, { + parentIndex: 22, + identifier: 'com.android.settings:id/recycler_view', + hiddenContentAbove: true, + }), + ...rows, + ]; +} diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts new file mode 100644 index 0000000000..a2b06211dd --- /dev/null +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -0,0 +1,55 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { runScrollEdgePasses } from '../scroll-edge-state.ts'; +import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; +import { settingsHomeTree } from './scroll-edge-state-android-settings-fixture.ts'; + +// --------------------------------------------------------------------------- +// A scroller nested in a scrollable ancestor with the same rect takes its edge hints from that +// ancestor (#3737): the inner RecyclerView on Android Settings claims hidden content above even at +// the top, while the outer ScrollView framing the same pixels reports where the content is. +// --------------------------------------------------------------------------- + +test('scroll top edge: the Android Settings list at the top reports no hidden content above', async () => { + const state = await capture(settingsHomeTree('at-top'), 'top'); + assert.equal(state.canScroll, false); +}); + +test('scroll top edge: the Android Settings list scrolled down still reports hidden content above', async () => { + const state = await capture(settingsHomeTree('scrolled-down'), 'top'); + assert.equal(state.canScroll, true); +}); + +test('scroll bottom edge: the Android Settings list at the top still reports hidden content below', async () => { + const state = await capture(settingsHomeTree('at-top'), 'bottom'); + assert.equal(state.canScroll, true); +}); + +test('scroll top on Android Settings stops at the top instead of reporting no progress', async () => { + const frames = [settingsHomeTree('scrolled-down'), settingsHomeTree('at-top')]; + let scrolls = 0; + const outcome = await runScrollEdgePasses({ + edge: 'top', + captureState: async () => await capture(frames[Math.min(scrolls, frames.length - 1)]!, 'top'), + scroll: async () => { + scrolls += 1; + }, + }); + assert.equal(outcome.passes, 1); +}); + +test('scroll top edge: a nested scroller whose rect differs from its scrollable ancestor keeps its own hint', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'outer', rect: { x: 0, y: 0, width: 400, height: 800 } }), + scrollNode(2, { + parentIndex: 1, + identifier: 'inner', + hiddenContentAbove: true, + rect: { x: 0, y: 100, width: 400, height: 600 }, + }), + ]; + const state = await capture(nodes, 'top'); + assert.equal(state.scope, 'inner'); + assert.equal(state.canScroll, true); +}); diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index 7ef7a1fd94..c2d0048e0f 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -26,8 +26,11 @@ export function analyzeScrollEdgeState( }; } - const hiddenHints = deriveMobileSnapshotHiddenContentHints(nodes); - const container = selectScrollContainer(nodes, hiddenHints, edge, target); + const hasHiddenContentAtEdge = createEdgeHintReader( + nodes, + deriveMobileSnapshotHiddenContentHints(nodes), + ); + const container = selectScrollContainer(nodes, hasHiddenContentAtEdge, edge, target); if (!container) { return { canScroll: false, @@ -35,7 +38,7 @@ export function analyzeScrollEdgeState( }; } - const canScroll = hasHiddenContentAtEdge(container, hiddenHints.get(container.index), edge); + const canScroll = hasHiddenContentAtEdge(container, edge); return { canScroll, emptySnapshot: false, @@ -59,7 +62,7 @@ function normalizeSnapshotNodes( function selectScrollContainer( nodes: SnapshotNode[], - hiddenHints: Map, + hasHiddenContentAtEdge: EdgeHintReader, edge: ScrollEdge, target: ScrollEdgeTarget, ): SnapshotNode | null { @@ -74,28 +77,38 @@ function selectScrollContainer( const targetPoint = target.point; if (targetPoint) { - const containing = selectPointScrollContainer(scrollables, hiddenHints, edge, targetPoint); + const containing = selectPointScrollContainer( + scrollables, + hasHiddenContentAtEdge, + edge, + targetPoint, + ); if (containing) return containing; - return selectBroadScrollContainer(scrollables, hiddenHints, edge, visibility); + return selectBroadScrollContainer(scrollables, hasHiddenContentAtEdge, edge, visibility); } const viewportCenter = inferViewportCenter(nodes); if (viewportCenter) { - const centered = selectPointScrollContainer(scrollables, hiddenHints, edge, viewportCenter); + const centered = selectPointScrollContainer( + scrollables, + hasHiddenContentAtEdge, + edge, + viewportCenter, + ); if (centered) return centered; } - return selectBroadScrollContainer(scrollables, hiddenHints, edge, visibility); + return selectBroadScrollContainer(scrollables, hasHiddenContentAtEdge, edge, visibility); } function selectBroadScrollContainer( scrollables: SnapshotNode[], - hiddenHints: Map, + hasHiddenContentAtEdge: EdgeHintReader, edge: ScrollEdge, visibility: ReturnType, ): SnapshotNode | null { const withHiddenEdge = scrollables - .filter((node) => hasHiddenContentAtEdge(node, hiddenHints.get(node.index), edge)) + .filter((node) => hasHiddenContentAtEdge(node, edge)) .sort((a, b) => compareByArea(b, a)); if (withHiddenEdge.length > 0) return withHiddenEdge[0] ?? null; @@ -107,16 +120,14 @@ function selectBroadScrollContainer( function selectPointScrollContainer( scrollables: SnapshotNode[], - hiddenHints: Map, + hasHiddenContentAtEdge: EdgeHintReader, edge: ScrollEdge, point: Point, ): SnapshotNode | null { const containing = scrollables .filter((node) => node.rect && containsPoint(node.rect, point)) .sort(compareByArea); - const withHiddenEdge = containing.find((node) => - hasHiddenContentAtEdge(node, hiddenHints.get(node.index), edge), - ); + const withHiddenEdge = containing.find((node) => hasHiddenContentAtEdge(node, edge)); return withHiddenEdge ?? containing[0] ?? null; } @@ -146,15 +157,47 @@ function findNearestScrollableAncestor( return null; } -function hasHiddenContentAtEdge( +type EdgeHintReader = (node: SnapshotNode, edge: ScrollEdge) => boolean; + +/** + * Reads a container's hidden-content hint for an edge verdict. A scroller nested in a scrollable + * ancestor with the same rect answers with that ancestor's hints: Android's Settings RecyclerView + * offers scroll-backward even at the top, while the ScrollView framing the same pixels reports the + * real position (#3737). The snapshot's own per-node hints stay unchanged. + */ +function createEdgeHintReader( + nodes: readonly SnapshotNode[], + hiddenHints: ReadonlyMap, +): EdgeHintReader { + const byIndex = new Map(nodes.map((node) => [node.index, node] as const)); + return (node, edge) => { + const owner = sameRectScrollableAncestor(node, byIndex) ?? node; + const hint = hiddenHints.get(owner.index); + if (edge === 'bottom') { + return owner.hiddenContentBelow === true || hint?.hiddenContentBelow === true; + } + return owner.hiddenContentAbove === true || hint?.hiddenContentAbove === true; + }; +} + +function sameRectScrollableAncestor( node: SnapshotNode, - hint: HiddenContentHint | undefined, - edge: ScrollEdge, -): boolean { - if (edge === 'bottom') { - return node.hiddenContentBelow === true || hint?.hiddenContentBelow === true; + byIndex: ReadonlyMap, +): SnapshotNode | undefined { + let owner: SnapshotNode | undefined; + let current = node; + while (true) { + const ancestor = findNearestScrollableAncestor(current.parentIndex, byIndex); + if (!ancestor || !sameRect(ancestor.rect, current.rect)) return owner; + owner = ancestor; + current = ancestor; } - return node.hiddenContentAbove === true || hint?.hiddenContentAbove === true; +} + +function sameRect(a: SnapshotNode['rect'], b: SnapshotNode['rect']): boolean { + return Boolean( + a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height, + ); } function buildScrollContainerScope( From 4e9bcc8d349087611d3c936c69680a9cc310a133 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 09:50:31 -0400 Subject: [PATCH 2/6] fix(scroll): scope same-rect ancestor hints to the edge verdict Container selection reads each node's own hints again, as on main; only the final canScroll verdict consults the same-rect scrollable ancestor. The ancestor walk tracks visited indexes so a cyclic parentIndex chain in a malformed tree cannot hang edge analysis. Co-Authored-By: Claude Opus 5.5 --- ...roll-edge-state-same-rect-ancestor.test.ts | 17 +++ .../snapshot/scroll-edge-state/selection.ts | 101 +++++++++--------- 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts index a2b06211dd..d1f9ecd6cf 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -15,6 +15,23 @@ test('scroll top edge: the Android Settings list at the top reports no hidden co assert.equal(state.canScroll, false); }); +test('scroll top edge: the ancestor answers only the verdict, so selection still names the nested list', async () => { + const state = await capture(settingsHomeTree('at-top'), 'top'); + assert.equal(state.scope, 'com.android.settings:id/recycler_view'); +}); + +test('scroll top edge: a cyclic chain of same-rect scrollables ends the ancestor walk', async () => { + const rect = { x: 0, y: 100, width: 400, height: 600 }; + const nodes = [ + windowRoot(), + scrollNode(1, { parentIndex: 2, identifier: 'first', hiddenContentAbove: true, rect }), + scrollNode(2, { parentIndex: 1, identifier: 'second', rect }), + ]; + const state = await capture(nodes, 'top'); + assert.equal(state.scope, 'first'); + assert.equal(state.canScroll, false); +}); + test('scroll top edge: the Android Settings list scrolled down still reports hidden content above', async () => { const state = await capture(settingsHomeTree('scrolled-down'), 'top'); assert.equal(state.canScroll, true); diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index c2d0048e0f..1e187c3f72 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -26,11 +26,8 @@ export function analyzeScrollEdgeState( }; } - const hasHiddenContentAtEdge = createEdgeHintReader( - nodes, - deriveMobileSnapshotHiddenContentHints(nodes), - ); - const container = selectScrollContainer(nodes, hasHiddenContentAtEdge, edge, target); + const hiddenHints = deriveMobileSnapshotHiddenContentHints(nodes); + const container = selectScrollContainer(nodes, hiddenHints, edge, target); if (!container) { return { canScroll: false, @@ -38,7 +35,12 @@ export function analyzeScrollEdgeState( }; } - const canScroll = hasHiddenContentAtEdge(container, edge); + // A scroller nested in a scrollable ancestor with the same rect answers the edge verdict with + // that ancestor's hints: Android's Settings RecyclerView offers scroll-backward even at the top, + // while the ScrollView framing the same pixels reports the real position (#3737). Selection and + // the snapshot's own per-node hints are unchanged. + const verdictNode = sameRectScrollableAncestor(container, nodes) ?? container; + const canScroll = hasHiddenContentAtEdge(verdictNode, hiddenHints.get(verdictNode.index), edge); return { canScroll, emptySnapshot: false, @@ -62,7 +64,7 @@ function normalizeSnapshotNodes( function selectScrollContainer( nodes: SnapshotNode[], - hasHiddenContentAtEdge: EdgeHintReader, + hiddenHints: Map, edge: ScrollEdge, target: ScrollEdgeTarget, ): SnapshotNode | null { @@ -77,38 +79,28 @@ function selectScrollContainer( const targetPoint = target.point; if (targetPoint) { - const containing = selectPointScrollContainer( - scrollables, - hasHiddenContentAtEdge, - edge, - targetPoint, - ); + const containing = selectPointScrollContainer(scrollables, hiddenHints, edge, targetPoint); if (containing) return containing; - return selectBroadScrollContainer(scrollables, hasHiddenContentAtEdge, edge, visibility); + return selectBroadScrollContainer(scrollables, hiddenHints, edge, visibility); } const viewportCenter = inferViewportCenter(nodes); if (viewportCenter) { - const centered = selectPointScrollContainer( - scrollables, - hasHiddenContentAtEdge, - edge, - viewportCenter, - ); + const centered = selectPointScrollContainer(scrollables, hiddenHints, edge, viewportCenter); if (centered) return centered; } - return selectBroadScrollContainer(scrollables, hasHiddenContentAtEdge, edge, visibility); + return selectBroadScrollContainer(scrollables, hiddenHints, edge, visibility); } function selectBroadScrollContainer( scrollables: SnapshotNode[], - hasHiddenContentAtEdge: EdgeHintReader, + hiddenHints: Map, edge: ScrollEdge, visibility: ReturnType, ): SnapshotNode | null { const withHiddenEdge = scrollables - .filter((node) => hasHiddenContentAtEdge(node, edge)) + .filter((node) => hasHiddenContentAtEdge(node, hiddenHints.get(node.index), edge)) .sort((a, b) => compareByArea(b, a)); if (withHiddenEdge.length > 0) return withHiddenEdge[0] ?? null; @@ -120,14 +112,16 @@ function selectBroadScrollContainer( function selectPointScrollContainer( scrollables: SnapshotNode[], - hasHiddenContentAtEdge: EdgeHintReader, + hiddenHints: Map, edge: ScrollEdge, point: Point, ): SnapshotNode | null { const containing = scrollables .filter((node) => node.rect && containsPoint(node.rect, point)) .sort(compareByArea); - const withHiddenEdge = containing.find((node) => hasHiddenContentAtEdge(node, edge)); + const withHiddenEdge = containing.find((node) => + hasHiddenContentAtEdge(node, hiddenHints.get(node.index), edge), + ); return withHiddenEdge ?? containing[0] ?? null; } @@ -157,41 +151,48 @@ function findNearestScrollableAncestor( return null; } -type EdgeHintReader = (node: SnapshotNode, edge: ScrollEdge) => boolean; +function hasHiddenContentAtEdge( + node: SnapshotNode, + hint: HiddenContentHint | undefined, + edge: ScrollEdge, +): boolean { + if (edge === 'bottom') { + return node.hiddenContentBelow === true || hint?.hiddenContentBelow === true; + } + return node.hiddenContentAbove === true || hint?.hiddenContentAbove === true; +} /** - * Reads a container's hidden-content hint for an edge verdict. A scroller nested in a scrollable - * ancestor with the same rect answers with that ancestor's hints: Android's Settings RecyclerView - * offers scroll-backward even at the top, while the ScrollView framing the same pixels reports the - * real position (#3737). The snapshot's own per-node hints stay unchanged. + * The outermost scrollable in an unbroken chain of same-rect scrollable ancestors, or undefined + * when the nearest scrollable ancestor frames different pixels. Visited indexes stop a cyclic + * `parentIndex` chain in a malformed tree. */ -function createEdgeHintReader( +function sameRectScrollableAncestor( + node: SnapshotNode, nodes: readonly SnapshotNode[], - hiddenHints: ReadonlyMap, -): EdgeHintReader { - const byIndex = new Map(nodes.map((node) => [node.index, node] as const)); - return (node, edge) => { - const owner = sameRectScrollableAncestor(node, byIndex) ?? node; - const hint = hiddenHints.get(owner.index); - if (edge === 'bottom') { - return owner.hiddenContentBelow === true || hint?.hiddenContentBelow === true; +): SnapshotNode | undefined { + const byIndex = new Map(nodes.map((candidate) => [candidate.index, candidate] as const)); + const visited = new Set([node.index]); + let owner: SnapshotNode | undefined; + let current = node; + let parent = parentOf(current, byIndex); + while (parent && !visited.has(parent.index)) { + visited.add(parent.index); + if (isScrollableNodeLike(parent) && isUsableRect(parent.rect)) { + if (!sameRect(parent.rect, current.rect)) return owner; + owner = parent; + current = parent; } - return owner.hiddenContentAbove === true || hint?.hiddenContentAbove === true; - }; + parent = parentOf(parent, byIndex); + } + return owner; } -function sameRectScrollableAncestor( +function parentOf( node: SnapshotNode, byIndex: ReadonlyMap, ): SnapshotNode | undefined { - let owner: SnapshotNode | undefined; - let current = node; - while (true) { - const ancestor = findNearestScrollableAncestor(current.parentIndex, byIndex); - if (!ancestor || !sameRect(ancestor.rect, current.rect)) return owner; - owner = ancestor; - current = ancestor; - } + return node.parentIndex === undefined ? undefined : byIndex.get(node.parentIndex); } function sameRect(a: SnapshotNode['rect'], b: SnapshotNode['rect']): boolean { From 273e3410f1df4b1d9013345c1ebb24fe95e7c7a9 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 09:54:59 -0400 Subject: [PATCH 3/6] fix(scroll): select the same-rect ancestor so scoped recaptures keep it Scoping the ancestor's hints to the verdict alone let selection pick the nested RecyclerView at the top; the loop then scoped its recapture to that subtree, lost the ancestor, and fell back to the stale hint (scroll_edge_no_progress on a second `scroll top`). Scroll edge selection now stands a same-rect scrollable ancestor in for the scroller nested in it, so the chosen container, its scope and its verdict all come from the ancestor. The visited-index guard stays. Co-Authored-By: Claude Opus 5.5 --- ...roll-edge-state-same-rect-ancestor.test.ts | 28 ++++++++++++++--- .../snapshot/scroll-edge-state/selection.ts | 31 +++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts index d1f9ecd6cf..54339a4a86 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -3,6 +3,18 @@ import assert from 'node:assert/strict'; import { runScrollEdgePasses } from '../scroll-edge-state.ts'; import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; import { settingsHomeTree } from './scroll-edge-state-android-settings-fixture.ts'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; + +function subtreeFor(nodes: readonly SnapshotNode[], scope: string | undefined): SnapshotNode[] { + if (scope === undefined) return [...nodes]; + const root = nodes.find((node) => node.identifier === scope); + if (!root) return []; + const inScope = new Set([root.index]); + for (const node of nodes) { + if (node.parentIndex !== undefined && inScope.has(node.parentIndex)) inScope.add(node.index); + } + return nodes.filter((node) => inScope.has(node.index)); +} // --------------------------------------------------------------------------- // A scroller nested in a scrollable ancestor with the same rect takes its edge hints from that @@ -15,9 +27,17 @@ test('scroll top edge: the Android Settings list at the top reports no hidden co assert.equal(state.canScroll, false); }); -test('scroll top edge: the ancestor answers only the verdict, so selection still names the nested list', async () => { - const state = await capture(settingsHomeTree('at-top'), 'top'); - assert.equal(state.scope, 'com.android.settings:id/recycler_view'); +test('scroll top on Android Settings already at the top costs no pass under scoped recapture', async () => { + // Scoped captures return only the scope root's subtree, as an Android scoped snapshot does. + const tree = settingsHomeTree('at-top'); + const outcome = await runScrollEdgePasses({ + edge: 'top', + captureState: async (scope) => await capture(subtreeFor(tree, scope), 'top'), + scroll: async () => { + throw new Error('scroll top dispatched a gesture at the top'); + }, + }); + assert.equal(outcome.passes, 0); }); test('scroll top edge: a cyclic chain of same-rect scrollables ends the ancestor walk', async () => { @@ -29,7 +49,7 @@ test('scroll top edge: a cyclic chain of same-rect scrollables ends the ancestor ]; const state = await capture(nodes, 'top'); assert.equal(state.scope, 'first'); - assert.equal(state.canScroll, false); + assert.equal(state.canScroll, true); }); test('scroll top edge: the Android Settings list scrolled down still reports hidden content above', async () => { diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index 1e187c3f72..ab518a263a 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -35,12 +35,7 @@ export function analyzeScrollEdgeState( }; } - // A scroller nested in a scrollable ancestor with the same rect answers the edge verdict with - // that ancestor's hints: Android's Settings RecyclerView offers scroll-backward even at the top, - // while the ScrollView framing the same pixels reports the real position (#3737). Selection and - // the snapshot's own per-node hints are unchanged. - const verdictNode = sameRectScrollableAncestor(container, nodes) ?? container; - const canScroll = hasHiddenContentAtEdge(verdictNode, hiddenHints.get(verdictNode.index), edge); + const canScroll = hasHiddenContentAtEdge(container, hiddenHints.get(container.index), edge); return { canScroll, emptySnapshot: false, @@ -69,12 +64,20 @@ function selectScrollContainer( target: ScrollEdgeTarget, ): SnapshotNode | null { const visibility = createSnapshotVisibility(nodes); - const scrollables = nodes.filter((node) => isScrollableNodeLike(node) && isUsableRect(node.rect)); + const edgeContainer = (node: SnapshotNode): SnapshotNode => + sameRectScrollableAncestor(node, visibility.nodeByIndex) ?? node; + const scrollables = [ + ...new Set( + nodes + .filter((node) => isScrollableNodeLike(node) && isUsableRect(node.rect)) + .map(edgeContainer), + ), + ]; if (scrollables.length === 0) return null; const targetAncestor = findNearestScrollableAncestor(target.nodeIndex, visibility.nodeByIndex); if (targetAncestor) { - return targetAncestor; + return edgeContainer(targetAncestor); } const targetPoint = target.point; @@ -163,15 +166,17 @@ function hasHiddenContentAtEdge( } /** - * The outermost scrollable in an unbroken chain of same-rect scrollable ancestors, or undefined - * when the nearest scrollable ancestor frames different pixels. Visited indexes stop a cyclic - * `parentIndex` chain in a malformed tree. + * The outermost scrollable in an unbroken chain of same-rect scrollable ancestors, or undefined when + * the nearest scrollable ancestor frames different pixels. Scroll edge selection stands that + * ancestor in for the nested scroller: Android's Settings RecyclerView offers scroll-backward even at + * the top, while the ScrollView framing the same pixels reports the real position (#3737). Choosing + * the ancestor also keeps the nested scroller inside the scoped recapture. Visited indexes stop a + * cyclic `parentIndex` chain in a malformed tree. */ function sameRectScrollableAncestor( node: SnapshotNode, - nodes: readonly SnapshotNode[], + byIndex: ReadonlyMap, ): SnapshotNode | undefined { - const byIndex = new Map(nodes.map((candidate) => [candidate.index, candidate] as const)); const visited = new Set([node.index]); let owner: SnapshotNode | undefined; let current = node; From 3818a90490910c3692044cb16c9ab8fbf3a795ed Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 10:01:50 -0400 Subject: [PATCH 4/6] fix(scroll): guard the targeted scrollable-ancestor walk against cycles findNearestScrollableAncestor followed parentIndex without a visited set, so a targeted scroll on a malformed tree whose parent chain loops with no scrollable ancestor could hang edge analysis. It now stops on a revisited index. Co-Authored-By: Claude Opus 5.5 --- .../scroll-edge-state-same-rect-ancestor.test.ts | 12 ++++++++++++ .../src/snapshot/scroll-edge-state/selection.ts | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts index 54339a4a86..004896aa85 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -90,3 +90,15 @@ test('scroll top edge: a nested scroller whose rect differs from its scrollable assert.equal(state.scope, 'inner'); assert.equal(state.canScroll, true); }); + +test('scroll top edge: a targeted node in a cyclic chain with no scrollable ancestor ends the walk', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'list', hiddenContentAbove: true }), + { ...scrollNode(2, { parentIndex: 3 }), type: 'Button', kind: 'button' }, + { ...scrollNode(3, { parentIndex: 2 }), type: 'Group', kind: 'group' }, + ]; + const state = await capture(nodes, 'top', { nodeIndex: 2 }); + assert.equal(state.scope, 'list'); + assert.equal(state.canScroll, true); +}); diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index ab518a263a..ac00a19c71 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -144,12 +144,14 @@ function findNearestScrollableAncestor( byIndex: ReadonlyMap, ): SnapshotNode | null { if (nodeIndex === undefined) return null; + const visited = new Set(); let node = byIndex.get(nodeIndex); - while (node) { + while (node && !visited.has(node.index)) { + visited.add(node.index); if (isScrollableNodeLike(node) && isUsableRect(node.rect)) { return node; } - node = node.parentIndex === undefined ? undefined : byIndex.get(node.parentIndex); + node = parentOf(node, byIndex); } return null; } From dc24f9a76d9927c8548953127dcd4224111a9af7 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 12:02:59 -0400 Subject: [PATCH 5/6] refactor(scroll): name the same-rect heuristic and share the scoped-subtree fixture The doc comment now states that the same-rect check is geometric and ignores scroll axis. subtreeFor moves to the shared scroll-edge-state fixtures. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/scroll-edge-state-fixtures.ts | 15 +++++++++++++++ .../scroll-edge-state-same-rect-ancestor.test.ts | 14 +------------- .../src/snapshot/scroll-edge-state/selection.ts | 4 ++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts index 349fa7ca8d..66aa8e958b 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts @@ -116,3 +116,18 @@ export async function captureThrows(scope: string | undefined): Promise node.identifier === scope); + if (!root) return []; + const inScope = new Set([root.index]); + for (const node of nodes) { + if (node.parentIndex !== undefined && inScope.has(node.parentIndex)) inScope.add(node.index); + } + return nodes.filter((node) => inScope.has(node.index)); +} diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts index 004896aa85..3a9e32d179 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -1,20 +1,8 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { runScrollEdgePasses } from '../scroll-edge-state.ts'; -import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; +import { capture, scrollNode, subtreeFor, windowRoot } from './scroll-edge-state-fixtures.ts'; import { settingsHomeTree } from './scroll-edge-state-android-settings-fixture.ts'; -import type { SnapshotNode } from '@agent-device/kernel/snapshot'; - -function subtreeFor(nodes: readonly SnapshotNode[], scope: string | undefined): SnapshotNode[] { - if (scope === undefined) return [...nodes]; - const root = nodes.find((node) => node.identifier === scope); - if (!root) return []; - const inScope = new Set([root.index]); - for (const node of nodes) { - if (node.parentIndex !== undefined && inScope.has(node.parentIndex)) inScope.add(node.index); - } - return nodes.filter((node) => inScope.has(node.index)); -} // --------------------------------------------------------------------------- // A scroller nested in a scrollable ancestor with the same rect takes its edge hints from that diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index ac00a19c71..eeef7661fd 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -174,6 +174,10 @@ function hasHiddenContentAtEdge( * the top, while the ScrollView framing the same pixels reports the real position (#3737). Choosing * the ancestor also keeps the nested scroller inside the scoped recapture. Visited indexes stop a * cyclic `parentIndex` chain in a malformed tree. + * + * This is a geometric heuristic: an identical rect is taken to mean the two scrollers move the same + * content. It ignores scroll axis, so a horizontal scroller framing exactly the same pixels as a + * vertical list nested in it would stand in for that list. No known screen has that shape. */ function sameRectScrollableAncestor( node: SnapshotNode, From f4c2c24062410688d6995e5940523ccb3b881084 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 17 Oct 2026 12:11:09 -0400 Subject: [PATCH 6/6] fix(scroll): require a same-rect ancestor to report a vertical edge Geometry carries no scroll axis, so a horizontal pager framing the same pixels as a vertical list on its page could stand in for that list and hide its edge hints. The snapshot emits vertical edge hints only for vertical scrollers, so an ancestor now stands in only when it reports one. subtreeFor matches scopes and reindexes nodes the way an Android scoped snapshot does. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/scroll-edge-state-fixtures.ts | 11 ++++++-- ...roll-edge-state-same-rect-ancestor.test.ts | 13 +++++++++ .../snapshot/scroll-edge-state/selection.ts | 28 +++++++++++-------- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts index 66aa8e958b..756b938d78 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-fixtures.ts @@ -4,6 +4,7 @@ import { type ScrollEdgeTarget, } from '../scroll-edge-state.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { matchesSnapshotScope, reindexSnapshotNodes } from '@agent-device/contracts/snapshot'; import { formatRole } from '@agent-device/kernel/snapshot'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; @@ -117,17 +118,21 @@ export async function captureThrows(scope: string | undefined): Promise node.identifier === scope); + const root = nodes.find((node) => matchesSnapshotScope(node, scope)); if (!root) return []; const inScope = new Set([root.index]); for (const node of nodes) { if (node.parentIndex !== undefined && inScope.has(node.parentIndex)) inScope.add(node.index); } - return nodes.filter((node) => inScope.has(node.index)); + return reindexSnapshotNodes(nodes.filter((node) => inScope.has(node.index))); } diff --git a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts index 3a9e32d179..ffff79a16e 100644 --- a/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts @@ -90,3 +90,16 @@ test('scroll top edge: a targeted node in a cyclic chain with no scrollable ance assert.equal(state.scope, 'list'); assert.equal(state.canScroll, true); }); + +test('scroll bottom edge: a same-rect horizontal pager does not stand in for the vertical list on its page', async () => { + // A horizontal pager reports no vertical edge hints; its page's vertical list carries them. + const rect = { x: 0, y: 100, width: 400, height: 600 }; + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'pager', rect }), + scrollNode(2, { parentIndex: 1, identifier: 'feed', hiddenContentBelow: true, rect }), + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.scope, 'feed'); + assert.equal(state.canScroll, true); +}); diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts index eeef7661fd..5fec0a0727 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state/selection.ts @@ -64,8 +64,11 @@ function selectScrollContainer( target: ScrollEdgeTarget, ): SnapshotNode | null { const visibility = createSnapshotVisibility(nodes); + const reportsVerticalEdge = (node: SnapshotNode): boolean => + hasHiddenContentAtEdge(node, hiddenHints.get(node.index), 'top') || + hasHiddenContentAtEdge(node, hiddenHints.get(node.index), 'bottom'); const edgeContainer = (node: SnapshotNode): SnapshotNode => - sameRectScrollableAncestor(node, visibility.nodeByIndex) ?? node; + sameRectScrollableAncestor(node, visibility.nodeByIndex, reportsVerticalEdge) ?? node; const scrollables = [ ...new Set( nodes @@ -168,20 +171,23 @@ function hasHiddenContentAtEdge( } /** - * The outermost scrollable in an unbroken chain of same-rect scrollable ancestors, or undefined when - * the nearest scrollable ancestor frames different pixels. Scroll edge selection stands that - * ancestor in for the nested scroller: Android's Settings RecyclerView offers scroll-backward even at - * the top, while the ScrollView framing the same pixels reports the real position (#3737). Choosing - * the ancestor also keeps the nested scroller inside the scoped recapture. Visited indexes stop a - * cyclic `parentIndex` chain in a malformed tree. + * The outermost scrollable in an unbroken chain of same-rect, vertically scrolling ancestors, or + * undefined when the nearest scrollable ancestor frames different pixels or reports no vertical edge. + * Scroll edge selection stands that ancestor in for the nested scroller: Android's Settings + * RecyclerView offers scroll-backward even at the top, while the ScrollView framing the same pixels + * reports the real position (#3737). Choosing the ancestor also keeps the nested scroller inside the + * scoped recapture. Visited indexes stop a cyclic `parentIndex` chain in a malformed tree. * - * This is a geometric heuristic: an identical rect is taken to mean the two scrollers move the same - * content. It ignores scroll axis, so a horizontal scroller framing exactly the same pixels as a - * vertical list nested in it would stand in for that list. No known screen has that shape. + * An identical rect is a geometric heuristic for "these two scrollers move the same content", and + * geometry carries no scroll axis. Requiring the ancestor to report a vertical edge hint keeps a + * horizontal pager whose page holds a vertical list from standing in for that list; the snapshot + * emits vertical edge hints only for vertical scrollers. A vertical ancestor whose content fits in + * both directions reports no edge either, and leaves the nested scroller to answer for itself. */ function sameRectScrollableAncestor( node: SnapshotNode, byIndex: ReadonlyMap, + reportsVerticalEdge: (candidate: SnapshotNode) => boolean, ): SnapshotNode | undefined { const visited = new Set([node.index]); let owner: SnapshotNode | undefined; @@ -190,7 +196,7 @@ function sameRectScrollableAncestor( while (parent && !visited.has(parent.index)) { visited.add(parent.index); if (isScrollableNodeLike(parent) && isUsableRect(parent.rect)) { - if (!sameRect(parent.rect, current.rect)) return owner; + if (!sameRect(parent.rect, current.rect) || !reportsVerticalEdge(parent)) return owner; owner = parent; current = parent; }