Skip to content

fix(scroll): ignore browser tail jitter that silently unpinned readers - #7268

Open
ruizanthony wants to merge 11 commits into
nesquena:masterfrom
ruizanthony:fix/scroll-tail-jitter-unpin
Open

ruizanthony wants to merge 11 commits into
nesquena:masterfrom
ruizanthony:fix/scroll-tail-jitter-unpin

Conversation

@ruizanthony

@ruizanthony ruizanthony commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Thinking Path

  • Long transcripts are initially placed at the true tail with auto-follow enabled.
  • Chromium can then settle layout by a few pixels without any application scroll write.
  • The existing direction check interpreted that browser-only movement as reader intent and latched the transcript unpinned.
  • The safe fix is narrow: suppress only tiny upward movement while still at the tail and only when no genuine input owns the movement.
  • Wheel, touch, keyboard, gutter/overlay scrollbar, and non-message scroll intent must continue to unpin.
  • Scrollbar intent must also survive asynchronous scroll delivery without leaking into a later render nudge after the reader returns to the true bottom.

What Changed

  • Added a bounded tail-jitter classifier for pinned readers within 16 px of the true bottom and a maximum 16 px upward delta.
  • Preserved real input ownership across deferred rAF classification, including pointerup → scroll → rAF ordering and overlay scrollbar hits.
  • Added lifecycle cleanup for session/stream resets, blur, and hidden-document aborts.
  • On scrollbar release at the true bottom, clear both queued and timestamped intent; a genuinely late upward movement above the tail retains ownership and still unpins.
  • Replaced/extended source-only coverage with production-composed Node listener regressions, including the reviewed drag-up → back-to-tail → release → render-nudge schedule and its late-upward counterpart.
  • Documented the streaming-reader interaction contract in TESTING.md.

Release-note wording: Prevent small browser layout shifts at the bottom of long conversations from silently disabling auto-follow, while preserving deliberate wheel, touch, keyboard, and scrollbar scrolling.

Why It Matters

Without this guard, opening a long conversation can leave the reader in the middle of the transcript even though they never scrolled. The fix keeps browser layout settle from stealing scroll ownership while preserving explicit reader intent and the sticky-unpin model.

Contract Routing

Task type: transcript scroll/pinning runtime bug fix.

Touched areas:

  • static/ui.js scroll ownership and pin state
  • behavioral scroll-listener tests
  • TESTING.md interaction guidance
  • responsive UI evidence

Relevant public docs:

  • AGENTS.md
  • CONTRIBUTING.md
  • docs/CONTRACTS.md
  • docs/GUIDELINES.md
  • docs/UIUX-GUIDE.md
  • DESIGN.md
  • TESTING.md

Scope boundaries: transcript tail-follow ownership only; no layout, API, persistence, dependency, or build-system change. This restores the existing follow/unpin contract rather than intentionally redefining it, so no Contract Change section is required.

Evidence needed before claiming done: observable listener-state regressions, adjacent scroll suites, JavaScript syntax/runtime lint, diff-scoped Python lint, clean rebase, and desktop/narrow/mobile evidence.

Verification

Rebased without conflicts onto master c296673ebfaf98750fe38438bc71f0cbb1f75777; git range-diff reports all nine commits patch-identical and the aggregate stable patch ID is unchanged.

Local verification on exact head 96fc55ba3dd4b05f850f4efb2fe47fafef291b38:

  • ./scripts/test.sh tests/test_tail_jitter_unpins_pinned_reader.py tests/test_issue4702_portrait_open_scroll_bottom.py — 33 passed.
  • Adjacent scroll/pin sweep through ./scripts/test.sh — 176 passed across issue 1731/3250/3319/3470/4295/4346/4793/4856/6414, TARS reset, jump-to-answer settle, pinned-tail jitter, and collapse/clamp follow suites.
  • Runtime ESLint, scope-undefined, and forward-ruff tests through ./scripts/test.sh — 6 passed.
  • node --check static/ui.js — clean (Node v24.16.0).
  • scripts/ruff_lint.py --diff github-upstream/master — 0 findings in changed Python files and 0 on changed lines.
  • git diff --check github-upstream/master...HEAD — clean.

Responsive UI evidence

The captures exercise the real application page in Chromium with isolated state and a deterministic 8 px browser tail shift. The diagnostic card is capture-only so the otherwise invisible pin-state transition is readable.

Before — base behavior

Before: desktop, narrow, and mobile lose auto-follow after an 8 px browser tail shift

After — fixed behavior

After: desktop, narrow, and mobile preserve auto-follow after an 8 px browser tail shift

Verified viewports and interactions:

  • desktop: 1280×720, Chromium wheel input;
  • narrow: 768×720, Chromium wheel input;
  • mobile: 390×844 with touch/mobile emulation and a browser-level CDP touch gesture.

