mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user