fix(scroll): refuse an unreadable capture before scroll top/bottom decides

Merged
AI
3h ago
+399-152

Summary

scroll top/scroll bottom no longer report "Already at bottom" on a capture they could not read. A missing, empty or sparse tree now fails with scroll_edge_capture_unreadable (with captureRefusal: no-capture | sparse-tree) before any edge verdict or gesture.

Following the shape in the issue, both scroll loops now run on one runScrollPasses({ observe, refuse, shouldStop, passLimit, ... }) in capture-kit. selectors depends on capture-kit, so capture-kit can't import sparseCaptureQuality. The loop therefore takes a refuse hook, and the daemon defines the refusal once (scroll-capture-refusal.ts) for both scroll --until and the edge scrolls.

One ordering change: the edge loop now checks its 40-pass budget before the stuck window, as --until already did. This only differs if a surface first reads as stuck on exactly pass 40.

6 files, no scope expansion. Fixes #2468.

Validation

  • pnpm check:affected --run --head 62eb122: format, lint, typecheck, layering, fallow, build and vitest-related all pass (exactCommit=true).
  • New tests: runScrollPasses unit cases (refusal before the stop condition, budget, stuck window, budget wins ties) and scroll bottom daemon cases (no tree, empty tree, sparse tree, unreadable mid-loop). They failed before the change.
  • Live, Pixel_7_API_36 emulator, Settings: scroll bottom takes 1 pass to the bottom on both origin/main (4db1b7d) and this branch, in two runs each.
  • Not live-tested: the unreadable-capture path itself, which isn't reproducible on demand.
  • Seen on both main and this branch: scroll top right after scroll bottom on Settings fails with scroll_edge_no_progress. Out of scope here; I can file it separately.