@@ -25,22 +25,6 @@ const USER_GESTURE_WINDOW = 250
2525 * in the listener) is the other upward shortcut; plain `Space` pages down.
2626 */
2727const SCROLL_UP_KEYS = new Set ( [ 'ArrowUp' , 'PageUp' , 'Home' ] )
28- /** How long to keep chasing the bottom while a CSS height animation plays. */
29- const ANIMATION_FOLLOW_WINDOW = 500
30- /**
31- * How long to keep chasing the bottom after streaming stops. End-of-turn content
32- * mounts just after `isStreaming` flips false — the suggested-follow-up options,
33- * the actions row (swapped into the thinking slot's place), and the
34- * virtualizer's re-measure of the grown row — so a single final scroll fires
35- * before it lays out and leaves it clipped behind the input. Following for a
36- * short window pulls it into view.
37- */
38- const POST_STREAM_SETTLE_WINDOW = 300
39-
40- interface UseAutoScrollOptions {
41- scrollOnMount ?: boolean
42- }
43-
4428/**
4529 * Manages sticky auto-scroll for a streaming chat container.
4630 *
@@ -50,23 +34,18 @@ interface UseAutoScrollOptions {
5034 * of the bottom to re-engage. Each streaming start re-seeds stickiness from the
5135 * current scroll position, so a user who scrolled up beforehand stays put.
5236 *
53- * Returns `ref` (callback ref for the scroll container), `scrollToBottom` for
54- * imperative use after layout-changing events like panel expansion, and
55- * `detach` for programmatic freezes (a user stop) — it parks every chase path
56- * exactly like a user scroll-away, until the user scrolls back to the bottom
57- * or the next stream re-seeds stickiness.
37+ * Returns `ref` (callback ref for the scroll container) and `detach` for
38+ * programmatic freezes (a user stop) — it parks every chase path exactly like
39+ * a user scroll-away, until the user scrolls back to the bottom or the next
40+ * stream re-seeds stickiness.
5841 */
59- export function useAutoScroll (
60- isStreaming : boolean ,
61- { scrollOnMount = false } : UseAutoScrollOptions = { }
62- ) {
42+ export function useAutoScroll ( isStreaming : boolean ) {
6343 const containerRef = useRef < HTMLDivElement > ( null )
6444 const stickyRef = useRef ( true )
6545 const userDetachedRef = useRef ( false )
6646 const prevScrollTopRef = useRef ( 0 )
6747 const prevScrollHeightRef = useRef ( 0 )
6848 const touchStartYRef = useRef ( 0 )
69- const scrollOnMountRef = useRef ( scrollOnMount )
7049 /**
7150 * Whether the user is actively dragging the scrollbar — a pointer press on the
7251 * container itself rather than its content. Reset on teardown so a pointer held
@@ -80,20 +59,13 @@ export function useAutoScroll(
8059 */
8160 const lastUserGestureAtRef = useRef ( Number . NEGATIVE_INFINITY )
8261
83- const scrollToBottom = useCallback ( ( ) => {
84- const el = containerRef . current
85- if ( ! el ) return
86- el . scrollTop = el . scrollHeight
87- } , [ ] )
88-
8962 const detach = useCallback ( ( ) => {
9063 stickyRef . current = false
9164 userDetachedRef . current = true
9265 } , [ ] )
9366
9467 const callbackRef = useCallback ( ( el : HTMLDivElement | null ) => {
9568 containerRef . current = el
96- if ( el && scrollOnMountRef . current ) el . scrollTop = el . scrollHeight
9769 } , [ ] )
9870
9971 useEffect ( ( ) => {
@@ -198,42 +170,38 @@ export function useAutoScroll(
198170 prevScrollHeightRef . current = scrollHeight
199171 }
200172
201- const onMutation = ( ) => {
173+ /**
174+ * The single growth signal: the transcript sizer's height. Every source of
175+ * scrollHeight growth flows through it — virtualizer re-measures per
176+ * streamed token, CSS height animations (each frame re-measures the row),
177+ * the sizer min-height floor — so one ResizeObserver replaces a subtree
178+ * MutationObserver plus an `animationstart` deadline machine, and there is
179+ * exactly one reason the chase ever runs.
180+ */
181+ const sizer = el . firstElementChild
182+ const onSizerResize = ( ) => {
202183 prevScrollHeightRef . current = el . scrollHeight
203184 if ( ! stickyRef . current ) return
204185 chase . kick ( )
205186 }
206187
207- /**
208- * CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
209- * grow scrollHeight without triggering MutationObserver, so auto-scroll stops
210- * following. Keep the one chase loop alive for a short window so the
211- * container stays pinned while the animation runs. `animationstart` fires
212- * for every child animation in the transcript (segment fade-ins, loader
213- * keyframes, label crossfades) — kickUntil coalesces them into a single
214- * extended deadline on the single loop; anything more snaps the glide.
215- */
216- const onAnimationStart = ( ) => chase . kickUntil ( ANIMATION_FOLLOW_WINDOW )
217-
218188 el . addEventListener ( 'wheel' , onWheel , { passive : true } )
219189 el . addEventListener ( 'touchstart' , onTouchStart , { passive : true } )
220190 el . addEventListener ( 'touchmove' , onTouchMove , { passive : true } )
221191 el . addEventListener ( 'scroll' , onScroll , { passive : true } )
222- el . addEventListener ( 'animationstart' , onAnimationStart )
223192 el . addEventListener ( 'pointerdown' , onPointerDown , { passive : true } )
224193 el . addEventListener ( 'keydown' , onKeyDown , { passive : true } )
225194 window . addEventListener ( 'pointerup' , onPointerUp , { passive : true } )
226195 window . addEventListener ( 'pointercancel' , onPointerUp , { passive : true } )
227196
228- const observer = new MutationObserver ( onMutation )
229- observer . observe ( el , { childList : true , subtree : true , characterData : true } )
197+ const observer = new ResizeObserver ( onSizerResize )
198+ if ( sizer ) observer . observe ( sizer )
230199
231200 return ( ) => {
232201 el . removeEventListener ( 'wheel' , onWheel )
233202 el . removeEventListener ( 'touchstart' , onTouchStart )
234203 el . removeEventListener ( 'touchmove' , onTouchMove )
235204 el . removeEventListener ( 'scroll' , onScroll )
236- el . removeEventListener ( 'animationstart' , onAnimationStart )
237205 el . removeEventListener ( 'pointerdown' , onPointerDown )
238206 el . removeEventListener ( 'keydown' , onKeyDown )
239207 window . removeEventListener ( 'pointerup' , onPointerUp )
@@ -242,13 +210,13 @@ export function useAutoScroll(
242210 chase . cancel ( )
243211 pointerDownRef . current = false
244212 lastUserGestureAtRef . current = Number . NEGATIVE_INFINITY
245- // End-of-turn content mounts just after teardown; follow it briefly. The
246- // chase's own upward-move interrupt still protects a real user scroll
247- // even with the gesture listeners gone, and the per-frame sticky check
248- // makes this a no-op after a detach() .
249- chase . kickUntil ( POST_STREAM_SETTLE_WINDOW )
213+ // Teardown can land mid-glide (options mounted late in the reveal, gap
214+ // not yet closed) — canceling there strands the follow-ups behind the
215+ // input. One plain kick runs the loop to rest and parks; a stopped turn
216+ // stays frozen because the detached sticky check parks it on frame one .
217+ chase . kick ( )
250218 }
251- } , [ isStreaming , scrollToBottom ] )
219+ } , [ isStreaming ] )
252220
253- return { ref : callbackRef , scrollToBottom , detach }
221+ return { ref : callbackRef , detach }
254222}
0 commit comments