Skip to content

fix(interaction): carry one post-gesture outcome on the tree and disclose it on every route - #2864

Merged
thymikee merged 1 commit into
mainfrom
fix/2853-unsettled-selector-interactions
Sep 24, 2026
Merged

thymikee merged 1 commit into
mainfrom
fix/2853-unsettled-selector-interactions

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #2853
Closes #2856 (folded in from #2868)

After a scroll/swipe whose surface never settled, a selector click/press/fill answered a plain selector_not_found. So did find … click when a sparse capture was recovered by re-capturing. A proven no-effect gesture was reported only on snapshot.

  • One field: SnapshotState.postGestureOutcome = { kind: 'unsettled' | 'no-effect', gesture } replaces unsettledGesture and gestureNoEffect, which were never released. One formatter builds the warning; the unsettled wording also holds when the command succeeds.
  • Inheritance: inheritPostGestureOutcome runs where a re-capture is taken at once, which is find's sparse recovery and the full-tree retry in resolveSelectorInteractionTarget.
  • Disclosure: there is one entry point, withCaptureDisclosures. Interactions pass their capture proof as the consumed tree. Failure sentences go to error.hint.
  • Deleted: withGestureNoEffectWarning, withRequestCaptureDisclosures, and gesture-no-effect.ts.
  • Reuse: a stored tree is refused for reuse only when kind === 'unsettled'; a settled no-effect tree is reused.
  • E2E helper: only unsettled maps to settle-and-re-read.

Diff against main, 36 files:

  • production: 19 files, +165/−172
  • tests: 15 files, +313/−83
  • CHANGELOG and docs: +12/−6

Validation

Tested SHA 95daea082f. pnpm check:affected --run: passed (852 test files; two earlier runs under load average 400-900 timed out in unrelated files, which then passed in isolation).

Each named mutation fails a test:

  • either inheritance site removed
  • the hint written to details.hint
  • a kind === 'unsettled' filter in buildSnapshotWarnings or withCaptureDisclosures, which breaks the new route-level no-effect cases on snapshot and is
  • the reuse check refusing no-effect trees, or allowing unsettled ones
  • the E2E helper settling on any outcome

Live on an iOS 26.2 sim, Catalog list. Each run was a fling followed immediately by the command.

Command Result
click, press, fill on a missing selector 9/9 returned selector_not_found with postGestureOutcome {kind:"unsettled"} and one warning
press on the tab bar 3/3 succeeded, carrying the same field and one warning
find … click (earlier head) 5/5 carried the field
no-effect is/get (earlier head) 4/6 returned kind: "no-effect"

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-24 13:51 UTC

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB +288 B
Package (unpacked) 4.80 MB 4.80 MB +288 B
Package (download) 1.44 MB 1.44 MB -470 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.2 ms 27.5 ms +0.3 ms
CLI --help 79.0 ms 82.4 ms +3.4 ms

@thymikee
thymikee force-pushed the fix/2853-unsettled-selector-interactions branch from 0b82302 to 56ce5c0 Compare September 24, 2026 08:12
@thymikee thymikee changed the title fix(interaction): disclose an unsettled post-gesture surface on interactions fix(interaction): carry one post-gesture outcome on the tree and disclose it on every route Sep 24, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Review round addressed in 56ce5c0: (1) re-captures inherit the outcome at the recovery and full-tree-retry sites, with a find-recovery regression test; (2) one entry point: interactions call withCaptureDisclosures with the proof as the consumed tree; (3) failure sentences always go to error.hint, the unsettled wording holds on success, and a success-side assertion was added. #2868 is folded in: one postGestureOutcome field replaces unsettledGesture/gestureNoEffect. Production is net −7.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 56ce5c0. The core change is sound: one discriminated postGestureOutcome field replaces the annotation channel and two formatters, and production lines go down.

One design question first. The two explicit re-capture sites (sparse recovery and full-tree retry) now own the outcome. The only smaller option I can see is to inherit the outcome through the request's capture binding, but that would also stamp post-action captures. Did you consider that, and is that the reason you kept the two sites?

The no-effect warning lost its route-level test. src/daemon/__tests__/deferred-interaction-outcome.test.ts:303 used to assert that the #1600 warning reaches the caller (/produced no visible change/). Now it only checks that the tree is stamped, and "no visible change" appears only in the formatter test. So a filter such as postGestureOutcome?.kind === 'unsettled' in buildSnapshotWarnings (snapshot.ts#L264) or in withCaptureDisclosures would pass every test. Please add one kind: 'no-effect' case on snapshot and one on is or get, driven by markDeferredInteractionOutcome with identical trees, and assert both the warning and the typed field.

The live iOS runs covered find … click and is/get after a no-effect gesture. The plain click/press/fill <selector> route after an unsettled scroll, the main case of #2853, was not run live. It shares the device capture and unit tests cover its daemon plumbing.

Not blocking: the stored-tree reuse check (selector-capture-runtime.ts#L283) now refuses any tree with postGestureOutcome, including a settled no-effect one. Is that intended, or should it check only kind === 'unsettled'? The CHANGELOG counts (+165/-172) also do not match the diff (+167/-173).

Smoke Tests was still running. It runs scroll followed by selector reads, which this PR changes, so a failure there would need a look before it is called unrelated.

…lose it on every route

A selector click, press, or fill right after a scroll whose surface never
settled missed on that moving tree and answered a plain selector_not_found,
and find lost the fact when a sparse capture was recovered by a re-capture.
A proven no-effect gesture reached only snapshot, through annotations that
selector reads and interactions drop.

Both facts are one field, SnapshotState.postGestureOutcome ({ kind:
'unsettled' | 'no-effect', gesture }), stamped by post-gesture
stabilization. A re-capture taken at once to recover or widen that tree
inherits it. Every route discloses it through withCaptureDisclosures;
interactions pass their request capture proof as the consumed tree. A
failure's disclosure sentence now lands in error.hint, the hint request
finalization keeps. withGestureNoEffectWarning is deleted.

Closes #2853
Closes #2856
@thymikee
thymikee force-pushed the fix/2853-unsettled-selector-interactions branch from 56ce5c0 to 95daea0 Compare September 24, 2026 12:28
@thymikee

Copy link
Copy Markdown
Member Author

Addressed in 95daea0, rebased on main after #2883 and #2869.

Why two sites, not the capture binding. The request's capture binding is interaction-runtime.ts:51-59 for interactions and selector-capture-runtime.ts:232 for selector reads. That binding also serves:

  • the post-action observation (post-action-observation.ts:106)
  • the settle observation (generic-settle.ts:106, which goes through the same createInteractionRuntime capture)
  • the pre-action evidence baseline (resolution.ts:271)
  • every wait poll

If the binding inherited the outcome, those captures would be stamped with a gesture outcome they did not observe. wait absent would then read every poll as unsettled (wait-absent.ts:67) and never pass. Only a re-capture taken at once to recover or widen the same tree reads the same moment, and those are the two sites.

No-effect route tests. One kind: 'no-effect' case each on snapshot (snapshot-runtime-disclosure.test.ts) and on is (is-runtime.test.ts), both driven by markDeferredInteractionOutcome with identical trees.

  • They assert the warning and the typed field. For snapshot the typed field is the stamp on the stored tree, because snapshot exposes only the warning, the same as for unsettled.
  • A kind === 'unsettled' filter in buildSnapshotWarnings or in withCaptureDisclosures makes them fail.

Live, plain route. On my sim: fling, then immediately click, press, or fill on a missing selector. 9/9 returned selector_not_found with postGestureOutcome {kind:"unsettled"} and one warning. press on the tab bar succeeded 3/3 and carried the same field and one warning.

Reuse check. A no-effect tree passed an accepted verdict: two agreeing captures that match the baseline. Re-capturing it only reproduces the same tree. The check now refuses only kind === 'unsettled'.

  • The is test pins it: the re-read after no-effect takes no capture, and the unsettled test still re-captures.
  • Each direction was checked by mutation.

E2E helper. Only unsettled maps to settle-and-re-read. A new test asserts that a no-effect miss does not settle.

Counts. The PR body now splits the diff as production +165/−172 (19 files), tests +313/−83, and CHANGELOG and docs +12/−6.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 95daea0. The code looks good. The design question from the last review is answered: only the two explicit re-capture sites own the outcome, because stamping it through the capture binding would make wait absent read every poll as unsettled. The #1600 no-effect warning now has route tests on snapshot and is again.

One small note, not blocking: a selector read that reuses a stored no-effect tree repeats the outcome, and the new is test checks only the capture count on the second read. Could it also assert what the second response discloses (selector-capture-runtime.ts#L284)?

All checks pass, and there are no conflicts.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit d852b79 into main Sep 24, 2026
20 checks passed
@thymikee
thymikee deleted the fix/2853-unsettled-selector-interactions branch September 24, 2026 13:51
thymikee added a commit that referenced this pull request Sep 24, 2026
…ag change

readFragments ignores dotfiles, so a stray .DS_Store no longer aborts
npm version; every other entry still has to be a valid fragment.

The #2864 fragment restated the unsettled-surface fix that v0.21.13
already shipped. It now names only what changed after the tag: the
unsettledGesture field renamed to postGestureOutcome, the no-effect
kind, and disclosure on interactions.

The vitest lane comment now says the migration guard runs one git show.
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

1 participant