Skip to content

🤖 fix: stop scroll-up jitter at bottom + harden auto-scroll ownership#3226

Merged
ammario merged 2 commits intomainfrom
scroll-mf57
May 3, 2026
Merged

🤖 fix: stop scroll-up jitter at bottom + harden auto-scroll ownership#3226
ammario merged 2 commits intomainfrom
scroll-mf57

Conversation

@ammar-agent
Copy link
Copy Markdown
Collaborator

Summary

Fixes the small-but-noticeable jitter when the user starts scrolling up from the very bottom while the chat transcript bottom-lock is engaged. Eventually a large enough wheel/touch delta would "win" against the rAF settle tick, but the first 1–3 notches of a slow gesture used to be snapped back to the bottom — felt like the scrollport was fighting the user.

Background

useAutoScroll.handleScroll's user-intent branch decided lock state from a single 8 px geometric threshold. A small wheel notch (typical mousewheel notch is 3–7 px) landed within that window, so the hook re-engaged the lock and the 60-frame rAF settle loop wrote scrollTop = scrollHeight − clientHeight on the next frame, snapping the user back to the bottom. The user could only "win" by accumulating a single tick of more than 8 px before the next rAF.

Implementation

Two commits, both behavior-preserving outside the targeted regression and validated against the existing 22 hook unit tests + the bottomLayoutShift integration suite.

Commit 1 — fix: stop scroll-up jitter from the very bottom

Asymmetric thresholds in handleScroll's user-intent branch:

  • Locked → release on > 1 px drift (BOTTOM_LOCK_EPSILON_PX). The very first wheel-up notch releases without rAF snap-back.
  • Released → relock only when the user is moving toward the bottom (currentScrollTop > previousScrollTop) AND within 8 px (USER_BOTTOM_RELOCK_THRESHOLD_PX). Direction is tracked with a single new ref (lastScrollTopRef) updated at the top of every handleScroll call.

The no-intent paths (1 px drift correction while locked, geometric relock at 8 px after the intent window expires) are unchanged. The existing "scroll back to bottom and the lock re-engages" UX is preserved.

Commit 2 — fix: harden auto-scroll user-intent ownership

Audit follow-ups (best-of-5 read-only audit converged on these):

  1. Filter delta-0 wheel events. Cmd-wheel zoom on macOS, Shift-wheel for horizontal-only, Bluetooth-mouse jitter, and pinch gestures all dispatch wheel events with deltaY === 0 (and often deltaX === 0). Without filtering, every phantom wheel cleared programmaticDisableRef and refreshed the 750 ms intent window, weakening every downstream gate that relied on those refs. New handleScrollContainerWheel is exposed by the hook; ChatPane wires it in place of markUserScrollIntent.

  2. Seed lastScrollTopRef inside disableAutoScroll and jumpToBottom. The released-branch direction check compares scrollTop against this ref, but neither path always emits a scroll event (disableAutoScroll never does; jumpToBottom skips the write when scrollTop is already max). Without the seed, a small wheel-up notch following an explicit programmatic disable could be misread as "moving toward bottom" (e.g. 895 > 0) and spuriously relock the lock that was just disabled.

Validation

  • bun test src/browser/hooks/useAutoScroll.test.tsx — 25 / 25 pass (19 prior + 6 new regression tests covering the four scenarios above).
  • bun x jest tests/ui/chat/bottomLayoutShift.test.ts — passes (drift-correction / pin-on-resize / send / workspace-switch contracts unchanged).
  • make static-check — passes locally end-to-end.

Risks

  • Behavior near the 1 px drift epsilon under hi-DPI / browser zoom is unchanged from before; BOTTOM_LOCK_EPSILON_PX was already used for the no-intent drift correction. The fix uses the same value for the locked-intent release path, so any pre-existing subpixel sensitivity is consistent across paths.
  • The wheel filter will not mark intent on a wheel event with both deltas equal to 0 — by design. Users on assistive input devices that emit deltaY = 0 but expect intent marking are unaffected because such events also do not move the scrollport, and our intent window only matters when scroll motion follows.
  • lastScrollTopRef seeding is purely additive — every code path that writes to it before now still writes to it now; we just close two narrow staleness windows (disableAutoScroll with no follow-up scroll event, jumpToBottom when already at bottom).

Pains

The audit phase (5 read-only sub-agents in parallel) was the right call here: 4 of 5 audits independently flagged Findings #1 (programmaticDisableRef bypass) and #2 (lastScrollTopRef cold-start), which I would have likely missed reasoning forward from the initial fix alone. The audits also agreed that the new direction-aware logic is the right primitive — none recommended walking it back.

Deferred to follow-up PRs (out of scope for "scroll-up jitter"):

  • Workspace-switch hydration race (hasLoadedTranscriptRows flip mid-read snaps to bottom).
  • ResizeObserver disconnect/reconnect on every autoScroll toggle.
  • Tab key not in TRANSCRIPT_SCROLL_KEYS (keyboard-nav focus-induced scroll snaps back).
  • Parallel patterns in OutputTab / BashToolCall / InitMessage (different sub-views, each with its own bottom-lock heuristic).

Generated with mux • Model: anthropic:claude-opus-4-7 • Thinking: max • Cost: $11.30

@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Best-of-5 read-only audit was run before opening; this PR rolls up the convergent findings (delta-0 wheel filter + lastScrollTopRef seeding for disableAutoScroll/jumpToBottom). Deferred follow-ups (workspace-switch hydration race, ResizeObserver lifecycle, Tab-key intent, parallel patterns in OutputTab/BashToolCall/InitMessage) are listed in the PR body — please flag if any feel like they should land in this PR instead.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Asymmetric thresholds in handleScroll's user-intent branch:
- Locked -> release on >1px drift (small wheel notch shouldn't snap back)
- Released -> relock only when moving toward bottom AND within 8px

Previously a single 8px geometry threshold gated both sides, so a small
wheel-up tick (~3-7px) re-engaged the lock and the rAF settle tick
wrote scrollTop=max on the next frame, snapping the user back. The
direction check uses lastScrollTopRef captured at the top of every
handleScroll call.
Audit follow-ups for the bottom-lock state machine after the scroll-up
jitter fix:

1) Filter delta-0 wheel events (Cmd-wheel zoom on macOS, Shift-wheel for
   horizontal-only, Bluetooth-mouse jitter, pinch gestures). Without the
   filter every phantom wheel cleared programmaticDisableRef and refreshed
   the 750 ms intent window, weakening downstream gates.

2) Seed lastScrollTopRef inside disableAutoScroll and jumpToBottom. The
   released-branch user-intent direction check compares scrollTop against
   this ref, but neither path always fires a scroll event (disable never
   does; jumpToBottom skips the write when scrollTop is already max). Without
   the seed, a small wheel-up notch following an explicit programmatic
   disable could be misread as 'moving toward bottom' and spuriously relock.

3) Add three regression tests covering disable-then-wheel-up, delta-0 wheel
   ignored, and non-zero wheel still marks intent.

ChatPane wires onWheel to the new handleScrollContainerWheel handler from
the hook, replacing the direct binding to markUserScrollIntent.
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Rebased onto current origin/main (clean rebase, no file overlap with the two intervening main commits #3222 and #3223). No code changes vs the previously approved revision.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ammario ammario merged commit aadaf16 into main May 3, 2026
24 checks passed
@ammario ammario deleted the scroll-mf57 branch May 3, 2026 03:35
@mux-bot mux-bot Bot mentioned this pull request May 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants