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])