Skip to content

fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming#5093

Merged
waleedlatif1 merged 4 commits into
stagingfrom
autoscroll-fix
Jun 16, 2026
Merged

fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming#5093
waleedlatif1 merged 4 commits into
stagingfrom
autoscroll-fix

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Autoscroll detached mid-stream because the sticky-scroll detach heuristic (scrollTop drops while scrollHeight doesn't grow) couldn't tell a user scrollbar drag from a programmatic scroll.
  • react-virtual re-pins content by moving scrollTop whenever a measured row's size changes — including the transient height shrinks streamdown emits as it re-parses each streaming token — so the hook misread those upward programmatic scrolls as the user scrolling away. This is the "perfect autoscroll" regression introduced alongside virtualization + streamdown.
  • Fix: gate the scroll-delta detach branch behind a genuine recent user gesture (pointerdown/up tracking + a 250ms wheel/touch/keydown stamp). Programmatic scrolls have no preceding gesture, so they no longer detach; scrollbar drag, wheel, and keyboard detach are fully preserved.
  • Scope is contained: only mothership-chat.tsx consumes this hook; new listeners are passive and torn down with the streaming-gated effect.

Type of Change

  • Bug fix

Testing

Confirmed against the exact onScroll logic in headless Chromium: a programmatic up-scroll on a row-size shrink flips the original hook to detached, while the patched hook stays pinned and still detaches on genuine scrollbar-drag / wheel / keyboard. tsc clean, biome clean.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…ing streaming

The sticky-scroll detach heuristic (scrollTop drops while scrollHeight
doesn't grow) could not distinguish a user scrollbar drag from a
programmatic scroll. react-virtual re-pins content by moving scrollTop
whenever a measured row's size changes — including the transient height
shrinks streamdown emits as it re-parses each streaming token — so the
hook misread those upward programmatic scrolls as the user scrolling
away and detached mid-stream.

Gate the scroll-delta detach branch behind a genuine recent user gesture
(pointerdown/up tracking + wheel/touch/keydown stamp). Programmatic
scrolls have no preceding gesture, so they no longer detach; scrollbar
drag, wheel, and keyboard detach are preserved.
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 16, 2026 6:23am

Request Review

@cursor

cursor Bot commented Jun 16, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
UI-only change in a single hook used by mothership chat; preserves existing wheel/touch detach behavior with added guards.

Overview
Fixes mid-stream auto-scroll detaching when react-virtual or streaming markdown temporarily shrinks row height and programmatically moves scrollTop upward. The hook used to treat any upward scroll (with flat scrollHeight) as the user leaving the bottom.

onScroll detach is now gated on a real user gesture: active scrollbar drag (pointerdown on the scroll container only, not message content) or a keyboard up-scroll within a 250ms window (ArrowUp, PageUp, Home, Shift+Space). Wheel and touch still detach via their existing handlers.

Streaming start re-seeds stickiness from the current position (users already scrolled up stay detached). Gesture refs reset on effect teardown so state does not leak across streams. Listeners are passive and cleaned up with the streaming effect.

Reviewed by Cursor Bugbot for commit 7746faa. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a mid-stream autoscroll detachment regression caused by react-virtual programmatically adjusting scrollTop (to keep visible rows stable when a streaming renderer briefly shrinks a row's measured height), which the previous heuristic misidentified as a user scrolling away. The fix gates the onScroll detach branch behind a verified user gesture — an active scrollbar drag tracked via pointerdown/pointerup on the element, or a recent keyboard scroll stamped within a 250 ms window — so purely programmatic virtualizer re-pins are silently ignored.

  • Gesture detection: Three distinct user-gesture signals are tracked: pointer-down directly on the scroll container (scrollbar drag), keydown for ArrowUp/PageUp/Home/Shift+Space (keyboard scroll), and the pre-existing direct detach() calls in the wheel and touch handlers.
  • Cleanup safety: All new refs (pointerDownRef, lastUserGestureAtRef) are reset in the effect cleanup, preventing gesture state from leaking into the next streaming session.

Confidence Score: 4/5

The core fix is correct and well-scoped; one inconsistency in the detach path could allow autoscroll to re-engage unexpectedly after a scrollbar drag.

The gesture-detection approach is sound and the cleanup is correct. One gap: the onScroll detach branch sets only stickyRef.current = false rather than calling detach(), leaving userDetachedRef.current = false. This keeps the re-engagement threshold at the lenient 30 px (STICK_THRESHOLD) instead of the strict 5 px (REATTACH_THRESHOLD), so a virtualizer re-pin landing within 30 px of the bottom can immediately pull autoscroll back on after the user has just intentionally dragged the scrollbar away.

apps/sim/hooks/use-auto-scroll.ts — specifically the onScroll detach branch (lines 145-151)

Important Files Changed

Filename Overview
apps/sim/hooks/use-auto-scroll.ts Adds pointer + keyboard gesture tracking to distinguish programmatic virtualizer re-pins from genuine user scroll gestures; prevents spurious autoscroll detach mid-stream. One gap: the onScroll detach branch sets only stickyRef without calling detach(), so userDetachedRef stays false and re-engagement uses the lenient STICK_THRESHOLD (30 px) instead of REATTACH_THRESHOLD (5 px) after a scrollbar-drag or keyboard detach.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    S([scroll event fires]) --> UD{userDetachedRef?}
    UD -->|true| TH2[threshold = REATTACH_THRESHOLD 5px]
    UD -->|false| TH1[threshold = STICK_THRESHOLD 30px]
    TH1 & TH2 --> NEAR{distanceFromBottom <= threshold?}
    NEAR -->|yes| RE[re-engage: stickyRef=true, userDetachedRef=false]
    NEAR -->|no| DRV{userDriven? pointerDownRef OR recent keydown < 250ms}
    DRV -->|no programmatic virtualizer re-pin| SKIP[no detach, autoscroll stays pinned]
    DRV -->|yes| UP{scrollTop dropped AND scrollHeight stable?}
    UP -->|yes| DET[soft detach: stickyRef=false, userDetachedRef unchanged]
    UP -->|no| SKIP2[no change]
    W([wheel deltaY < 0]) --> HARD[hard detach: stickyRef=false, userDetachedRef=true]
    T([touch swipe up]) --> HARD
    HARD -.->|tighter 5px re-engagement| TH2
    DET -.->|lenient 30px re-engagement| TH1
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
    S([scroll event fires]) --> UD{userDetachedRef?}
    UD -->|true| TH2[threshold = REATTACH_THRESHOLD 5px]
    UD -->|false| TH1[threshold = STICK_THRESHOLD 30px]
    TH1 & TH2 --> NEAR{distanceFromBottom <= threshold?}
    NEAR -->|yes| RE[re-engage: stickyRef=true, userDetachedRef=false]
    NEAR -->|no| DRV{userDriven? pointerDownRef OR recent keydown < 250ms}
    DRV -->|no programmatic virtualizer re-pin| SKIP[no detach, autoscroll stays pinned]
    DRV -->|yes| UP{scrollTop dropped AND scrollHeight stable?}
    UP -->|yes| DET[soft detach: stickyRef=false, userDetachedRef unchanged]
    UP -->|no| SKIP2[no change]
    W([wheel deltaY < 0]) --> HARD[hard detach: stickyRef=false, userDetachedRef=true]
    T([touch swipe up]) --> HARD
    HARD -.->|tighter 5px re-engagement| TH2
    DET -.->|lenient 30px re-engagement| TH1
Loading

Comments Outside Diff (1)

  1. apps/sim/hooks/use-auto-scroll.ts, line 145-151 (link)

    P1 The onScroll detach branch sets only stickyRef.current = false, while the detach() helper (used by wheel and touch) also sets userDetachedRef.current = true. Because userDetachedRef controls the re-engagement threshold, a scrollbar-drag or keyboard detach leaves the threshold at STICK_THRESHOLD = 30 px instead of tightening it to REATTACH_THRESHOLD = 5 px. In practice this means a virtualizer re-pin that lands within 30 px of the bottom can immediately flip autoscroll back on after the user just intentionally dragged the scrollbar away — the same kind of spurious re-engagement the PR is fixing in the other direction. Replacing the inline stickyRef.current = false with a detach() call aligns behavior with wheel and touch.

Reviews (4): Last reviewed commit: "fix(chat): only upward scroll keys autho..." | Re-trigger Greptile

Comment thread apps/sim/hooks/use-auto-scroll.ts
Comment thread apps/sim/hooks/use-auto-scroll.ts
Comment thread apps/sim/hooks/use-auto-scroll.ts Outdated
…/touch opening detach window

- Reset pointerDownRef in effect cleanup so a pointer held through teardown
  (e.g. dragging the scrollbar as a stream finishes) can't leak a stuck-true
  ref into the next session and detach on the first programmatic re-pin.
- Wheel-up and touch-drag already detach directly, so the onScroll delta
  heuristic only needs to authorize scrollbar drag (pointerDownRef) and
  keyboard. Stop stamping the gesture window on wheel/touch, which otherwise
  let a harmless downward wheel open a 250ms window where a virtualizer
  shrink could falsely detach.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/use-auto-scroll.ts
Comment thread apps/sim/hooks/use-auto-scroll.ts
…comments

- onPointerDown only marks an active drag when the press targets the scroll
  container itself (the scrollbar), not its content, so a text-selection drag
  on a message can't authorize a detach during a programmatic re-pin.
- Reset lastUserGestureAtRef on teardown alongside pointerDownRef so neither a
  held pointer nor a late keydown can leak across streaming sessions.
- Convert the hook's inline comments to TSDoc on the relevant declarations per
  codebase conventions.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/use-auto-scroll.ts
onKeyDown stamped the gesture window on any bubbling key, so an unrelated
keypress within USER_GESTURE_WINDOW of a programmatic virtualizer re-pin
could satisfy userDriven and detach mid-stream. Filter to the upward scroll
keys (ArrowUp, PageUp, Home, Shift+Space), mirroring the wheel handler's
upward-only rule, so only a genuine upward keyboard scroll authorizes detach.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 7746faa. Configure here.

@waleedlatif1 waleedlatif1 merged commit e48c960 into staging Jun 16, 2026
16 checks passed
@waleedlatif1 waleedlatif1 deleted the autoscroll-fix branch June 16, 2026 06:30
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.

1 participant