From caf77adcd954d6d3066a51d9a03bcb62c0fccc1d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:36:19 -0700 Subject: [PATCH 1/2] fix(chat): align scrollbar/keyboard detach with wheel/touch re-engage threshold The onScroll detach branch set only stickyRef.current = false, leaving userDetachedRef false, so a scrollbar-drag or keyboard detach kept the lenient 30px (STICK_THRESHOLD) re-engage threshold instead of the strict 5px (REATTACH_THRESHOLD) used after wheel/touch. A programmatic virtualizer re-pin landing within 30px could then snap autoscroll back on right after the user deliberately scrolled away. Reuse the detach() helper so all detach paths set userDetachedRef consistently. --- apps/sim/hooks/use-auto-scroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index efe44b313f..f76b754750 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -147,7 +147,7 @@ export function useAutoScroll( scrollTop < prevScrollTopRef.current && scrollHeight <= prevScrollHeightRef.current ) { - stickyRef.current = false + detach() } prevScrollTopRef.current = scrollTop From 9a1c0817e81408c24f5e5e10a67c4f61d530c3ed Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 09:30:37 -0700 Subject: [PATCH 2/2] fix(chat): keep end-of-turn options in view after streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a stream ends, the suggested-follow-up options and the actions row (gated on !isStreaming) mount, but the virtualizer's getTotalSize — which drives the scroll container's scrollHeight — only catches up a frame or two later via its ResizeObserver. The single scrollToBottom() on effect teardown therefore landed on a stale, too-short bottom and the options were clipped behind the input. (Pre-virtualization this worked because scrollHeight reflected the new rows immediately.) Extract the rAF follow loop already used for CSS height animations into a shared followToBottom(window) helper and run it for a short settle window on teardown, so the bottom is chased until the virtualizer re-measures. The follow is self-interrupting — height growth leaves scrollTop where we put it, while a user scroll moves it up, so it bails the instant the user scrolls and never fights a real gesture even with listeners torn down. --- apps/sim/hooks/use-auto-scroll.ts | 40 +++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index f76b754750..cfc2c5c552 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -24,6 +24,16 @@ const USER_GESTURE_WINDOW = 250 * in the listener) is the other upward shortcut; plain `Space` pages down. */ const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home']) +/** How long to keep chasing the bottom while a CSS height animation plays. */ +const ANIMATION_FOLLOW_WINDOW = 500 +/** + * How long to keep chasing the bottom after streaming stops. End-of-turn content + * mounts just after `isStreaming` flips false — the suggested-follow-up options, + * the actions row (gated on `!isStreaming`), and the virtualizer's re-measure of + * the grown row — so a single final scroll fires before it lays out and leaves it + * clipped behind the input. Following for a short window pulls it into view. + */ +const POST_STREAM_SETTLE_WINDOW = 300 interface UseAutoScrollOptions { scrollOnMount?: boolean @@ -166,22 +176,38 @@ export function useAutoScroll( } /** - * 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. + * Chase the bottom every frame for `durationMs`. Catches height growth that + * arrives over several frames with no observed DOM mutation — a CSS height + * animation, or end-of-turn content and the virtualizer's re-measure settling + * after streaming stops. + * + * Self-interrupting: height growth leaves `scrollTop` exactly where we last + * put it, whereas a user scroll moves it up from there — so the moment + * `scrollTop` drops below our last write, we stop and never fight a real + * scroll, even with the gesture listeners already torn down. */ - const onAnimationStart = () => { + const followToBottom = (durationMs: number) => { if (!stickyRef.current) return - const until = performance.now() + 500 + const until = performance.now() + durationMs + let lastTop = -1 const follow = () => { if (performance.now() > until || !stickyRef.current) return + if (lastTop >= 0 && el.scrollTop < lastTop - 1) return scrollToBottom() + lastTop = el.scrollTop requestAnimationFrame(follow) } requestAnimationFrame(follow) } + /** + * CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream) + * grow scrollHeight without triggering MutationObserver, so auto-scroll stops + * following. Follow for a short window so the container stays pinned while the + * animation runs. + */ + const onAnimationStart = () => followToBottom(ANIMATION_FOLLOW_WINDOW) + el.addEventListener('wheel', onWheel, { passive: true }) el.addEventListener('touchstart', onTouchStart, { passive: true }) el.addEventListener('touchmove', onTouchMove, { passive: true }) @@ -209,7 +235,7 @@ export function useAutoScroll( cancelAnimationFrame(rafIdRef.current) pointerDownRef.current = false lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY - if (stickyRef.current) scrollToBottom() + followToBottom(POST_STREAM_SETTLE_WINDOW) } }, [isStreaming, scrollToBottom])