Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions packages/contracts/src/scroll-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppError } from '@agent-device/kernel/errors';
import type { SettleObservation } from './interaction.ts';
import type { ScrollDirection } from './scroll-gesture.ts';
import type { Rect } from '@agent-device/kernel/snapshot';

export const SCROLL_DURATION_MAX_MS = 10_000;
export const DEFAULT_MOBILE_SCROLL_DURATION_MS = 300;
Expand Down Expand Up @@ -38,13 +39,24 @@ export type ScrollTimingOptions = {

export type ScrollCommandOptions = ScrollDistanceOptions & ScrollTimingOptions;

export type ScrollExecutionOptions = ScrollCommandOptions & {
releaseBehavior?: ScrollReleaseBehavior;
/**
* The scroll container the caller resolved from the current tree, in the snapshot's absolute space.
* An owner that plans its own swipe keeps both endpoints inside it (#2714); an owner without that
* planner ignores it.
*/
export type ScrollContainerTarget = {
containerRect?: Rect;
};

export type ResolvedScrollExecutionOptions = ScrollCommandOptions & {
releaseBehavior: ScrollReleaseBehavior;
};
export type ScrollExecutionOptions = ScrollCommandOptions &
ScrollContainerTarget & {
releaseBehavior?: ScrollReleaseBehavior;
};

export type ResolvedScrollExecutionOptions = ScrollCommandOptions &
ScrollContainerTarget & {
releaseBehavior: ScrollReleaseBehavior;
};

export function resolveScrollExecutionOptions(
options: ScrollCommandOptions,
Expand Down
94 changes: 94 additions & 0 deletions packages/contracts/src/scroll-gesture-container.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import type { Rect } from '@agent-device/kernel/snapshot';
import { buildScrollGesturePlan, type ScrollDirection } from './scroll-gesture.ts';

// The iPhone geometry #2714 was reported on: a 402x874 frame, the default 10% edge padding leaving
// y 87..787 for a swipe.
const FRAME = { referenceWidth: 402, referenceHeight: 874 };
const PADDED_TOP = 87;
const PADDED_BOTTOM = 787;
const INSET_CONTAINER: Rect = { x: 18, y: 178, width: 366, height: 662 };
const FITTING_CONTAINER: Rect = { x: 18, y: 62, width: 366, height: 729 };

function endpointsInside(
plan: { x1: number; y1: number; x2: number; y2: number },
rect: Rect,
): boolean {
const inside = (x: number, y: number) =>
x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
return inside(plan.x1, plan.y1) && inside(plan.x2, plan.y2);
}

test.each<ScrollDirection>(['up', 'down'])(
'a %s swipe longer than an inset container shrinks to the container and reports both spans',
(direction) => {
const unclamped = buildScrollGesturePlan({ direction, amount: 0.75, ...FRAME });
const plan = buildScrollGesturePlan({
direction,
amount: 0.75,
...FRAME,
container: INSET_CONTAINER,
});

assert.ok(endpointsInside(plan, INSET_CONTAINER), JSON.stringify(plan));
assert.deepEqual(
[Math.min(plan.y1, plan.y2), Math.max(plan.y1, plan.y2)],
[INSET_CONTAINER.y, PADDED_BOTTOM],
);
assert.equal(plan.pixels, PADDED_BOTTOM - INSET_CONTAINER.y);
assert.equal(plan.requestedPixels, unclamped.pixels);
assert.equal(Math.sign(plan.y2 - plan.y1), Math.sign(unclamped.y2 - unclamped.y1));
},
);

test.each<ScrollDirection>(['up', 'down', 'left', 'right'])(

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: No test covers a left or right swipe that is actually shortened or moved by a container. The ['up', 'down', 'left', 'right'] case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as { x: 100, y: 0, width: 200, height: 874 }, asserting the x endpoints and pixels/requestedPixels.

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 45:

<comment>No test covers a left or right swipe that is actually shortened or moved by a container. The `['up', 'down', 'left', 'right']` case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as `{ x: 100, y: 0, width: 200, height: 874 }`, asserting the x endpoints and `pixels`/`requestedPixels`.</comment>

<file context>
@@ -0,0 +1,94 @@
+  },
+);
+
+test.each<ScrollDirection>(['up', 'down', 'left', 'right'])(
+  'a %s swipe already inside its container keeps today’s endpoints and distance',
+  (direction) => {
</file context>

'a %s swipe already inside its container keeps today’s endpoints and distance',
(direction) => {
const unclamped = buildScrollGesturePlan({ direction, amount: 0.75, ...FRAME });
const plan = buildScrollGesturePlan({
direction,
amount: 0.75,
...FRAME,
container: FITTING_CONTAINER,
});

assert.deepEqual(plan, unclamped);
assert.equal('requestedPixels' in plan, false);
},
);

test('a swipe that fits an offset container moves into it without shortening', () => {
const lowerContainer: Rect = { x: 0, y: 300, width: 402, height: 574 };
const unclamped = buildScrollGesturePlan({ direction: 'down', amount: 0.5, ...FRAME });
const plan = buildScrollGesturePlan({
direction: 'down',
amount: 0.5,
...FRAME,
container: lowerContainer,
});

assert.ok(endpointsInside(plan, lowerContainer), JSON.stringify(plan));
assert.equal(Math.min(plan.y1, plan.y2), lowerContainer.y);
assert.equal(Math.abs(plan.y1 - plan.y2), Math.abs(unclamped.y1 - unclamped.y2));
assert.equal(plan.pixels, unclamped.pixels);
assert.equal('requestedPixels' in plan, false);
});

test('a vertical swipe beside a narrow container runs down its centre line', () => {
const rightColumn: Rect = { x: 250, y: 0, width: 152, height: 874 };
const unclamped = buildScrollGesturePlan({ direction: 'up', ...FRAME });
const plan = buildScrollGesturePlan({ direction: 'up', ...FRAME, container: rightColumn });

assert.equal(plan.x1, rightColumn.x + rightColumn.width / 2);
assert.equal(plan.x2, plan.x1);
assert.deepEqual([plan.y1, plan.y2, plan.pixels], [unclamped.y1, unclamped.y2, unclamped.pixels]);
});

test('a container lying wholly in the edge padding leaves the padded swipe as it was', () => {
const statusBand: Rect = { x: 0, y: 0, width: 402, height: PADDED_TOP - 1 };
const unclamped = buildScrollGesturePlan({ direction: 'up', ...FRAME });
const plan = buildScrollGesturePlan({ direction: 'up', ...FRAME, container: statusBand });

assert.deepEqual(plan, unclamped);
});
78 changes: 68 additions & 10 deletions packages/contracts/src/scroll-gesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export type ScrollGestureOptions = {
pixels?: number;
referenceWidth: number;
referenceHeight: number;
/**
* The scroll container the swipe is meant for, in the reference frame's own zero-origin space.
* Both endpoints stay inside its intersection with the padded frame (#2714): a span that already
* fits is unchanged, one that leaves it is moved in, and one longer than it is shortened.
*/
container?: Rect;
};

export type ScrollGesturePlan = {
Expand All @@ -60,7 +66,10 @@ export type ScrollGesturePlan = {
referenceWidth: number;
referenceHeight: number;
amount?: number;
/** The span dispatched, which is less than `requestedPixels` when the container shortened it. */
pixels: number;
/** Present only when the container shortened the swipe: the span the padded frame would carry. */
requestedPixels?: number;
};

export type SwipePresetGesturePlan = {
Expand Down Expand Up @@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
pixels: travelPixels,
});

switch (direction) {
case 'up':
return buildPlan(centerX, centerY - halfTravel, centerX, centerY + halfTravel);
case 'down':
return buildPlan(centerX, centerY + halfTravel, centerX, centerY - halfTravel);
case 'left':
return buildPlan(centerX - halfTravel, centerY, centerX + halfTravel, centerY);
case 'right':
return buildPlan(centerX + halfTravel, centerY, centerX - halfTravel, centerY);
}
const vertical = direction === 'up' || direction === 'down';
const unclamped = {
axisStart: (vertical ? centerY : centerX) - halfTravel,
axisEnd: (vertical ? centerY : centerX) + halfTravel,
cross: vertical ? centerX : centerY,
};
const lane = options.container
? keepSwipeInContainer(unclamped, options.container, vertical, {
min: edgePadding,
max: axisLength - edgePadding,
})
: unclamped;
const shortened = lane.axisEnd - lane.axisStart < unclamped.axisEnd - unclamped.axisStart;
const plan = buildPlan(
vertical ? lane.cross : lane.axisStart,
vertical ? lane.axisStart : lane.cross,
vertical ? lane.cross : lane.axisEnd,
vertical ? lane.axisEnd : lane.cross,
);
const placed = shortened
? { ...plan, pixels: lane.axisEnd - lane.axisStart, requestedPixels: travelPixels }

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: For odd travel distances, a shortened swipe can report pixels equal to requestedPixels, which contradicts the documented contract that pixels is smaller when shortened. Compare against the unclamped lane length, or derive requestedPixels from it.

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.ts, line 157:

<comment>For odd travel distances, a shortened swipe can report `pixels` equal to `requestedPixels`, which contradicts the documented contract that `pixels` is smaller when shortened. Compare against the unclamped lane length, or derive `requestedPixels` from it.</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+    vertical ? lane.axisEnd : lane.cross,
+  );
+  const placed = shortened
+    ? { ...plan, pixels: lane.axisEnd - lane.axisStart, requestedPixels: travelPixels }
+    : plan;
+  // `up`/`left` drag from the low end toward the high end; `down`/`right` drag the other way.
</file context>

: plan;
// `up`/`left` drag from the low end toward the high end; `down`/`right` drag the other way.
return direction === 'up' || direction === 'left'
? placed
: { ...placed, x1: placed.x2, y1: placed.y2, x2: placed.x1, y2: placed.y1 };
}

type SwipeLane = { axisStart: number; axisEnd: number; cross: number };

/**
* Moves a centred swipe lane inside the container's intersection with the padded axis, shortening
* it only when the intersection is shorter than the lane. A container wholly inside the padding
* leaves nothing to swipe in, so the lane stays as the padded frame planned it.
*/
function keepSwipeInContainer(
lane: SwipeLane,
container: Rect,
vertical: boolean,
padded: { min: number; max: number },
): SwipeLane {
const containerMin = vertical ? container.y : container.x;
const containerSize = vertical ? container.height : container.width;
const bandMin = Math.max(padded.min, Math.round(containerMin));
const bandMax = Math.min(padded.max, Math.round(containerMin + containerSize));
if (bandMax <= bandMin) return lane;

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: When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection.

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.ts, line 182:

<comment>When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection.</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+  const containerSize = vertical ? container.height : container.width;
+  const bandMin = Math.max(padded.min, Math.round(containerMin));
+  const bandMax = Math.min(padded.max, Math.round(containerMin + containerSize));
+  if (bandMax <= bandMin) return lane;
+
+  const crossMin = vertical ? container.x : container.y;
</file context>


const crossMin = vertical ? container.x : container.y;
const crossSize = vertical ? container.width : container.height;
const cross =

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 cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly).

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.ts, line 186:

<comment>The cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly).</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+
+  const crossMin = vertical ? container.x : container.y;
+  const crossSize = vertical ? container.width : container.height;
+  const cross =
+    lane.cross >= crossMin && lane.cross <= crossMin + crossSize
+      ? lane.cross
</file context>

lane.cross >= crossMin && lane.cross <= crossMin + crossSize
? lane.cross
: Math.round(crossMin + crossSize / 2);

const length = lane.axisEnd - lane.axisStart;
if (length >= bandMax - bandMin) return { axisStart: bandMin, axisEnd: bandMax, cross };
const shift =
lane.axisStart < bandMin ? bandMin - lane.axisStart : Math.min(0, bandMax - lane.axisEnd);
return { axisStart: lane.axisStart + shift, axisEnd: lane.axisEnd + shift, cross };
}

/**
Expand Down
67 changes: 67 additions & 0 deletions packages/daemon/src/execution/__tests__/scroll-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,3 +786,70 @@ test('bound scroll will not bill an unread earlier gesture to the scroll it is d
assert.equal(result.movement, 'unobserved');
assert.equal(captures.calls(), 0);
});

/**
* The container a directional swipe is planned inside (#2714): read off the same current tree the
* movement claim compares against, before the gesture, and never off a tree that may no longer be
* the screen.
*/
async function containerHandedToOwner(
positionals: string[],
session: SessionState,
flags?: CommandFlags,
): Promise<unknown> {
const calls: ScrollCall[] = [];
await runScroll(
positionals,
{},
{
scroll: async (direction, options) => {
calls.push({ direction, options });
return { ...OBSERVED_SWIPE };
},
},
{ session, ...(flags === undefined ? {} : { flags }) },
);
const [call] = calls;
assert.ok(call && calls.length === 1, `expected one scroll, got ${calls.length}`);
return (call.options as { containerRect?: unknown }).containerRect;
}

