Skip to content

fix(scroll): name what a scroll observed and read an edge off one tree - #2770

Merged
thymikee merged 1 commit into
mainfrom
fix/scroll-surface-vocabulary
Sep 23, 2026
Merged

thymikee merged 1 commit into
mainfrom
fix/scroll-surface-vocabulary

Conversation

@thymikee

@thymikee thymikee commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

What

Four behavior-neutral pieces the #2714 fix needs, extracted so the layer that changes an answer (#2771) can be reviewed against nothing else. Nothing in this PR changes an answer on any surface — no producer of any of it exists until the layers above.

  • movement (packages/contracts/src/scroll-command.ts) — the vocabulary for what a directional scroll saw: moved / at-edge / unchanged / unobserved, plus the result field. Absent is not the same claim as unobserved, and the type doc says so.
  • honoredScrollSwipeMidpoint (same file) — where a leaf's swipe ran, next to the honoredScrollPixels / honoredScrollDurationMs readers it belongs with, because "what the leaf reported about its gesture" is one question with one owner. Declines to guess when the owner reported no coordinates (a tvOS scroll is a remote keypress).
  • readScrollEdgeState + containerRect (packages/capture-kit/src/snapshot/scroll-edge-state.ts) — the analyzer's answer exposed as one pure door, widened with containerRect. canScrollFurtherAtEdge now goes through it. captureScrollEdgeState still calls analyzeScrollEdgeState directly because it passes a target, and scrollSurfaceFingerprint does so because it wants only the fingerprint: three callers, one analyzer, no second set of selection rules. containerRect answers what ScrollEdgeState could not: canScroll: false cannot distinguish this content ends here from the tree named no container at all.
  • scrollNoProgressHint — the recovery hint that a scroll failure prints. Three copies existed by the time this feature needed a fourth (capture-kit's scroll_edge_no_progress, scroll --until's, and the new scroll_no_progress); this consolidates them into one owner with the two axes that actually differ — whether the failing command has an --until form to point at, and whether it can name where a raw drag would land. The three existing strings are reproduced byte-for-byte, which the passing tests show rather than assert.

Why the split rather than one PR

Because the seam is behavior-neutral vs behavior-bearing, not "pure refactor vs the rest". Every line here is unreachable from the CLI: movement has no producer until #2776 computes it, containerRect has no reader until #2776's edge verdict reads it, honoredScrollSwipeMidpoint has no caller until #2771's swipe evidence, and the hint owner has a third consumer the same day #2771 lands. #2776 and #2771 are where each field earns its producer and its reader, and the CI of those two layers is where a mistake in this one surfaces.

