From e48c960fe8b25183d9e02b2708288b85b35b47f9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 15 Jun 2026 23:30:30 -0700 Subject: [PATCH] fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming (#5093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- apps/sim/hooks/use-auto-scroll.ts | 89 +++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 552f2bf62c..efe44b313f 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -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])