Skip to content

fix(webui): refuse stale-anchor scroll restore during streaming (#5637) - #5666

Merged
5 commits merged into
nesquena:masterfrom
allenliang2022:fix/5637-layer3-stale-anchor
Jul 6, 2026
Merged

5 commits merged into
nesquena:masterfrom
allenliang2022:fix/5637-layer3-stale-anchor

Conversation

@allenliang2022

Copy link
Copy Markdown
Contributor

Summary

A residual mobile scroll jump-back remained after #5638. While the reader is scrolled up
in history during a live stream, the viewport is nudged backward by a few hundred px on
each streaming tick.

Root cause

The same-frame scroll restore (_restoreMessageScrollSnapshotSameFrame) captures an
anchor before a live DOM update, then restores to it afterward. During streaming, the
incoming chunk grows content above the viewport, so the captured topOffset (and the
absolute snapshot.top) no longer map to the same content — realigning to them yanks a
still reader backward.

The existing snapshot.userUnpinned === true fallback skip does not cover this: the
scrollHeight-collapse scroll event re-pins the state machine (flips userUnpinned back
to false) mid-stream, so both scroll-writing exits still fire:

  1. the semantic realign (_restoreMessageViewportAnchor, scrollTop += delta), and
  2. the absolute snapshot.top fallback.

Fix

Two guards, both keyed on content-growth-since-capture + absence of recent real input
intent
— deliberately not a scrollTop diff. On an overflow-anchor: auto
container (the mobile resting state) the browser itself writes scrollTop to compensate
above-viewport growth, so a genuinely still reader's scrollTop is not stationary; a
scrollTop-diff "did the user move?" test is defeated by that compensation.
_recentMessageScrollIntent / _recentMessageTouchScrollIntent instead reflect genuine
touchmove / wheel / keydown input, which the browser's anchor layer never sets.

  • _captureMessageViewportAnchor now records scrollHeightAtCapture (+ scrollTopAtCapture).
  • _restoreMessageViewportAnchor refuses the realign when content grew since capture, there
    is no recent input intent, and the delta would move scrollTop more than a few px.
  • the absolute snapshot.top fallback in _restoreMessageScrollSnapshotSameFrame mirrors
    the same guard.

An actively scrolling reader (recent intent, e.g. a load-older prepend they triggered)
keeps the legitimate restore; a fresh anchor (no growth) and legacy snapshots without the
captured geometry are unaffected.

Tests

tests/test_issue5637_stale_anchor_guard.py — 7 node-harness cases covering both guards:
refuses the stale realign / fallback, allows a fresh anchor, allows an actively-scrolling
reader, and stays backward-compatible with snapshots lacking the captured geometry. Two
cases are mutation-checked (removing either guard makes them fail).

Builds on #5638 (content-visibility scoping + the unpinned-hold), which addressed the
earlier layers of the same report.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds two streaming stale-anchor guards to prevent a scroll jump-back regression on Android during live streams: when content grows above the viewport mid-stream, the previously captured anchor offset becomes stale and any realign attempt yanks a still reader backward. Both guards key on content growth since capture plus absence of genuine input intent, explicitly avoiding a scrollTop-diff test (which the browser's own overflow-anchor compensation defeats on touch).

  • _captureMessageViewportAnchor records scrollHeightAtCapture so _restoreMessageViewportAnchor can refuse a realign when scrollHeight grew, no real input was received, and the delta exceeds 8px.
  • A parallel guard in _restoreMessageScrollSnapshotSameFrame mirrors this logic for the absolute snapshot.top fallback write, which the existing userUnpinned check missed because a scrollHeight-collapse event re-pins the state machine mid-stream.
  • _isTouchLikeMessageViewport (with an iOS WebKit carve-out via _isIOSWebKit) gates both guards to Android touch only, preserving the semantic realign on desktop and iOS where overflow-anchor is absent or inert.

Confidence Score: 5/5

Safe to merge — the guards are narrowly scoped to Android touch viewports and degrade gracefully on desktop, iOS, and legacy snapshots without the captured geometry.

The fix is targeted: scrollHeightAtCapture is added to an already-existing anchor object, both guards are behind _isTouchLikeMessageViewport so desktop and iOS behavior is unchanged, and the Number.isFinite / > 4 / > 8 thresholds ensure the guards are no-ops for subpixel rounding and for snapshots captured before this change. The 16 node-harness tests include mutation-checked cases that would fail if either guard condition were removed, and the backward-compat cases confirm legacy code paths are unaffected.

No files require special attention.

Important Files Changed

Filename Overview
static/ui.js Adds scrollHeightAtCapture to anchor object, two stale-anchor guards (realign + fallback), and _isIOSWebKit/_isTouchLikeMessageViewport predicates — logic is sound, desktop/iOS exclusions are correctly gated, all code paths are tested.
tests/test_issue5637_stale_anchor_guard.py 16 node-harness tests covering refuse/allow cases for both guards, desktop and iOS exclusions, the matchMedia-clobbered inline override, backward-compat with legacy snapshots, and active-intent pass-through for both guard paths; mutation annotations are present.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[_renderMessagesWithScrollSnapshot] -->|capture| B[_captureMessageScrollSnapshot\nrecords scrollHeight, anchor\nwith scrollHeightAtCapture]
    B --> C[renderMessages DOM update\ncontent may grow above viewport]
    C --> D[_restoreMessageScrollSnapshotSameFrame]
    D --> E{pinned follower?}
    E -->|yes| F[tail-relative restore]
    E -->|no| G{anchor restore\n_restoreMessageViewportAnchor}
    G --> H{NEW GUARD:\ntouchHold AND\ngrewSinceCapture AND\n!activeIntent AND\ndelta > 8px?}
    H -->|yes| I[return false - refuse realign]
    H -->|no| J[scrollTop realign]
    G -->|no anchor / refused| K{fallback: absolute\nsnapshot.top write}
    K --> L{NEW GUARD:\nfbTouchHold AND\n!pinned AND\ngrewSinceSnap AND\n!fbActiveIntent AND\ndelta > 8px?}
    L -->|yes| M[return early - set userUnpinned=true]
    L -->|no| N[el.scrollTop = target]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[_renderMessagesWithScrollSnapshot] -->|capture| B[_captureMessageScrollSnapshot\nrecords scrollHeight, anchor\nwith scrollHeightAtCapture]
    B --> C[renderMessages DOM update\ncontent may grow above viewport]
    C --> D[_restoreMessageScrollSnapshotSameFrame]
    D --> E{pinned follower?}
    E -->|yes| F[tail-relative restore]
    E -->|no| G{anchor restore\n_restoreMessageViewportAnchor}
    G --> H{NEW GUARD:\ntouchHold AND\ngrewSinceCapture AND\n!activeIntent AND\ndelta > 8px?}
    H -->|yes| I[return false - refuse realign]
    H -->|no| J[scrollTop realign]
    G -->|no anchor / refused| K{fallback: absolute\nsnapshot.top write}
    K --> L{NEW GUARD:\nfbTouchHold AND\n!pinned AND\ngrewSinceSnap AND\n!fbActiveIntent AND\ndelta > 8px?}
    L -->|yes| M[return early - set userUnpinned=true]
    L -->|no| N[el.scrollTop = target]
Loading

Reviews (5): Last reviewed commit: "fix(webui): exclude iOS WebKit from stal..." | Re-trigger Greptile

Comment thread static/ui.js Outdated
Comment thread static/ui.js Outdated
Comment thread tests/test_issue5637_stale_anchor_guard.py
@allenliang2022

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed all three points in the follow-up commit:

  • Removed the dead scrollTopAtCapture field; only scrollHeightAtCapture is read.
  • Renamed _grewAbove_grewSinceCapture (the check is overall scrollHeight growth since capture, not specifically above-viewport), matching the fallback guard's _grewSinceSnap.
  • Added a mutation-checked active-intent test for the fallback path (test_fallback_allows_snapshot_top_with_active_intent): content grew but the reader has recent real input intent, so the absolute snapshot.top restore is kept — mirrors the realign-guard active-intent case.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — RED ⛔ (the mobile stale-anchor guard runs on ALL platforms → breaks DESKTOP scroll-restore, which has no native overflow-anchor to fall back to — 2 CORE)

Certified head: sha:34f9d55e (rebased onto current master, git apply clean) · PR: #5666 · allenliang2022 (NEW, T1), fix(webui): refuse stale-anchor scroll restore during streaming (#5637)
Verdict: The stale-anchor-during-streaming concept is correct and it fixes the mobile case (Fable SHIP-UX for the mobile surface), but Codex found — and proved with a focused Node harness — that the guard's core assumption ("let native overflow-anchor hold the viewport") is MOBILE-ONLY while the guard actually runs on ALL platforms. On desktop (.messages uses overflow-anchor:none), refusing the restore means the viewport is NOT held by anything → the desktop reader's position breaks after above-viewport growth (the very bug this fixes on mobile, now caused on desktop).

What I ran (rebased worktree /tmp/wt-rebase-5666) — full gate: Codex + Fable + suite

Gate Result
Rebase onto current master git apply clean
Codex SHIP-WITH-FIXES — 2 CORE (cross-platform, harness-verified)
Fable-UX SHIP-UX (mobile surface — correct for what it reviewed; the break is desktop)
Full pytest suite 12193 passed, 0 failed (+ 233 scroll/anchor/pin tests)

Findings

⛔ CORE (Codex, static/ui.js:1051) — desktop unpinned streaming anchor-restore stops preserving the reader's position: the new growth/no-intent refusal runs for EVERY production anchor because scrollHeightAtCapture is now always set (captured at ui.js:960), and it assumes native overflow-anchor:auto will hold the viewport. But desktop .messages uses overflow-anchor:none → a legitimate realign after above-viewport height growth is refused and NO scroll write happens → desktop reader gets yanked/mispositioned. Fix: gate the refusal on _browserOverflowAnchorActive(container) — only refuse when the browser anchoring layer can actually hold the viewport; otherwise keep the existing _restoreMessageViewportAnchor scrollTop realign.
⛔ CORE (Codex, static/ui.js:13233) — desktop same-frame fallback restore also skipped with no native anchoring: the fallback guard has the same "let browser overflow-anchor hold" assumption → on desktop, anchor-failed restores after content growth leave the viewport at the wrong absolute position AND force _messageUserUnpinned=true. Fix: apply the stale-snapshot refusal only when _browserOverflowAnchorActive(el) is true; otherwise preserve the previous absolute fallback write.

Why the tests + Fable missed it: the #4295 anchor-restore harness uses a legacy anchor WITHOUT scrollHeightAtCapture, while production captures it — so the guard never fires in the test. Fable reviewed the mobile scroll-feel (correct: SHIP-UX there); the regression is desktop-only, where there's no native overflow-anchor to compensate the refused restore.

Recommendation to the next agent / author

RED — gate-fail/changes-requested (2 CORE): gate both stale-anchor refusals on _browserOverflowAnchorActive(container/el) so they fire ONLY where native overflow-anchor:auto can actually hold the viewport (mobile); on desktop (overflow-anchor:none) keep the existing semantic-realign / absolute-fallback scroll write. The mobile fix is good and addresses the real secondary #5637 JS cause Fable flagged on #5639 — it just can't assume browser-native anchoring universally. Add a desktop-path regression using the production snapshot shape (with scrollHeightAtCapture) so this is caught. concept 4/5 (correct mobile fix, cross-platform gap). Author @allenliang2022 (new, T1). crit=3. (Gate value: Fable SHIP-UX'd the mobile surface; Codex caught the desktop break by building a harness with the production anchor shape — the two legs covered different platforms, exactly the point of running both. This is the 3rd #5637-family fix (#5635/#5638/#5666) — all need the same land-order reconciliation.)


_Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Stale-anchor-during-streaming refusal is correct for mobile (overflow-anchor:auto) but runs on ALL platforms since scrollHeightAtCapture is always set → desktop (overflow-anchor:none) refuses a legitimate restore with nothing to compensate = 2 CORE (ui.js:1051 realign, ui.js:13233 fallback). Fix: gate both on browserOverflowAnchorActive. Fable SHIP-UX (mobile), Codex caught desktop via harness; suite green (harness uses legacy anchor w/o scrollHeightAtCapture). 3rd #5637 fix — land-order w/ #5635/#5638. Cert valid for sha:34f9d55e.

@nesquena-hermes nesquena-hermes added gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Jul 6, 2026
…uena#5637)

A residual mobile scroll jump-back remained after nesquena#5638: while the reader is up in
history during a live stream, the anchor captured for a same-frame restore goes stale
as the streaming chunk grows content ABOVE the viewport. The anchor's captured
topOffset (and the absolute snapshot.top) no longer map to the same content, so
realigning to them yanks a still reader backward by a few hundred px per tick.

The existing snapshot.userUnpinned===true fallback skip does not cover it: the
scrollHeight-collapse scroll event re-pins the state machine (flips userUnpinned back to
false) mid-stream, so both the semantic realign (_restoreMessageViewportAnchor) and the
absolute snapshot.top fallback still fire.

Fix: two guards, both keyed on content-growth-since-capture + absence of recent real
input intent, NOT on a scrollTop diff — on an overflow-anchor:auto container the browser
itself writes scrollTop to compensate above-viewport growth, so a still reader's scrollTop
is not stationary; _recentMessage*ScrollIntent instead reflects genuine touch/wheel/key
input, which the browser's anchor layer never sets.
- _captureMessageViewportAnchor records scrollHeightAtCapture (+scrollTopAtCapture).
- _restoreMessageViewportAnchor refuses the realign when content grew since capture, there
  is no recent intent, and the delta would move scrollTop >8px.
- the absolute snapshot.top fallback in _restoreMessageScrollSnapshotSameFrame mirrors the
  same guard.
An actively scrolling reader (recent intent) keeps the legitimate restore; a fresh anchor
(no growth) and legacy snapshots without the captured geometry are unaffected. Adds
tests/test_issue5637_stale_anchor_guard.py (7 node-harness cases; two are mutation-checked
to fail if either guard is removed).
…ck intent test (nesquena#5637)

Greptile review follow-ups (no behavior change):
- Remove the captured-but-never-read scrollTopAtCapture field from
  _captureMessageViewportAnchor; only scrollHeightAtCapture is consulted.
- Rename _grewAbove -> _grewSinceCapture in the realign guard (the check is
  overall scrollHeight growth since capture, not specifically above-viewport),
  matching the fallback guard's _grewSinceSnap.
- Add a mutation-checked fallback active-intent test: content grew but the reader
  has recent real input intent, so the absolute snapshot.top restore is kept.
…ena#5637)

The two streaming stale-anchor guards added for nesquena#5637 refuse a scroll
restore and rely on the browser's native overflow-anchor layer to hold
the viewport. That layer is only active where .messages computes to
overflow-anchor:auto (touch viewports). On hover+fine-pointer desktops
.messages is overflow-anchor:none, so refusing the restore leaves nothing
to hold the reader -> the desktop reader is yanked after above-viewport
growth (the same jump the guards fix on mobile), and the fallback also
latches _messageUserUnpinned=true.

Gate both refusals on _isTouchLikeMessageViewport(container), a
matchMedia('(pointer:coarse)') predicate (falling back to the computed
overflow-anchor probe) so they fire only where native anchoring can
actually hold the viewport. Desktop keeps its semantic scrollTop realign
and absolute snapshot.top fallback. matchMedia is used rather than the
computed-anchor probe alone because the realign temporarily writes inline
overflowAnchor:none for its own scroll write, which a computed probe would
misread mid-realign.

Add two desktop regression tests (touch_like=False) covering the exact
stale-anchor case on a no-native-anchor viewport; both are mutation-checked
(dropping the touch gate makes them fail).
@allenliang2022
allenliang2022 force-pushed the fix/5637-layer3-stale-anchor branch from 34f9d55 to cab238c Compare July 6, 2026 06:51
@allenliang2022

Copy link
Copy Markdown
Contributor Author

Thanks for the gate cert — you're right, and I've fixed both CORE findings.

What was wrong

Both stale-anchor refusals (realign at _restoreMessageViewportAnchor and the absolute-snapshot.top fallback in _restoreMessageScrollSnapshotSameFrame) assumed the browser's native overflow-anchor layer would hold the viewport once the JS restore was refused. Since scrollHeightAtCapture is always set in production, the refusal ran on every platform — but the "let native anchoring hold" assumption only holds where .messages computes to overflow-anchor:auto (touch viewports). On hover+fine-pointer desktops .messages is overflow-anchor:none, so refusing the restore left nothing to hold the reader after above-viewport growth, and the fallback additionally latched _messageUserUnpinned=true.

The fix

Both refusals are now gated on _isTouchLikeMessageViewport(container) so they only fire where native anchoring can actually hold the viewport. Desktop keeps its semantic scrollTop realign and its absolute snapshot.top fallback write.

I used a matchMedia('(pointer:coarse)') predicate (falling back to the computed overflow-anchor probe when matchMedia is absent) rather than _browserOverflowAnchorActive(container) alone, because _restoreMessageViewportAnchor temporarily writes an inline overflowAnchor:'none' on #messages for its own scroll write and only restores it on the next frame. When the realign fires every live tick, that inline none persists across ticks, so a computed-value probe reads none mid-realign and would misclassify a touch device as desktop — letting the stale realign through. pointer:coarse reflects the input device and can't be mutated by that inline write, so the gate stays stable across a realign burst; desktop (fine pointer) stays false regardless.

Desktop regression tests (mutation-checked)

Added two desktop-path tests that use the production anchor/snapshot shape (with scrollHeightAtCapture / snapshot.scrollHeight set) so the guard actually fires — the gap you noted where the #4295 harness uses a legacy anchor without the captured geometry:

  • test_realign_allows_on_desktop_no_native_anchor — the exact stale-anchor case (content grew, no intent, delta -453) on a touch_like=False viewport → the realign MUST run (returned True, one write), not refuse.
  • test_fallback_allows_snapshot_top_on_desktop_no_native_anchor — the exact stale-snapshot case on desktop → the absolute snapshot.top restore MUST be kept and _messageUserUnpinned must stay false.

Both are mutation-checked: dropping the _touchHold/_fbTouchHold term from either guard makes exactly these two fail while the eight mobile-path tests stay green. The mobile refusal tests are unchanged (they now pass touch_like=True explicitly).

Rebased onto current master; the three prior review points (dead scrollTopAtCapture field, _grewSinceCapture rename, fallback active-intent test) are still in.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-review of cab238c4c — both CORE findings resolved ✅

Reading the follow-up commit against origin/master in a read-only worktree, both desktop-break findings from the earlier gate-fail are now correctly addressed. The realign refusal (static/ui.js:1080-1089) and the absolute-snapshot.top fallback refusal (static/ui.js:13337-13349) are each gated on _isTouchLikeMessageViewport(...), so neither fires on a hover+fine-pointer desktop:

const _touchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(container));
if(_touchHold&&_grewSinceCapture&&!_activeIntent&&Math.abs(_realignDelta)>8){
  return false;
}

Why the predicate choice is right

The gate cert recommended _browserOverflowAnchorActive(container), but the switch to a matchMedia('(pointer:coarse)') primary probe (static/ui.js:996-1002) is the better call, and the code comment's reasoning holds up. The desktop CSS rule is:

@media (hover:hover) and (pointer:fine){.messages{overflow-anchor:none;}}

.messages resting state is overflow-anchor:auto (static/ui.js:2259), flipped to none only for pointer:fine desktops. So pointer:coarse is the exact complement of that media query — it identifies precisely the touch viewports where the native anchor layer can hold the reader. And because _restoreMessageViewportAnchor writes an inline overflowAnchor:'none' on #messages for its own scroll write (restored next frame), a computed-value probe read mid-realign burst would transiently see none and misclassify a touch device as desktop — letting the stale realign back through. pointer:coarse reflects the input device and can't be mutated by that inline override, so it stays stable across ticks. Good catch.

I also confirmed the fallback guard's snapshot.scrollHeight field is genuinely populated at capture (static/ui.js:13077, _captureMessageScrollSnapshot), so _grewSinceSnap isn't dead — this is the same snapshot object threaded into _restoreMessageScrollSnapshotSameFrame.

Test coverage

The two new desktop-path cases use the production anchor/snapshot shape (with scrollHeightAtCapture / snapshot.scrollHeight set) so the guard actually evaluates, closing the gap the #4295 legacy-anchor harness left:

  • test_realign_allows_on_desktop_no_native_anchor (touch_like=False) → asserts returned True and wrote == 1
  • test_fallback_allows_snapshot_top_on_desktop_no_native_anchor (touch_like=False) → asserts the absolute write is kept and _messageUserUnpinned stays False

Both are the exact regressions the cert flagged, and both are mutation-checked per the description.

Two minor observations (non-blocking)

  1. The harness stubs _isTouchLikeMessageViewport via the touch_like boolean rather than exercising the real matchMedia/computed-probe logic. That validates the guard wiring (the _touchHold term gates the refusal) but not the predicate's own mid-realign stability — the very claim that motivated choosing matchMedia over the computed probe. A small unit test that flips an inline overflowAnchor:'none' on a fake element and asserts the predicate stays true under pointer:coarse would lock that reasoning in.
  2. In a no-matchMedia environment the predicate falls back to _browserOverflowAnchorActive(el) — the computed probe the comment argues is unreliable mid-realign. On a touch device without matchMedia this could re-admit the original mobile yank during a realign burst. matchMedia is universally supported today, so this is a negligible edge, worth at most a one-line comment noting the fallback is best-effort.

Net: the CORE desktop breaks are resolved and the fix is correctly scoped to touch viewports. Recommend a re-gate.

…best-effort (nesquena#5637)

Address the two non-blocking observations from re-review of the touch-gate fix:

1. Add a direct unit test for _isTouchLikeMessageViewport itself — the guard-wiring
   tests stub the predicate via a boolean, which validates that the _touchHold term
   gates the refusal but not the predicate's own mid-realign stability (the claim that
   motivated choosing matchMedia over the computed overflow-anchor probe). The new
   _predicate_harness exercises the real predicate + _browserOverflowAnchorActive with
   mocked matchMedia/getComputedStyle:
   - test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none: on a
     pointer:coarse device whose inline overflowAnchor was clobbered to 'none' by a
     prior realign tick, the predicate must still report touch=true. Mutation-checked:
     reverting the predicate to the bare computed probe makes exactly this test fail.
   - test_predicate_false_on_desktop_fine_pointer / _falls_back_to_computed_probe_without_matchmedia
     cover the desktop and no-matchMedia paths.

2. Note in the predicate comment that the no-matchMedia fallback to the computed probe
   is best-effort (matchMedia('(pointer:coarse)') is universally supported in every
   targeted browser, so the primary path is what runs).
@allenliang2022

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review — I've addressed both non-blocking observations in 5a5f5448.

1. Predicate mid-realign stability now has direct coverage. You're right that the touch_like boolean stub only proves the guard wiring, not the predicate's own stability — which is the whole reason for choosing matchMedia over the computed probe. Added a _predicate_harness that exercises the real _isTouchLikeMessageViewport + _browserOverflowAnchorActive with mocked matchMedia/getComputedStyle:

  • test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none — a pointer:coarse element whose inline overflowAnchor was clobbered to 'none' by a prior realign tick; the predicate must still report true. Mutation-checked: reverting the predicate to the bare _browserOverflowAnchorActive(el) makes exactly this test fail (the computed probe reads the inline 'none' and misclassifies the touch device as desktop), while the guard-wiring tests stay green — so this test is what locks the matchMedia choice in.
  • test_predicate_false_on_desktop_fine_pointer and test_predicate_falls_back_to_computed_probe_without_matchmedia cover the desktop and no-matchMedia paths.

2. Fallback best-effort note. Added a comment on the no-matchMedia fallback documenting that the computed probe can transiently read 'none' mid-realign, so it's best-effort — and that matchMedia('(pointer:coarse)') is universally supported in every targeted browser, so the primary path is what actually runs.

Full file is 13 tests now, all green; the desktop-regression and predicate-stability cases are both mutation-checked.

@nesquena-hermes nesquena-hermes added size:L Large PR (>10 files or >250 LOC) and removed changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Jul 6, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — RED ⛔ (round-1 desktop regression fixed, but the touch predicate conflates Android with iOS — on iOS overflow-anchor is INERT so refusing the restore leaves nothing to hold the reader — 1 CORE, iOS)

Certified head: sha:5a5f5448 (rebased onto current master, git apply clean) · PR: #5666 · allenliang2022 (NEW, T1), fix(webui): refuse stale-anchor scroll restore during streaming (#5637)
Verdict: Round-2 fixed my round-1 desktop regression correctly (both guards now require _isTouchLikeMessageViewport, so desktop is byte-identical to pre-round-1 — Codex SAFE, verified by a desktop regression test + the mid-realign predicate-stability test). BUT Fable found a real cross-platform gap Codex missed: the touch predicate is matchMedia('(pointer:coarse)'), which is TRUE on both Android AND iOS — but the repo's own CSS comment (static/style.css:5838) says overflow-anchor is inert on iOS WebKit. So on iOS the guard refuses the restore with nothing to hold the reader (the same class as the round-1 desktop bug, now on iOS).

What I ran (rebased worktree /tmp/wt-rebase-5666b) — full gate: Codex + Fable + suite

Gate Result
Rebase onto current master git apply clean
Codex (both platforms) SAFE TO SHIP — 0 findings (verified desktop + Android; did not flag the iOS overflow-anchor-inert nuance)
Fable-UX (cross-device) SHIP-WITH-UX-FIXES — 1 real iOS gap + a merge-blocking real-device recording requirement
Full pytest suite 12209 passed, 0 failed (+ 238 scroll/anchor/pin, incl. new test_realign_allows_on_desktop_no_native_anchor)

Findings

✅ Round-1 desktop regression FIXED: both stale-anchor refusals now require _isTouchLikeMessageViewport (matchMedia('(pointer:coarse)')), which is false on hover+fine-pointer desktop → desktop keeps the explicit semantic realign + absolute restore (byte-identical to pre-round-1). Codex + Fable both confirm; the mid-realign predicate-stability test proves the transient inline overflow-anchor:none (round-1's classification trap) can't clobber the matchMedia probe. Android Chrome (pointer:coarse + working overflow-anchor): refusal + native anchor holds — matches the issue's real-device telemetry. Sound.
⛔ CORE (Fable, iOS gap) — the refusal fires on iOS where overflow-anchor is inert: pointer:coarse is true on iOS/iPadOS, so the guard refuses the restore there — but the refusal's own premise ("let the browser overflow-anchor hold") is FALSE on iOS WebKit per the repo's own static/style.css:5838 comment. On iOS the semantic realign IS the compensation for above-viewport height changes; refusing it leaves nothing holding a still, scrolled-up reader (narrow: needs above-viewport growth — history remeasure / image load above viewport — while scrolled up; the >8px deadband + no-intent requirement limit it further). The node harness structurally CANNOT test this ("native anchor holds" is an assumed postcondition it mocks, never observed). Fix: don't treat "touch" as one platform — the refusal is only safe where overflow-anchor actually holds (Android), NOT iOS WebKit; either detect iOS/WebKit and keep the semantic realign there, or verify on a real iOS device that refusing is acceptable. AND: this needs a real iOS-Safari recording before merge (structurally untestable in the suite).

Recommendation to the next agent / author

RED — gate-fail/changes-requested (1 CORE, iOS): the touch predicate conflates Android (overflow-anchor works → refusal safe) with iOS WebKit (overflow-anchor inert per style.css:5838 → refusing leaves the reader unheld). Split the platform check so the stale-anchor refusal fires only where native overflow-anchor actually holds the viewport (Android), and keep the semantic realign on iOS; OR prove on a real iOS device that the deadband+no-intent guard makes refusal acceptable there. The desktop fix is correct and the Android path is sound — this is the third platform (iOS) needing the same "does native anchoring actually hold HERE" gate. concept 4/5 (converging; the "mobile is not one platform" nuance is the last mile). Author @allenliang2022 (new, T1). crit=3. (Gate value: Codex SAFE'd desktop+Android; Fable caught the iOS gap by knowing the repo's own CSS comment that overflow-anchor is inert on iOS WebKit — exactly the kind of platform-specific browser-behavior knowledge the advisor leg brings. This is also 3rd #5637-family fix — land-order with #5635/#5638.)


_Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Round-1 desktop regression fixed (both guards require isTouchLikeMessageViewport; desktop byte-identical, Codex SAFE + desktop regression test). BUT Fable: matchMedia('(pointer:coarse)') is true on iOS too, and overflow-anchor is INERT on iOS WebKit (repo's own style.css:5838) → refusing the restore on iOS leaves nothing to hold a scrolled-up reader (CORE, narrow). Fix: split Android (anchor works, refuse) vs iOS (anchor inert, keep realign); real-iOS recording needed (node harness can't test the assumed "native anchor holds"). Suite green 12209 + 238 scroll. Cert valid for sha:5a5f5448.

…nchor is inert there (nesquena#5637)

Round-2 gate cert (iOS CORE): the touch predicate _isTouchLikeMessageViewport
used matchMedia('(pointer:coarse)'), which is true on BOTH Android and iOS. But
overflow-anchor is inert on iOS WebKit (the repo's own static/style.css mobile
content-visibility block documents this — it deliberately does not set
overflow-anchor:none because it is a no-op on iOS and re-opens the nesquena#4856/nesquena#5338
jump on Android). So on iOS the stale-anchor refusal fired but its premise (let
the native overflow-anchor layer hold the viewport) is false → a scrolled-up iOS
reader was left unheld after above-viewport growth, the same class as the round-1
desktop regression, one platform over.

Split the platform check: add _isIOSWebKit() (classic iPhone/iPod/iPad UA, plus
iPadOS 13+ which masquerades as MacIntel but has maxTouchPoints>1 unlike a real
Mac) and exclude it from _isTouchLikeMessageViewport. The refusal now fires ONLY
on Android (pointer:coarse AND overflow-anchor actually works); desktop and iOS
both keep the explicit semantic realign / absolute snapshot.top restore.

Tests: 3 new predicate cases (iPhone, iPadOS-as-Mac, Android control), all
mutation-checked — removing the _isIOSWebKit exclusion fails exactly the iOS
cases while Android/desktop stay green; broadening _isIOSWebKit to any touch
device fails the Android control. The Node harness forces the navigator mock via
Object.defineProperty because Node 18+ ships a built-in read-only navigator that
a plain assignment silently ignores.

Note: iOS Safari cannot be exercised by the Node harness (the 'native anchor
holds' postcondition is mocked, never observed), so this needs a real iOS-Safari
recording before merge per the gate cert.
@allenliang2022

Copy link
Copy Markdown
Contributor Author

Thanks — fixed the iOS gap in c1bfdbfe. Good catch by Fable on the style.css inert-on-iOS comment; that's exactly right.

The fix

Split the platform check instead of treating "touch" as one platform. Added _isIOSWebKit() and excluded it from _isTouchLikeMessageViewport, so the stale-anchor refusal now fires only where native overflow-anchor actually holds the viewport:

  • Desktop (hover+fine-pointer): overflow-anchor:none → excluded via matchMedia('(pointer:coarse)') being false (round-1 fix, unchanged).
  • iOS WebKit: overflow-anchor inert per the repo's own static/style.css comment, even though it computes to auto → now excluded via _isIOSWebKit(). iOS keeps the explicit semantic realign / absolute snapshot.top restore (the realign IS the compensation there), same path as desktop.
  • Android (pointer:coarse AND working overflow-anchor): the one platform where the refusal is safe — native anchoring holds. Unchanged.

_isIOSWebKit() covers classic iPhone/iPod/iPad UAs plus iPadOS 13+, which reports a desktop MacIntel platform but is distinguishable by maxTouchPoints>1 (a real Mac has 0).

Tests

3 new predicate cases, all mutation-checked:

  • test_predicate_false_on_ios_iphone_despite_pointer_coarse — removing the _isIOSWebKit() exclusion fails exactly this (iPhone misclassified as hold-capable).
  • test_predicate_false_on_ipados13_masquerading_as_mac — dropping the MacIntel && maxTouchPoints>1 branch fails this.
  • test_predicate_true_on_android_not_ios — broadening _isIOSWebKit() to any touch device fails this (Android must stay in-scope, or the mobile jump re-opens).

(The Node harness forces the navigator mock via Object.defineProperty because Node 18+ ships a built-in read-only navigator that a plain assignment silently ignores.)

On the real-iOS recording

You're right that the suite can't observe the "native anchor holds" postcondition — it's mocked, never exercised. The predicate logic is now unit-covered, but I can't provide the real iOS-Safari recording myself: I only have an Android device to reproduce on. So this last leg is genuinely unverified on-device for iOS from my side — if you or anyone with an iOS device can confirm a scrolled-up reader stays put on iOS during above-viewport growth, that would close it. The fix is conservative (iOS now behaves exactly like desktop, which is the known-good pre-round-1 path), so the risk is bounded to "iOS keeps the prior behavior" rather than a new iOS-specific code path.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — GREEN ✅ · CONVERGED (round 3 — complete platform matrix: desktop + Android + iOS all correct) · ⏸️ visible scroll → Nathan sign-off

Certified head: sha:57612d05 (clean rebase, branch gate-rebase/5666-stale-anchor-ios-excluded) · PR: #5666 · allenliang2022 (NEW, T1), fix(webui): refuse stale-anchor scroll restore during streaming (#5637)
Verdict: Round-3 CONVERGED. The 3-round cross-platform arc is closed — r1 broke desktop (fixed: guard requires touch), r2 broke iOS (fixed r3: iOS excluded because overflow-anchor is inert on iOS WebKit). All three platforms now correctly hold a scrolled-up reader during streaming. Codex SAFE, Fable-UX SHIP-UX, full suite green.

What I ran (rebased worktree /tmp/wt-rebase-5666c) — full gate: Codex + Fable + suite

Gate Result
Rebase onto current master git apply clean
Codex (complete platform matrix) SAFE TO SHIP — 0 findings
Fable-UX SHIP-UX — all 3 platforms hold the reader during streaming
Full pytest suite 12212 passed, 0 failed (+ 241 scroll/anchor/pin, incl. new iOS-exclusion tests)

Findings — complete platform matrix, all 3 rounds closed

✅ The refusal now fires ONLY where native overflow-anchor actually holds the viewport:

Codex confirmed _isIOSWebKit detection is robust (correctly identifies iOS Safari/iPadOS/iOS-webview, no false-positive on desktop Safari or Android) and no scroll-pin regression (#3250/#4295/#4856/#1731/#3470 all green). Fable confirmed all three platforms hold the reader during streaming. Full suite green.

Recommendation to the next agent / Nathan

GREEN — merge from branch gate-rebase/5666-stale-anchor-ios-excluded (sha:57612d05), NOT the PR's stale head c1bfdbfe — after a quick screen-recording sign-off (desktop + Android + iOS scroll-up-during-stream hold). A properly-converged cross-platform scroll-stability fix that took 3 rounds to get right on all platforms (the browser-native overflow-anchor availability differs by platform). Codex SAFE + Fable SHIP-UX + full suite green. Land-order: 3rd #5637-family fix (with #5635/#5638) — reconcile land order (they touch overlapping scroll-restore paths). concept 4/5. Author @allenliang2022 (new, T1). crit=3.


_Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Complete platform matrix: refusal fires only where overflow-anchor holds — desktop (inert, realign kept, r1), Android (works, refuse+anchor holds, #5637), iOS WebKit (INERT, excluded via isIOSWebKit, realign kept, r2). Codex SAFE (robust iOS detection, no pin regression) + Fable-UX SHIP-UX + full suite green (0 failed) + 241 scroll tests. 3-round cross-platform convergence. Land-order w/ #5635/#5638. Visible → Nathan. Cert valid for sha:57612d05.

@nesquena-hermes nesquena-hermes added gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent and removed gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push labels Jul 6, 2026
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in c93672a Jul 6, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.904. Thanks @allenliang2022 — on Android, a scrolled-up reader no longer drifts backward each streaming tick; the stale viewport-anchor restore is refused during streaming so native scroll-anchoring holds, carefully scoped to Android only (iOS overflow-anchor is inert; desktop uses overflow-anchor:none). Verified coexisting with #5685+#5681, full gate green, live-served.

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

Labels

gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants