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

* fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during 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.

* fix(chat): address review — reset pointer ref on teardown, stop wheel/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.

* fix(chat): scope detach authorization to real scroll gestures; TSDoc 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.

* fix(chat): only upward scroll keys authorize a keyboard detach

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.
This commit is contained in:
Waleed
2026-06-15 23:30:30 -07:00
committed by GitHub
parent 6cbaf4282d
commit e48c960fe8
+80 -9
View File
@@ -4,6 +4,26 @@ import { useCallback, useEffect, useRef } from 'react'
const STICK_THRESHOLD = 30
/** User must scroll back to within this distance to re-engage auto-scroll. */
const REATTACH_THRESHOLD = 5
/**
* An upward keyboard scroll ({@link SCROLL_UP_KEYS}) only emits `scroll` events, so
* its detach is honored when it lands within this window of the `keydown`. Wheel and
* touch detach directly via their own handlers, and scrollbar drags are tracked
* through {@link pointerDownRef}, so neither feeds this window.
*
* The guard exists because virtualizers (react-virtual) programmatically move
* `scrollTop` to keep content stable when a measured row's size changes —
* including the transient height *shrinks* a streaming markdown renderer emits as
* it re-parses each token. Without it, that upward programmatic scroll is misread
* as the user scrolling away and auto-scroll detaches mid-stream.
*/
const USER_GESTURE_WINDOW = 250
/**
* Keys that scroll the viewport upward. Only these authorize a keyboard detach,
* mirroring the wheel handler's upward-only ({@link WheelEvent.deltaY} < 0) rule,
* so an unrelated keypress can't open the detach window. `Shift`+`Space` (handled
* in the listener) is the other upward shortcut; plain `Space` pages down.
*/
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
interface UseAutoScrollOptions {
scrollOnMount?: boolean
@@ -13,9 +33,10 @@ interface UseAutoScrollOptions {
* Manages sticky auto-scroll for a streaming chat container.
*
* Stays pinned to the bottom while content streams in. Detaches immediately
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
* bottom to re-engage.
* on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
* detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
* the bottom to re-engage. Each streaming start re-seeds stickiness from the
* current scroll position, so a user who scrolled up beforehand stays put.
*
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
* for imperative use after layout-changing events like panel expansion.
@@ -32,6 +53,18 @@ export function useAutoScroll(
const touchStartYRef = useRef(0)
const rafIdRef = useRef(0)
const scrollOnMountRef = useRef(scrollOnMount)
/**
* Whether the user is actively dragging the scrollbar — a pointer press on the
* container itself rather than its content. Reset on teardown so a pointer held
* as one stream ends can't leak into the next session and authorize a detach.
*/
const pointerDownRef = useRef(false)
/**
* Timestamp of the last keyboard scroll, the only detach gesture that emits no
* wheel/touch/pointer signal. Gates {@link USER_GESTURE_WINDOW}; reset on teardown
* so a keypress near a stream's end can't carry into the next session.
*/
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
@@ -49,7 +82,6 @@ export function useAutoScroll(
const el = containerRef.current
if (!el) return
// Don't jump if the user scrolled up — keep their position.
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
stickyRef.current = isNearBottom
@@ -75,15 +107,43 @@ export function useAutoScroll(
if (e.touches[0].clientY > touchStartYRef.current) detach()
}
/**
* A scrollbar press targets the scroll container itself; a press on message
* content targets a descendant. Only the former is a scroll gesture, so a
* text-selection drag on content can't authorize a detach.
*/
const onPointerDown = (e: PointerEvent) => {
if (e.target === el) pointerDownRef.current = true
}
const onPointerUp = () => {
pointerDownRef.current = false
}
const onKeyDown = (e: KeyboardEvent) => {
if (SCROLL_UP_KEYS.has(e.key) || (e.key === ' ' && e.shiftKey)) {
lastUserGestureAtRef.current = performance.now()
}
}
/**
* Re-engages when the user returns near the bottom, and detaches on an upward
* scroll — but only a genuine user scroll qualifies: an active scrollbar drag
* (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
* a virtualizer re-pinning content on a row-size shrink, has neither and must not
* be mistaken for the user scrolling away.
*/
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD
const userDriven =
pointerDownRef.current ||
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW
if (distanceFromBottom <= threshold) {
stickyRef.current = true
userDetachedRef.current = false
} else if (
userDriven &&
scrollTop < prevScrollTopRef.current &&
scrollHeight <= prevScrollHeightRef.current
) {
@@ -105,11 +165,12 @@ export function useAutoScroll(
rafIdRef.current = requestAnimationFrame(guardedScroll)
}
// CSS-driven height animations (e.g. Radix Collapsible expanding
// mid-stream) grow scrollHeight without triggering MutationObserver,
// so auto-scroll stops following. When any animation starts in the
// container, follow rAF for a short window so the container stays
// pinned to the bottom while the animation runs.
/**
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
* following. When any animation starts in the container, follow rAF for a short
* window so the container stays pinned to the bottom while the animation runs.
*/
const onAnimationStart = () => {
if (!stickyRef.current) return
const until = performance.now() + 500
@@ -126,6 +187,10 @@ export function useAutoScroll(
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('animationstart', onAnimationStart)
el.addEventListener('pointerdown', onPointerDown, { passive: true })
el.addEventListener('keydown', onKeyDown, { passive: true })
window.addEventListener('pointerup', onPointerUp, { passive: true })
window.addEventListener('pointercancel', onPointerUp, { passive: true })
const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
@@ -136,8 +201,14 @@ export function useAutoScroll(
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('scroll', onScroll)
el.removeEventListener('animationstart', onAnimationStart)
el.removeEventListener('pointerdown', onPointerDown)
el.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
observer.disconnect()
cancelAnimationFrame(rafIdRef.current)
pointerDownRef.current = false
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
if (stickyRef.current) scrollToBottom()
}
}, [isStreaming, scrollToBottom])