Validation

  • verticalEdgeFor is exported from scroll-edge-state.ts next to ScrollEdge (it was duplicated verbatim in scroll-until.ts, and fix(scroll): observe the movement a directional scroll can claim #2776 needs it too), and formatScrollEdgeMessage now reads the axis through it: a horizontal scroll can no longer be answered with "no hidden content above was detected" — it falls through to the distance it was asked for. Test pinned.
  • Full pnpm check:affected --run → all runnable checks passed at the stack head 882256121; at this layer's own head (a266c3927) the related suites (146 files / 786 tests), tsc, oxlint, oxfmt and fallow are clean standalone.
  • New coverage for the new surface: scroll-edge-state-read.test.ts (a resolved container reports the frame the decision was taken on; pair: no container and a zero-area scrollable report none; and the two doors pinned against each other — canScroll, containerRect, fingerprint identical for one tree, so the extraction cannot fork a second truth); scroll-command.test.ts (midpoint from reported coordinates, pair: absent/partial/non-numeric coordinates); the formatScrollEdgeMessage movement branches (an observed edge answers without a distance; pair: unobserved keeps it, because the disclosure travels in the field).
  • Red/green: removing the two message branches turns those three assertions red; they are green here.

No device run is needed for this slice — it is unreachable until the layers above, and the live evidence for the feature is recorded on #2771.

🤖 Generated with Apex by Callstack

@thymikee
thymikee added this pull request to stack #2773 September 23, 2026 08:26
@thymikee thymikee changed the title fix/scroll surface vocabulary fix(scroll): name what a scroll observed and read an edge off one tree Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.79 MB 4.79 MB +1.8 kB
Package (unpacked) 4.79 MB 4.79 MB +1.8 kB
Package (download) 1.43 MB 1.43 MB +697 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.4 ms 28.5 ms +0.1 ms
CLI --help 79.2 ms 82.4 ms +3.2 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at bd8464a. The refactor keeps behavior: canScrollFurtherAtEdge now goes through readScrollEdgeState, which calls the same analyzeScrollEdgeState with the same default target, and scrollResult() never passes movement yet, so the new message branches can't be reached.

One question before this goes to review. ScrollMovementObservation, ScrollCommandResult.movement, readScrollEdgeState and ScrollEdgeState.containerRect have no production producer or reader in this slice; only tests use them (scroll-command.ts#L278). Would folding this into #2771, so the vocabulary lands with its first reader, be the smaller change? Isolating the pure refactor for review is a fair reason to keep the split. If that is the reason, please say so, and make sure #2771 gives every new field and branch a real producer and reader.

CI was still running when I looked, with no failures. This slice changes no route that is in use, so check whether a later Smoke or Integration failure exercises scroll before tying it to this PR. No device run is needed for this slice.

@thymikee
thymikee force-pushed the fix/scroll-surface-vocabulary branch from bd8464a to 571d5cd Compare September 23, 2026 08:54
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 571d5cd: this is the same patch as bd8464a after a rebase, and CI is green.

The question from the bd8464a review is still open. Should movement, containerRect and readScrollEdgeState (https://github.com/callstack/agent-device/blob/571d5cd/packages/contracts/src/scroll-command.ts#L139) move into #2771, so they land with their first real producer and reader? Or is the split on purpose, to review the vocabulary on its own? Either answer is fine; please put one on record. If the split stays, #2771 should wire every field and branch added here to a real producer and consumer.

Not blocking: the PR body says captureScrollEdgeState now delegates to readScrollEdgeState, but only canScrollFurtherAtEdge does; captureScrollEdgeState and scrollSurfaceFingerprint still call analyzeScrollEdgeState directly, so the body or those call sites need a small fix.

@thymikee
thymikee force-pushed the fix/scroll-surface-vocabulary branch from 571d5cd to a624ec1 Compare September 23, 2026 10:22
@thymikee
thymikee removed this pull request from stack #2773 September 23, 2026 10:23
@thymikee
thymikee added this pull request to stack #2777 September 23, 2026 10:23
@thymikee

Copy link
Copy Markdown
Member Author

Kept the split, and the reason is the one you allowed — stated explicitly: this layer is behavior-neutral, and the layer above it is behavior-bearing. Not "pure refactor vs the rest": movement has no producer until #2776, containerRect has no reader until #2776's edge verdict, honoredScrollSwipeMidpoint (new here) has no caller until #2771, and the hint owner gains its third consumer the day #2771 lands. Folding it into the movement PR would have made one ~1,180-line PR that mixes "nothing changed" with "the answer changed", which is the split I did not want to ask you to review.

Every new field and branch does have a real producer and reader, one layer up:

this slice producer reader
ScrollMovementObservation / movement #2776 observeScrollMovement → #2771 scrollResult formatScrollEdgeMessage (outranks the distance), MCP output schema, scroll-runtime.test.ts wire assertions
readScrollEdgeState #2770 (both captureScrollEdgeState and canScrollFurtherAtEdge now route through it) #2776 decideEdgeVerdict
containerRect analyzeScrollEdgeState #2776: refusal payload + the swipe-inside-container check
honoredScrollSwipeMidpoint leaf-reported x1..y2 #2771 swipeEvidence
scrollNoProgressHint this PR (three copies → one owner) scroll_edge_no_progress, scroll_until_no_progress, scroll_no_progress

Two of those rows are new in this round: the hint consolidation (my PR was adding a third copy of that sentence, so it now owns one) and the swipe-midpoint reader, which moved out of the daemon into the file that already owns "what the leaf reported".

Also note the layer count changed while addressing #2771: this is layer 1 of 4, with the observation module split out as #2776 to keep each PR under the budget. Validation is on the PR bodies at named commits.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed a624ec1. The code is ready for review. This layer changes no behavior, and the no-progress hint strings are byte-identical to main. No conflicts. CI was still running with nothing failed.

Please fix one claim in the PR body. captureScrollEdgeState does not route through readScrollEdgeState: it still calls analyzeScrollEdgeState with target (scroll-edge-state.ts#L55-L56), and so does scrollSurfaceFingerprint. All callers still share one analyzer, so nothing is forked.

Not blocking: formatScrollEdgeMessage accepts movement: 'at-edge' for any direction, and left/right map to "above" (L209). Should the at-edge branch accept only up/down, or is that meant to land with #2776?

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 23, 2026
@thymikee
thymikee force-pushed the fix/scroll-surface-vocabulary branch 2 times, most recently from bb4ae93 to 0e99f4d Compare September 23, 2026 12:21
@thymikee

Copy link
Copy Markdown
Member Author

This is ready at 0e99f4d: the guard now names what a scroll observed and reads the edge off one tree, fixing what the earlier pass on a624ec1 flagged. Not blocking: honoredScrollSwipeMidpoint and readScrollEdgeState still have no production importer outside tests, and the new "a horizontal scroll never claims a vertical edge" test calls the pure formatter directly rather than the scroll-runtime route, so landing this together with (or right before) the layer that wires movement into scrollResult, with a scroll-runtime-level test for the at-edge and horizontal cases, can be taken or left. Every check was still queued or in progress when I looked, and none had failed; since the diff touches scroll message formatting and the scroll --until import, which Smoke, Integration and Coverage exercise, a failure there needs its own explanation rather than being pinned on main. I did not run the tests or typecheck myself and judged the new test by reading the diff, and CI results were not in yet. CI needs to go green on 0e99f4d before this can merge.

@thymikee

Copy link
Copy Markdown
Member Author

Both points taken, on 0e99f4d29.

The body claim was wrong and is corrected. captureScrollEdgeState passes a target and scrollSurfaceFingerprint wants only the fingerprint, so both still call analyzeScrollEdgeState directly; canScrollFurtherAtEdge is the caller that now goes through readScrollEdgeState. One analyzer, three callers, no second set of selection rules — that is what the body says now.

On the at-edge branch accepting any direction: it should not, and it no longer does. verticalEdgeFor is exported from scroll-edge-state.ts next to ScrollEdge (this also removes the verbatim copy #2776 flagged against scroll-until.ts, so --until and the movement observation read one definition of which directions have an end signal), and formatScrollEdgeMessage consults it before speaking. A horizontal at-edge now falls through to the distance it was asked for rather than inventing "above"; pinned by formatScrollEdgeMessage: a horizontal scroll never claims a vertical edge.

Validation for this layer: related suites 146 files / 786 tests, tsc, oxlint, oxfmt and the fallow audit clean at 0e99f4d29; full pnpm check:affected --run green at the stack head 6a0661bfa.

🤖 Generated with Apex by Callstack

Two pieces the directional-scroll answer needs, with no behavior change on its own.

`movement` is the vocabulary a scroll uses to say what happened to the surface: `moved`,
`at-edge`, `unchanged` when the visible content did not change, and `unobserved` when the
command could not compare anything. It is absent when the runtime cannot read a screen at
all, so a caller never has to guess whether a missing claim means "did not move".

`readScrollEdgeState` is the one pure door into the edge analyzer — the question
`canScrollFurtherAtEdge` and `scrollSurfaceFingerprint` already ask, exposed with the frame
of the container that answer was taken on. Without `containerRect`, `canScroll: false`
cannot distinguish content that ends here from a tree that named no container, and a caller
checking whether its gesture landed inside the list has nothing to check against.
@thymikee
thymikee force-pushed the fix/scroll-surface-vocabulary branch from 0e99f4d to a266c39 Compare September 23, 2026 13:35
@thymikee

Copy link
Copy Markdown
Member Author

CI note: this layer's two Smoke failures both die at Apple toolchain probe failed: xcrun --sdk iphonesimulator --show-sdk-version (spawnSync xcrun ETIMEDOUT) — scripts/write-xcuitest-cache-metadata.mjs:183 — after ** TEST BUILD SUCCEEDED ** and before a single test runs. The probe carries a fatal 5 s budget (timeout: 5000, in place since #688; the file was last touched by #2306), so a saturated macOS runner aborts the whole job rather than the probe. Nothing in this layer's diff touches scripts/; the fallow scope for all four layers is clean locally. Rerun triggered, currently queued behind runner contention. Related Apple spawn work is tracked in #2784 / #2818.

🤖 Generated with Apex by Callstack

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant