Skip to content

Commit 2c1392e

Browse files
authored
fix(chat): autoscroll follow-ups — re-engage threshold + keep end-of-turn options in view (#5094)
* 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. * fix(chat): keep end-of-turn options in view after streaming 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.
1 parent e1f22bd commit 2c1392e

1 file changed

Lines changed: 34 additions & 8 deletions

File tree

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ const USER_GESTURE_WINDOW = 250
2424
* in the listener) is the other upward shortcut; plain `Space` pages down.
2525
*/
2626
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
27+
/** How long to keep chasing the bottom while a CSS height animation plays. */
28+
const ANIMATION_FOLLOW_WINDOW = 500
29+
/**
30+
* How long to keep chasing the bottom after streaming stops. End-of-turn content
31+
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
32+
* the actions row (gated on `!isStreaming`), and the virtualizer's re-measure of
33+
* the grown row — so a single final scroll fires before it lays out and leaves it
34+
* clipped behind the input. Following for a short window pulls it into view.
35+
*/
36+
const POST_STREAM_SETTLE_WINDOW = 300
2737

2838
interface UseAutoScrollOptions {
2939
scrollOnMount?: boolean
@@ -147,7 +157,7 @@ export function useAutoScroll(
147157
scrollTop < prevScrollTopRef.current &&
148158
scrollHeight <= prevScrollHeightRef.current
149159
) {
150-
stickyRef.current = false
160+
detach()
151161
}
152162

153163
prevScrollTopRef.current = scrollTop
@@ -166,22 +176,38 @@ export function useAutoScroll(
166176
}
167177

168178
/**
169-
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
170-
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
171-
* following. When any animation starts in the container, follow rAF for a short
172-
* window so the container stays pinned to the bottom while the animation runs.
179+
* Chase the bottom every frame for `durationMs`. Catches height growth that
180+
* arrives over several frames with no observed DOM mutation — a CSS height
181+
* animation, or end-of-turn content and the virtualizer's re-measure settling
182+
* after streaming stops.
183+
*
184+
* Self-interrupting: height growth leaves `scrollTop` exactly where we last
185+
* put it, whereas a user scroll moves it up from there — so the moment
186+
* `scrollTop` drops below our last write, we stop and never fight a real
187+
* scroll, even with the gesture listeners already torn down.
173188
*/
174-
const onAnimationStart = () => {
189+
const followToBottom = (durationMs: number) => {
175190
if (!stickyRef.current) return
176-
const until = performance.now() + 500
191+
const until = performance.now() + durationMs
192+
let lastTop = -1
177193
const follow = () => {
178194
if (performance.now() > until || !stickyRef.current) return
195+
if (lastTop >= 0 && el.scrollTop < lastTop - 1) return
179196
scrollToBottom()
197+
lastTop = el.scrollTop
180198
requestAnimationFrame(follow)
181199
}
182200
requestAnimationFrame(follow)
183201
}
184202

203+
/**
204+
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
205+
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
206+
* following. Follow for a short window so the container stays pinned while the
207+
* animation runs.
208+
*/
209+
const onAnimationStart = () => followToBottom(ANIMATION_FOLLOW_WINDOW)
210+
185211
el.addEventListener('wheel', onWheel, { passive: true })
186212
el.addEventListener('touchstart', onTouchStart, { passive: true })
187213
el.addEventListener('touchmove', onTouchMove, { passive: true })
@@ -209,7 +235,7 @@ export function useAutoScroll(
209235
cancelAnimationFrame(rafIdRef.current)
210236
pointerDownRef.current = false
211237
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
212-
if (stickyRef.current) scrollToBottom()
238+
followToBottom(POST_STREAM_SETTLE_WINDOW)
213239
}
214240
}, [isStreaming, scrollToBottom])
215241

0 commit comments

Comments
 (0)