From db5566dc5860504a4993ae0ce0131ed0f55ba0b8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:51:06 -0700 Subject: [PATCH 1/4] fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/hooks/use-auto-scroll.ts | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 552f2bf62ca..a5659c055d4 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -4,6 +4,15 @@ 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 +/** + * A scrollbar-drag detach is only honored if a real user gesture occurred within + * this window. 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 this guard, that upward programmatic scroll is misread as the + * user scrolling away and auto-scroll detaches mid-stream. + */ +const USER_GESTURE_WINDOW = 250 interface UseAutoScrollOptions { scrollOnMount?: boolean @@ -32,6 +41,8 @@ export function useAutoScroll( const touchStartYRef = useRef(0) const rafIdRef = useRef(0) const scrollOnMountRef = useRef(scrollOnMount) + const pointerDownRef = useRef(false) + const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY) const scrollToBottom = useCallback(() => { const el = containerRef.current @@ -63,27 +74,52 @@ export function useAutoScroll( userDetachedRef.current = true } + const markGesture = () => { + lastUserGestureAtRef.current = performance.now() + } + const onWheel = (e: WheelEvent) => { + markGesture() if (e.deltaY < 0) detach() } const onTouchStart = (e: TouchEvent) => { + markGesture() touchStartYRef.current = e.touches[0].clientY } const onTouchMove = (e: TouchEvent) => { + markGesture() if (e.touches[0].clientY > touchStartYRef.current) detach() } + const onPointerDown = () => { + pointerDownRef.current = true + markGesture() + } + const onPointerUp = () => { + pointerDownRef.current = false + } + const onKeyDown = markGesture + const onScroll = () => { const { scrollTop, scrollHeight, clientHeight } = el const distanceFromBottom = scrollHeight - scrollTop - clientHeight const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD + // Only a genuine, recent user gesture (scrollbar drag, keyboard) may detach via + // a scroll-position delta. A programmatic upward scroll — e.g. a virtualizer + // re-pinning content on a row-size shrink — has no preceding gesture and must + // not be mistaken for the user scrolling away. + 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 ) { @@ -126,6 +162,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,6 +176,10 @@ 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) if (stickyRef.current) scrollToBottom() From c08a22cd96084ed2ecb57c438a7f6c55db1541d8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:00:59 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(chat):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20reset=20pointer=20ref=20on=20teardown,=20stop=20wheel/touch?= =?UTF-8?q?=20opening=20detach=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/sim/hooks/use-auto-scroll.ts | 40 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index a5659c055d4..e28dd85aa44 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -5,12 +5,16 @@ const STICK_THRESHOLD = 30 /** User must scroll back to within this distance to re-engage auto-scroll. */ const REATTACH_THRESHOLD = 5 /** - * A scrollbar-drag detach is only honored if a real user gesture occurred within - * this window. 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 this guard, that upward programmatic scroll is misread as the - * user scrolling away and auto-scroll detaches mid-stream. + * A keyboard-driven scroll (PageUp, arrows) only emits `scroll` events, so its + * detach is honored when it lands within this window of a `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 @@ -74,43 +78,37 @@ export function useAutoScroll( userDetachedRef.current = true } - const markGesture = () => { - lastUserGestureAtRef.current = performance.now() - } - const onWheel = (e: WheelEvent) => { - markGesture() if (e.deltaY < 0) detach() } const onTouchStart = (e: TouchEvent) => { - markGesture() touchStartYRef.current = e.touches[0].clientY } const onTouchMove = (e: TouchEvent) => { - markGesture() if (e.touches[0].clientY > touchStartYRef.current) detach() } const onPointerDown = () => { pointerDownRef.current = true - markGesture() } const onPointerUp = () => { pointerDownRef.current = false } - const onKeyDown = markGesture + const onKeyDown = () => { + lastUserGestureAtRef.current = performance.now() + } const onScroll = () => { const { scrollTop, scrollHeight, clientHeight } = el const distanceFromBottom = scrollHeight - scrollTop - clientHeight const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD - // Only a genuine, recent user gesture (scrollbar drag, keyboard) may detach via - // a scroll-position delta. A programmatic upward scroll — e.g. a virtualizer - // re-pinning content on a row-size shrink — has no preceding gesture and must - // not be mistaken for the user scrolling away. + // Only a genuine user scroll may detach via a scroll-position delta: 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 userDriven = pointerDownRef.current || performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW @@ -182,6 +180,10 @@ export function useAutoScroll( window.removeEventListener('pointercancel', onPointerUp) observer.disconnect() cancelAnimationFrame(rafIdRef.current) + // A pointer held through teardown (e.g. dragging the scrollbar as the stream + // finishes) would never see its window `pointerup`, leaving the ref stuck true + // into the next session and detaching on the first programmatic re-pin. + pointerDownRef.current = false if (stickyRef.current) scrollToBottom() } }, [isStreaming, scrollToBottom]) From 2a3f48dd60c8d2d5cb97db0877449117cae5dd1b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:12:50 -0700 Subject: [PATCH 3/4] 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. --- apps/sim/hooks/use-auto-scroll.ts | 54 ++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index e28dd85aa44..f6ceb03db81 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -26,9 +26,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. @@ -45,7 +46,17 @@ 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(() => { @@ -64,7 +75,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 @@ -90,8 +100,13 @@ export function useAutoScroll( if (e.touches[0].clientY > touchStartYRef.current) detach() } - const onPointerDown = () => { - pointerDownRef.current = true + /** + * 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 @@ -100,15 +115,17 @@ export function useAutoScroll( 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 - - // Only a genuine user scroll may detach via a scroll-position delta: 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 userDriven = pointerDownRef.current || performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW @@ -139,11 +156,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 @@ -180,10 +198,8 @@ export function useAutoScroll( window.removeEventListener('pointercancel', onPointerUp) observer.disconnect() cancelAnimationFrame(rafIdRef.current) - // A pointer held through teardown (e.g. dragging the scrollbar as the stream - // finishes) would never see its window `pointerup`, leaving the ref stuck true - // into the next session and detaching on the first programmatic re-pin. pointerDownRef.current = false + lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY if (stickyRef.current) scrollToBottom() } }, [isStreaming, scrollToBottom]) From 7746faa6882554f5bf5e9dfd382eb8efc0448cfa Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:23:22 -0700 Subject: [PATCH 4/4] 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 | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index f6ceb03db81..efe44b313f9 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -5,8 +5,8 @@ const STICK_THRESHOLD = 30 /** User must scroll back to within this distance to re-engage auto-scroll. */ const REATTACH_THRESHOLD = 5 /** - * A keyboard-driven scroll (PageUp, arrows) only emits `scroll` events, so its - * detach is honored when it lands within this window of a `keydown`. Wheel and + * 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. * @@ -17,6 +17,13 @@ const REATTACH_THRESHOLD = 5 * 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 @@ -111,8 +118,10 @@ export function useAutoScroll( const onPointerUp = () => { pointerDownRef.current = false } - const onKeyDown = () => { - lastUserGestureAtRef.current = performance.now() + const onKeyDown = (e: KeyboardEvent) => { + if (SCROLL_UP_KEYS.has(e.key) || (e.key === ' ' && e.shiftKey)) { + lastUserGestureAtRef.current = performance.now() + } } /**