test.each<[string, CommandFlags | undefined]>([
['an observed scroll', undefined],
['a --settle scroll', { settle: true }],
])('bound scroll hands %s the container resolved from the current tree', async (_, flags) => {
const containerRect = await containerHandedToOwner(
['down', '0.75'],
sessionWithStoredScreen(automationScreen(0)),
flags,
);

assert.deepEqual(containerRect, SCREEN_CONTAINER);
});

test('bound scroll hands no container from a tree that predates the last mutation', async () => {
const session = sessionWithStoredScreen(automationScreen(0));
expireRefFrame(session);

assert.equal(await containerHandedToOwner(['down', '0.75'], session), undefined);
});

test('bound scroll hands no container for a horizontal swipe the edge analyzer cannot place', async () => {
const containerRect = await containerHandedToOwner(
['left'],
sessionWithStoredScreen(automationScreen(0)),
);

assert.equal(containerRect, undefined);
});

test('bound scroll hands no container to a desktop owner that never synthesizes a swipe', async () => {
const session = makeMacOsSession('scroll-desktop');
session.snapshot = buildSnapshotState(
{ nodes: automationScreen(0), backend: 'xctest', producer: 'apple-runner' },
undefined,
);
activateCompleteRefFrame(session);

assert.equal(await containerHandedToOwner(['down', '0.75'], session), undefined);
});
39 changes: 31 additions & 8 deletions packages/daemon/src/execution/scroll-movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,40 @@ export function readScrollSurfaceBaseline(
function freezeScrollSurfaceBaseline(session: SessionState): ScrollMovementPlan {
const baseline = readScrollSurfaceBaseline(session.snapshot);
if (!baseline) return { kind: 'unobservable', reason: 'stored-surface-unusable' };
if (refFrameState(session) !== 'active') {
return { kind: 'unobservable', reason: 'stored-surface-not-current' };
}
if (isPostGestureStabilizationPending(session)) {
// A gesture nobody has read yet may still be moving this tree, which would bill its motion to
// the scroll being dispatched now.
return { kind: 'unobservable', reason: 'prior-gesture-unsettled' };
}
const staleness = storedSurfaceStaleness(session);
if (staleness) return { kind: 'unobservable', reason: staleness };
return { kind: 'observe', baseline };
}

function storedSurfaceStaleness(
session: SessionState,
): Exclude<ScrollSurfaceBaselineAbsence, 'stored-surface-unusable'> | undefined {
if (refFrameState(session) !== 'active') return 'stored-surface-not-current';
// A gesture nobody has read yet may still be moving this tree, which would bill its motion to
// the scroll being dispatched now.
if (isPostGestureStabilizationPending(session)) return 'prior-gesture-unsettled';
return undefined;
}

/**
* The scroll container a directional swipe should stay inside (#2714), read before the gesture off
* the same current tree a movement claim would compare against, through the edge verdict's own
* selection. A tree that may no longer be the screen names no container, and neither does a
* horizontal scroll, which the vertical-only edge analyzer cannot place, nor a platform that moves
* content without a synthesized swipe.
*/
export async function readScrollTargetContainer(
device: DeviceInfo,
session: SessionState,
direction: ScrollDirection,
): Promise<Rect | undefined> {
if (!isMobilePlatform(device)) return undefined;

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: readScrollTargetContainer is gated only by isMobilePlatform, which includes iOS. iOS does not clamp yet, so this computes an edge analysis of the whole tree on every iOS directional scroll and discards the result. Gate it to Android so the iOS path stays as it was.

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/daemon/src/execution/scroll-movement.ts, line 194:

<comment>`readScrollTargetContainer` is gated only by `isMobilePlatform`, which includes iOS. iOS does not clamp yet, so this computes an edge analysis of the whole tree on every iOS directional scroll and discards the result. Gate it to Android so the iOS path stays as it was.</comment>

<file context>
@@ -164,17 +164,40 @@ export function readScrollSurfaceBaseline(
+  session: SessionState,
+  direction: ScrollDirection,
+): Promise<Rect | undefined> {
+  if (!isMobilePlatform(device)) return undefined;
+  const edge = verticalEdgeFor(direction);
+  const nodes = session.snapshot?.nodes;
</file context>
Suggested change
if (!isMobilePlatform(device)) return undefined;
if (device.platform !== 'android') return undefined;

const edge = verticalEdgeFor(direction);
const nodes = session.snapshot?.nodes;
if (!edge || !nodes || nodes.length === 0 || storedSurfaceStaleness(session)) return undefined;
return (await readScrollEdgeState(nodes, edge)).containerRect;
}

/**
* Observes one directional scroll and returns the movement its response may claim.
*
Expand Down
16 changes: 15 additions & 1 deletion packages/daemon/src/execution/scroll-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import {
observeScrollMovement,
planScrollMovement,
readScrollTargetContainer,
reportScrollMovementNotApplicable,
reportScrollMovementUnobserved,
type ScrollMovementPlan,
Expand Down Expand Up @@ -145,10 +146,23 @@ export async function resolveBoundScrollRuntime(
flags: params.flags,
session: params.session,
});
const containerRect = await readScrollTargetContainer(
params.device,
params.session,
target.direction,
);
const directionOptions =
containerRect === undefined ? options : { ...options, containerRect };
return await resolveBoundGenericRuntime(
{ ...admission, use: plan.use },
async (runtime, dispatchContext) =>
await executeDirectionScroll(runtime, target, options, dispatchContext, movementPlan),
await executeDirectionScroll(
runtime,
target,
directionOptions,
dispatchContext,
movementPlan,
),
);
}
case 'edge': {
Expand Down
Loading
Loading