On the base revision, the same 8 px no-input movement produced _scrollPinned=false and _messageUserUnpinned=true at all three widths. On the fixed revision, all three remain pinned (true / false). Genuine wheel and touch input still transfers ownership at every relevant width (false / true). No provider credentials or live state were used.

Risks / Follow-ups

  • The 16 px bounds are deliberately narrow and calibrated above the observed 8 px Chromium settle. A larger engine- or zoom-specific shift could still unpin and may require later tuning if reported.
  • The async scrollbar schedules are deterministic production-composed listener tests. Real-browser evidence covers the no-input shift plus wheel/touch ownership, but not every native scrollbar event ordering on every operating system.
  • No known blocker remains.

Model Used

OpenAI Codex gpt-5.6-sol through Hermes Agent for the final rebase, review, and verification, using Git, Node, Python, and GitHub CLI tooling.

@greptile-apps

greptile-apps Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[Medium risk] Fixes scroll-pinning behavior when browser layout shifts occur.

The PR does not appear safe to merge until ordinary presses in the wide transcript’s empty margin stop claiming scrollbar ownership.

Findings

  1. P1 Empty margin claims scrollbar intent ▶
Summary

The PR keeps auto-follow pinned through small browser-only tail shifts and adds scrollbar-intent handling, behavioral regressions, documentation, and responsive before/after evidence.

  • The scrollbar edge-band check can also claim an ordinary press on the empty margin of a wide transcript.
  • The previous Greptile threads are resolved; no previous finding is reposted.

Reviews (11) · Last reviewed commit: "fix(scroll): drop queued drag intent at ..."

Comment thread tests/test_tail_jitter_unpins_pinned_reader.py Outdated
@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from dca28f0 to b1d2bd1 Compare August 24, 2026 14:01
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Fixed the CI failures from the first push

The initial revision wrote the guard as an inlined IIFE inside the messages
scroll listener
. That broke 4 pre-existing tests on every shard:

tests/test_tars_scroll_reset_regressions.py::test_user_scroll_cancels_delayed_bottom_settling
tests/test_jump_to_answer_scroll_settle.py::test_response_jump_owns_native_smooth_scroll_until_final_reconciliation[3 params]

Root cause. Several harnesses slice the listener with
UI_JS[start:UI_JS.index("})();", start)]. The inlined IIFE introduces an
earlier })();, so the slice was silently truncated: string assertions about
the tail of the listener stopped finding their target, and the Node extraction
harness raised SyntaxError: Unexpected end of input on the truncated block.
The failure was real and caused by this PR — not flakiness.

Fix. The guard now lives at module scope as
_isMessageTailJitter(top, bottomDistance) and the listener just calls it.
Behaviour is byte-for-byte equivalent: same thresholds, same intent detectors,
same short-circuit order.

A regression test was added so this cannot come back:
test_tail_jitter_helper_is_defined_outside_the_scroll_listener asserts the
helper is not inside the listener and that the sliced block still reaches
its tail (_cancelBottomSettle();).

Re-verified after the refactor

  • 74 tests pass locally, including all 4 previously failing ones and the
    neighbouring scroll/pin suites.
  • scripts/ruff_lint.py --diff origin/master: no new violations.
  • Real Chromium, 3 long sessions × 3 opens: 9/9 pinned at the tail
    (bottomDistance=0, unpinned=False), matching the pre-refactor result.

@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from b1d2bd1 to 638a42e Compare August 24, 2026 14:18
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Second CI failure fixed — root cause was mine again

Moving the guard to module scope fixed the brace-slicing harnesses, but broke a
different one: tests/test_issue4295_scroll_pin_reentry.py executes the scroll
listener body inside Node through new Function(...) with a fixed list of
injected identifiers
. _isMessageTailJitter is not in that list, so the bare
call raised a ReferenceError and the harness exited non-zero — on every shard.

Shard 1 passed on all three Python versions simply because that shard does not
contain the affected test file; the uniform failure elsewhere was one real bug,
not flakiness.

