mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-16 14:50:06 +08:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -147,7 +157,7 @@ export function useAutoScroll(
|
||||
scrollTop < prevScrollTopRef.current &&
|
||||
scrollHeight <= prevScrollHeightRef.current
|
||||
) {
|
||||
stickyRef.current = false
|
||||
detach()
|
||||
}
|
||||
|
||||
prevScrollTopRef.current = scrollTop
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user