Fix — apply the same pattern the surrounding code already uses for exactly
this reason (see the #4970 / #4295 comments in that listener):

const _tailJitter=typeof _isMessageTailJitter==='function'
  &&_isMessageTailJitter(top,bottomDistance);

Production behaviour is unchanged — the helper is always defined there. In the
injected-body harness the guard short-circuits to false, which is the correct
inert value: no jitter suppression, pre-existing semantics preserved exactly.

Verified on a clean origin/master base

  • test_issue4295_scroll_pin_reentry + test_tail_jitter_unpins_pinned_reader
    • test_tars_scroll_reset_regressions + test_jump_to_answer_scroll_settle
    • issue4702 + issue1731 + issue3319 + issue3470 + issue3250
    • pinned_tail_midstream_jitter: 77 passed
  • scripts/ruff_lint.py --diff origin/master: no new violations
  • node --check static/ui.js: OK — git diff --check: clean

Apologies for the two rounds of CI noise: both failures were caused by this PR
and both are now covered by assertions in the new test file.

@ruizanthony

Copy link
Copy Markdown
Contributor Author

Addressed in 6dfacc497d1b without changing the UI logic. The two regressions now execute the real scroll listener through Node: portrait client-height growth must keep the reader pinned at the true bottom, and transient tail jitter during streaming must re-snap without oscillation or unpinning. Targeted/adjacent scroll tests and the complete GitHub matrix are green.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head ea6adabc08f93315eb0ecd47e7a4d2fe7c16f002: the new tail-jitter guard can still swallow a real scrollbar drag.

Reproduced code schedule

  1. A scrollbar pointerdown sets _scrollbarDragActive = true in static/ui.js.
  2. The resulting native scroll event does not classify the movement immediately. It schedules the state transition in requestAnimationFrame().
  3. If the reader releases the scrollbar before that frame runs, the pointerup handler clears _scrollbarDragActive immediately.
  4. The deferred frame then calls _isMessageTailJitter(), which consults the live flag. For a real 3–16 px upward drag that still ends within 16 px of the tail, the flag is now false, _tailJitter becomes true, and movedUp becomes false. The reader stays pinned despite explicit input intent.

This contradicts the PR's contract that scrollbar intent always bypasses jitter suppression. The submitted scrollbar parameter case is false-green for this ordering because it injects _scrollbarDragActive = true at frame execution time; it never models pointerdown → scroll queues frame → pointerup → frame flush.

Required fix

Preserve scrollbar intent across the deferred classification boundary. For example, latch an owner/generation or a bounded scrollbar-intent timestamp when the drag begins (or when the scroll callback queues the frame), consume that snapshot in the frame, and reset it on the existing session/stream ownership resets. Do not rely only on the live _scrollbarDragActive flag after pointerup can clear it.

Add a production-composed controllable-rAF regression with this exact sequence:

  1. pointer down on the scrollbar,
  2. move upward by a small amount within the new geometric thresholds,
  3. dispatch scroll to queue the frame,
  4. dispatch pointerup before flushing the frame,
  5. flush the frame and assert _messageUserUnpinned === true and _scrollPinned === false.

The mandatory sandbox test gate did not execute test bodies in this pass because its Layer-1 GitHub diff fetch hit the account's REST rate limit and failed closed. This request is based on the deterministic event-order/code trace above, not on a claimed green or red test run. No PR code was executed outside the gate.

@Manny7717 Manny7717 left a comment

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.

Verified locally with the repo's Node scroll-listener runtime harness.

Bug is real. The transcript opens pinned at the tail; a browser layout-settle artifact (measured 8px on desktop Chromium, no JS scroll write — the PR's instrumentation evidence is solid) shifts scrollTop up. The listener's top<_lastScrollTop-2 direction test reads that as an upward user scroll and latches _messageUserUnpinned=true; auto-follow stays off for the session and later renders restore the semantic anchor → reader stranded mid-transcript.

Fix is correct.

  • _isMessageTailJitter(top, bottomDistance) gates movedUp: it only fires for a 1-16px upward delta while still ≤16px from the true bottom, and every real-intent signal bypasses it — scrollbar drag (_scrollbarDragActive), recent wheel/touch/key message-pane intent, and recent non-message scroll. A deliberate scroll-away keeps unpinning through the existing branches.
  • The guard sits BEFORE the #4970 post-render artifact window, so it also covers settles that arrive outside the 1400ms artifact window (the reported open-time case) without disturbing that mechanism — the movedUp suppression logic below is untouched.
  • At the tail (≤16px) every branch is nearBottom (250px band), so jitter frames fall through to the re-pin counter path — net effect is "stay pinned", exactly the intent. The _lastScrollTop===null first-event guard keeps initial placement handling intact.
  • Scope is tight: 2 commits (fix + runtime tests). The #4702 test was UPGRADED from a source-string assertion to a real runtime harness run (same guard, now executed) — an improvement, not a regression-weakening.

Regression proven: new test file applied to base (e168b67) → all 6 new runtime tests FAIL (pin stability + all 5 parametrized genuine-input bypass cases); on head 14/14 pass (test_tail_jitter_unpins_pinned_reader + upgraded test_issue4702 + test_issue4295 neighbor). node --check clean; repo ruff gate: 0 new violations.

Non-blocking notes:

  1. The 16px thresholds are calibrated to the measured 8px Chromium settle. Other engines, zoom levels, or a taller settle (e.g. font-load reflow >16px) could still falsely unpin; consider scaling the window by devicePixelRatio or the measured max settle if reports recur — a tuning follow-up, not a blocker.
  2. Any layout artifact >16px or occurring >16px from the bottom remains unpinning (documented bound of the heuristic) — acceptable, since "still visually at the bottom" is the only safe case to swallow.

@ruizanthony

Copy link
Copy Markdown
Contributor Author

Addressed in 7b0b3f22cdfb.

The scroll listener now latches scrollbar-drag intent when the native scroll event queues its deferred frame, consumes that snapshot inside the rAF callback, and clears it on the existing scroll/session reset paths. The new production-composed controllable-rAF regression executes the exact pointerdown → scroll → pointerup → flush rAF schedule and verifies that the reader becomes unpinned; reset-path coverage verifies the latch cannot leak across ownership changes.

Validated locally with the targeted runtime suite and 294 adjacent scroll/pin tests, plus node --check, diff-scoped lint, and git diff --check. The remote head and two-file follow-up commit were independently verified; GitHub CI is running on this exact SHA.

@nesquena-hermes nesquena-hermes added size:L Large PR (>10 files or >250 LOC) and removed size:M Medium PR (≤10 files, ≤250 LOC) labels Sep 4, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-gate at exact head 7b0b3f22 (rebased clean onto current master) — the latch narrows the drag-swallow hole but doesn't fully close it, plus an overlay-scrollbar gap

Thanks @ruizanthony — the tail-jitter guard is well-reasoned and the _scrollbarDragIntentQueued latch is the right direction. Confirmed working: the 16px guard is correctly bypassed by wheel / touch / keyboard intent (one-notch wheel and ArrowUp both unpin), the guards compose cleanly with the #4702 iOS grew guard and the post-render artifact suppression, and both reset paths clear the flags with no cross-session/new-stream leak. ESLint clean, 90 focused scroll tests pass, full suite 15,073 passed.

But the gate reproduced two SILENT scroll-ownership defects — both let a real scrollbar drag get misclassified as jitter and snap the reader back to the tail.

1. static/ui.js:6411 — the latch misses the pointerup → async scroll → rAF ordering

The latch at 6411 only sets _scrollbarDragIntentQueued=true when the synchronous scroll event observes _scrollbarDragActive === true. But pointerup clears _scrollbarDragActive at line 6353, and the scroll event is explicitly asynchronous (UI Events spec — "scroll ... MUST be dispatched ... asynchronously"). So for a quick 3–16px thumb drag the browser can deliver events in this order:

pointerdown (sets _scrollbarDragActive=true)
  → scrollTop changes
  → pointerup (sets _scrollbarDragActive=false, line 6353)
  → scroll  ← latch check at 6411 sees false, never latches
  → rAF     ← dragIntent=false → _isMessageTailJitter treats it as jitter → swallowed

I confirmed the structure: the latch is populated only from the sync scroll handler reading a flag that pointerup may already have cleared. A production-listener probe with exactly that ordering unpins on origin/master but stays pinned on this branch.

Fix: carry a bounded scrollbar-drag intent that survives from pointerdown/pointerup through the first subsequent scroll classification, rather than a boolean read live during the sync scroll. You already have the right primitive — pointerdown increments _messageScrollInputGeneration (line 6347). Stamp a _scrollbarDragIntentUntil = performance.now() + <smallWindow> (or capture the generation) on pointerdown/pointerup, and in the rAF treat the drag as intended if that stamp is still fresh, then clear it. Add a regression test with the explicit pointerdown → scrollTop change → pointerup → scroll → rAF ordering (the current tests don't exercise scroll-after-pointerup).

2. static/ui.js:6345 — overlay scrollbars aren't recognized as drag targets

Drag ownership is claimed only when e.offsetX >= el.clientWidth, which is true for a gutter scrollbar (outside the client box). But an overlay scrollbar sits inside the client box — Firefox on macOS keeps an overlay even with scrollbar-width:thin (Mozilla bug 1568939) — so a thin-overlay thumb drag never sets _scrollbarDragActive, and its small movement is swallowed as jitter. The added test hard-codes offsetX === clientWidth, which hides the path; an otherwise-identical offsetX === clientWidth - 1 probe unpins on master but stays pinned here.

Fix: replace the gutter-only offsetX >= clientWidth with overlay-safe right-edge/owner hit-testing (e.g. compare clientX against getBoundingClientRect().right within a small band, and confirm e.target === el), keep that intent through classification, and add a regression case with an inside-client-box (offsetX < clientWidth) scrollbar hit.

Both are the same failure the original bounce was about — a genuine scrollbar drag being mistaken for jitter — just reached through the async event ordering and the overlay-scrollbar geometry the current guard doesn't cover. Everything else is verified clean; two contained changes from shippable. Ping me when pushed and I'll re-gate at the new head.

@nesquena-hermes nesquena-hermes added the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Sep 8, 2026
@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from 7b0b3f2 to 75e2a68 Compare September 18, 2026 00:45
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Pushed 75e2a68cd94a09bbd2cabf5d6412355523504617 (rebased onto origin/master 2cf8e8a5e, lease-verified), addressing both re-gate findings:

1. Async pointerup → scroll → rAF ordering (ui.js scroll latch): the drag intent is now a bounded, time-stamped stamp — _scrollbarDragIntentUntil = performance.now() + 250ms set on pointerdown AND re-stamped on pointerup/pointercancel. The FIRST scroll event after the stamp consumes it (folded into the _scrollbarDragIntentQueued latch the classification rAF reads), whatever branch that event takes, so it never leaks into a later frame; an unconsumed stamp simply expires. Regression added driving the real extracted listeners through the explicit pointerdown → scrollTop change → pointerup → scroll → rAF ordering — it unpins now and failed on the previous head.

2. Overlay scrollbars (offsetX < clientWidth): pointerdown now claims drag ownership for gutter hits (offsetX >= clientWidth - band) OR overlay right-edge hits (clientX within the band of getBoundingClientRect().right), still requiring e.target === el so bubbled transcript presses never claim it. Regression added with offsetX === clientWidth - 1 — it unpins now and failed on the previous head. The original offsetX === clientWidth case is kept and still passes.

Guard not weakened: a press well inside the client box (offsetX: 400), a bubbled child press, a stale stamp (>250ms) and a second scroll after the drag's own all stay classified as tail jitter; wheel/touch/keyboard bypasses untouched and still covered.

Tests: tests/test_tail_jitter_unpins_pinned_reader.py (14 passed; the two new regressions fail on the previous head), focused scroll suites test_issue4702_*, test_issue6414_*, test_issue4295_*, test_issue4346_*, test_issue4793_*, test_issue4856_* and the other scroll files → 409 passed, 0 failed. node --check, ESLint runtime guard, scope_undef_gate, ruff_lint (diff), compileall, git diff --check: clean.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this forward — the tail-jitter diagnosis is right, and the rebase onto current master was clean (I did it myself; 104 commits behind, no conflicts, and your three-dot diff came out byte-identical, so nothing of yours was lost).

I took the rebased state through the full authoritative gate (regression review + mutation testing of your tests + the full suite). The core direction holds: ignoring browser tail jitter is the correct fix, and the deferred-rAF drag-intent latch you added in round 2 genuinely closes the original "small drag gets swallowed" hole. Two blockers remain, and one of them re-creates the exact bug class this PR exists to fix.

Blocker 1 (CORE) — release unconditionally re-arms drag intent, which can unpin a re-pinned reader

static/ui.js:6437 (and the pointercancel twin at :6447):

window.addEventListener('pointerup',()=>{
  if(!_scrollbarDragActive) return;
  _scrollbarDragActive=false;
  // `scroll` is async: the drag's own scroll event may only be dispatched
  // AFTER this release. Re-stamp so that first classification still owns it.
  if(typeof _markScrollbarDragIntent==='function') _markScrollbarDragIntent();
  _scheduleMessageVirtualizedRender(true);
},{passive:true});

_markScrollbarDragIntent() (ui.js:6073) sets _scrollbarDragIntentUntil = performance.now() + 250 with no condition. The re-stamp is correct when the drag's scroll event is still undelivered — that's the case you were fixing. But it also fires when the drag's scroll was already dispatched and classified. In that case the release opens a fresh 250 ms window in which the next scroll consumes the stale intent, and a render-generated scroll is enough to satisfy it. That bypasses both the new tail-jitter classification and the existing post-render suppression at ui.js:6546, flipping a reader who is legitimately re-pinned to the tail into _scrollPinned=false; _messageUserUnpinned=false→true.

Reproduced against the extracted production listeners: origin/master stays pinned, and the branch stays pinned when the stale intent is removed — only the current head unpins. So this is a regression introduced by the re-stamp, not a pre-existing master behavior.

Fix: record the last scroll position observed during the drag, and on pointerup/pointercancel re-stamp only when release detects a position change that has not yet been delivered to a classification pass. Otherwise clear the timestamp rather than extending it. That preserves the async-scroll case you're protecting while closing the stale-intent window.

Blocker 2 (SILENT) — two tests are false-green; one is weaker than the assertion it replaced

Both verified by mutation (I removed the production guard and the test still passed):

  1. tests/test_issue4702_portrait_open_scroll_bottom.py:46 — the modified assertion is weaker than the one it replaced. Deleting the !grew client-height growth guard from production still passes, because the scenario's zero bottom-distance geometry is independently protected by the bottomDistance>1 check. Use a small nonzero bottom distance (2 px) so that removing the growth guard actually reds the test.

  2. tests/test_tail_jitter_unpins_pinned_reader.py:252 — passes even when the pointerup re-stamp is removed entirely, so it does not pin the behavior it names. Advance the clock past the 250 ms intent window before pointerup.

For blocker 1, please also add a regression at tests/test_tail_jitter_unpins_pinned_reader.py:348 that returns to the tail before release and asserts a later render nudge leaves the reader pinned — that's the case that currently regresses.

For reference, the mutation results on the current head: disabling the jitter delta correctly reds its test (good — that one is real coverage), while removing the pointer-up stamp passes (false-green).

Happy to re-gate as soon as you push. The rebase is already done on my side, so you can branch from current master without redoing it.

@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from 75e2a68 to 79a48a7 Compare September 23, 2026 08:04
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Pushed 79a48a713686ee6164cc907f0374118e48e60990 — rebased onto current master (b1774b45a), lease-verified against 75e2a68cd. It addresses both blockers from the 2026-09-22 review.

Blocker 1: release re-armed stale drag intent. pointerdown now seeds _scrollbarDragObservedTop. Every scroll event delivered while the drag is active records its scrollTop there (inside _consumeScrollbarDragIntent(top), so the listener body doesn't grow). pointerup/pointercancel now go through _releaseScrollbarDragIntent(el.scrollTop):

  • It re-stamps the 250 ms window only if the live scrollTop differs from the last delivered one, meaning the drag's own scroll event is still pending.
  • Otherwise it clears _scrollbarDragIntentUntil instead of extending it.

Both ownership resets clear the new tracker. As before, the stamp is consumed by the first scroll only.

New runtime regressions drive the real extracted pointerdown/pointerup/pointercancel/scroll listeners with a controllable rAF and clock:

  • test_release_after_delivered_drag_scroll_does_not_rearm_intent[pointerup|pointercancel]: drag scroll delivered before release → no intent remains.
  • test_render_nudge_after_drag_back_to_tail_keeps_reader_pinned[pointerup|pointercancel]: the case you described. Drag up (unpins), drag back to the tail (re-pins), release, then an 8 px render nudge → stays _scrollPinned=true, _messageUserUnpinned=false.
  • test_scrollbar_click_without_movement_leaves_no_intent_for_render_nudge
  • test_pending_drag_scroll_after_release_owns_intent_then_render_nudge_cannot: drag scroll delivered after pointercancel consumes the re-armed intent and unpins. After the reader re-pins at the tail, a later nudge stays pinned.

RED on the previous behaviour: with the unconditional re-stamp restored, 7 tests fail (the 6 above + the reset-tracker assertions). GREEN on this head.

Blocker 2: false-green tests.

  1. test_issue4702_…viewport_growth: the settle frame now lands 2 px short of the bottom (bottomDistance=2), so bottomDistance>1 no longer covers it. Mutation check, deleting !grew from movedUp: the old test passes (false-green, confirmed), the new test fails.
  2. test_scrollbar_drag_intent_survives_pointerup_before_scroll_frame: the clock now advances 251 ms before pointerup, so the pointerdown stamp has expired and only the release re-stamp can carry intent. It also asserts the re-armed deadline and that the drag scroll consumed it. Mutation check, removing the release re-stamp: this test and test_pending_drag_scroll_after_release_… fail.

Validation (local, this head):

  • tests/test_tail_jitter_unpins_pinned_reader.py + test_issue4702_*: 25 passed.
  • All 71 test files that read static/ui.js and touch scroll/drag state: 1164 passed, 0 failed. This includes test_issue4856_*, test_scroll_collapse_clamp_keeps_follow, test_issue4346_* and test_issue4295_*.
  • node --check: clean. ESLint runtime guard: clean. scope_undef_gate: clean. ruff_lint.py --diff origin/master: no new violations. git diff --check: clean.

@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from 79a48a7 to 38c35b9 Compare September 23, 2026 22:42
Comment thread static/ui.js Outdated
Comment thread static/ui.js
Comment thread static/ui.js Outdated

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gate at abb568cdd798: both 09-22 blockers are fixed; one drag-intent path still unpins a reader back at the tail

Thanks @ruizanthony. Both blockers from my 09-22 review are resolved.

  • Release now re-arms drag intent only when the drag moved past the last delivered position, and otherwise clears it (_releaseScrollbarDragIntent, static/ui.js ~6133).
  • Both mutations from that review now turn red: removing the !grew growth guard, and the other guard.

Codex's sandboxed re-gate, rebuilt on current master 0c3550f4a (exp-v0.52.369), ran 31/31 focused tests green.

A process note: our pre-execution scanner rates this diff SUSPICIOUS because of the four eval(payload.*) lines in the node harness (tests/test_tail_jitter_unpins_pinned_reader.py ~202). I think that's the repo's extract-and-eval idiom and not hostile, but the gate is mechanical, so I didn't run the tests locally this round; Codex executed them in its own sandbox. If the harness can load the listeners with vm.runInContext on the sliced source, the next re-gate can run here too.

[CORE] A quick drag up and back to the tail leaves drag intent queued

static/ui.js ~6571 (scroll listener) together with ~6133 (release). Sequence: pointerdown → drag up → drag back to the true tail → pointerup → an 8px render shift before the rAF classification. The scroll listener has already set _scrollbarDragIntentQueued=true from the drag's scrolls. The release logic clears _scrollbarDragIntentUntil but not the queued flag. So the stale intent reaches the rAF pass, which treats the render nudge as a user drag, bypasses both jitter guards, and turns auto-follow off for a reader sitting at the tail.

Codex verified it with the extracted production listeners under a controlled event order: master stays pinned and this head unpins. The timing is simulated rather than a live browser capture, but it's the same bug class this PR exists to fix (a pinned reader silently unpinned).

Fix: when the live or last-delivered drag position is back at the true bottom, discard both pending signals (_scrollbarDragIntentQueued and _scrollbarDragIntentUntil) at release. A released drag that stays above the tail keeps its intent. Test: a listener regression for pointerdown → drag up → return to tail → pointerup → render nudge → rAF that asserts the reader stays pinned, plus the counterpart where a genuine late upward drag still unpins.

That's the only finding this round.

@ruizanthony

Copy link
Copy Markdown
Contributor Author

Addressed the 2026-09-24 re-gate finding in 90517be85fa1175d46cf9861c9f134e976ff073c (lease-verified push).

At scrollbar release, _releaseScrollbarDragIntent now uses the live release position (falling back to the last delivered position only when needed) and, when that position is at the true bottom, clears both _scrollbarDragIntentQueued and _scrollbarDragIntentUntil. A release above the tail preserves/re-arms drag ownership, so a genuinely late upward thumb movement still unpins.

The production-composed listener regression drives the exact pointerdown → drag up → drag back to true tail → pointerup → 8px render nudge → rAF schedule. It failed before the fix because queued intent survived release and now passes with the reader pinned. The counterpart with an undelivered final upward movement remains unpinned.

Verification on this exact SHA: 28 focused tests and 200 targeted/adjacent scroll tests passed; node --check, runtime ESLint, scope_undef_gate, diff-scoped ruff, and git diff --check are clean. The previously supplied desktop/narrow/mobile UI evidence remains applicable; no visual behavior or layout changed in this follow-up.

Opening a long conversation with auto-follow on landed the reader in the
middle of the transcript instead of at the tail.

Root cause is not the compaction card. On opening a long conversation the
reader is correctly placed AT the tail, then the browser nudges scrollTop
up by a few px (observed: 8) with NO scrollHeight/clientHeight change. A
CDP probe instrumenting scrollTop, scrollIntoView, scrollTo, scrollBy and
focus recorded zero JS writes for that move: it is a layout-settle
artifact of the freshly rebuilt transcript.

The scroll listener's direction test (`top < _lastScrollTop - 2`) read
that artifact as an upward user scroll and latched
_messageUserUnpinned = true on a reader who never touched anything.
Auto-follow stayed off for the whole session, and subsequent renders
restored the SEMANTIC viewport anchor instead of the tail — landing the
reader mid-conversation. The plan widget rides the same anchor path,
which is why it drifted too.

The compaction card is an aggravating factor, not the cause: it adds
height and rerenders that make the jitter more likely on long sessions.
Removing the compaction commits would have masked the symptom while
leaving the real bug in place.

Fix: treat a sub-scroll upward drift as jitter, not intent, but ONLY when
all of these hold — drift is <= 16px, the reader is still within 16px of
the bottom, and no wheel / touch / key / scrollbar intent was recorded.
Any real scroll-up carries one of those intents and keeps unpinning
exactly as before, so deliberate reading in history is untouched.

Evidence (headless Chromium, real sessions, 2190-message transcript):
  before: 8/8 opens ended unpinned, ~mid-transcript
  after: 12/12 opens ended pinned at the tail
Two of the 12 still show the 8px browser drift and stay correctly pinned,
which is the artifact being absorbed rather than hidden.

Tests: 636 ui.js static tests pass; the 2 failures in that set
(test_anchor_fallback_ownership, test_api_timeout) reproduce identically
with this change stashed and are pre-existing/unrelated.

test_issue4702's assertion is updated to target the `!grew` gate
behaviorally instead of matching the old literal line, preserving its
original intent.
…verlay scrollbar hits

Maintainer gate on 7b0b3f2 found two silent scroll-ownership defects:

1. The _scrollbarDragIntentQueued latch was armed only when the SYNC scroll
   handler saw _scrollbarDragActive===true, but pointerup clears that flag
   before the asynchronously-dispatched scroll arrives (quick 3-16px thumb
   drag: pointerdown -> scrollTop change -> pointerup -> scroll -> rAF), so
   the drag was classified as tail jitter and the reader re-pinned.

2. Drag ownership was claimed only for gutter scrollbars
   (offsetX >= clientWidth); an overlay scrollbar (Firefox macOS thin,
   Mozilla bug 1568939) sits INSIDE the client box and was never detected.

Fix: stamp a bounded drag intent (_scrollbarDragIntentUntil = now + window)
on pointerdown AND pointerup/pointercancel; the first scroll event after it
consumes the stamp (folded into the queued latch) whatever branch it takes,
so it never leaks into a later frame, and an unconsumed stamp expires.
pointerdown now accepts gutter hits OR overlay right-edge hits (offsetX
within the edge band of clientWidth, or clientX within the band of
getBoundingClientRect().right) while still requiring target === scroller.
Wheel/touch/keyboard jitter bypasses are untouched.
pointerup/pointercancel unconditionally re-stamped the scrollbar-drag
intent. When the drag's own scroll had already been delivered and
classified, that opened a fresh 250ms window which the next scroll -- a
render/layout nudge -- consumed, unpinning a reader who had dragged back
to the tail and been re-pinned.

Track the scrollTop last delivered to the scroll listener during the drag
(seeded at pointerdown). Release re-stamps only when the live scrollTop
differs (the drag's scroll event is still pending); otherwise it clears
the intent. Both ownership resets clear the tracker.

Tests:
- release after a delivered drag scroll does not re-arm (pointerup and
  pointercancel); drag back to tail + release + render nudge stays
  pinned; scrollbar click without movement leaves no intent; drag scroll
  delivered after release owns the re-armed intent, a later nudge cannot.
- async-ordering test now holds the thumb past the pointerdown window so
  removing the release re-stamp reds it.
- nesquena#4702 growth test settles 2px short of the bottom so removing the
  `!grew` guard reds it.
@ruizanthony
ruizanthony force-pushed the fix/scroll-tail-jitter-unpin branch from 90517be to 96fc55b Compare September 27, 2026 02:25
Comment thread static/ui.js Outdated
@ruizanthony

Copy link
Copy Markdown
Contributor Author

@nesquena-hermes ready for re-review at exact head 96fc55ba3dd4b05f850f4efb2fe47fafef291b38.

  • Rebased cleanly onto current master c296673ebfaf98750fe38438bc71f0cbb1f75777; all 9 commits are patch-identical by git range-diff, and the aggregate stable patch ID is unchanged.
  • The latest reviewed blocker remains fixed: release at the true bottom clears both queued and timestamped drag intent, while a genuinely late upward thumb movement still unpins.
  • Local post-rebase verification: 33 focused + 176 adjacent scroll/pin + 6 JS/lint gate tests passed; node --check, diff-scoped ruff, and git diff --check are clean.
  • All 25 current GitHub checks pass on this exact head, including the 15 Python 3.11–3.13 test shards, browser smoke, docs, lint, live-to-final scenarios, and Greptile.
  • The PR body is refreshed with Contract Routing, exact commands/results, risks, model disclosure, and preserved desktop/narrow/mobile evidence.

The review-request API was unavailable to this fork author, so this comment is the explicit re-review request.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static re-gate at exact head 780fd6e598c4649aafb5f6ad5e27147502067f0e (mandatory NO-RUN: Layer-1 scan is SUSPICIOUS on the submitted eval(...) harness). The true-bottom, queued-drag, and reset logic improved, but two objective pointer-ownership gaps remain.

  1. An ambiguous press in the rightmost 20px of the scrollable messages element becomes an overlay candidate. After only 2px of vertical movement, the next scrollTop change is itself used to prove a thumb drag before tail-jitter classification. Empty transcript margin + pointer movement + the same browser tail nudge under review can therefore manufacture drag ownership. The submitted negative margin case omits pointer movement and does not exercise this collision.
  2. pointerup / pointercancel ignore the candidate's stored pointerId; a different pointer can promote, release, or clear another pointer's drag.

Please replace the circular overlay inference with independent thumb/gutter evidence, preserve one pointer owner through move/up/cancel, and add behavioral rows for margin+movement+jitter, both real-drag event orderings, wrong-pointer release/cancel, true-bottom clearing, and full reset. This review is static because the scan refused execution; the bounce is based on the source-level schedules above, not on the scan finding.

This branch has not been deployed

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

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants