@@ -3,15 +3,8 @@ import { useCallback, useLayoutEffect, useRef } from 'react'
33const NEAR_BOTTOM_THRESHOLD = 30
44
55/**
6- * Computes how much extra height the scroll-anchor spacer needs to maintain
7- * the user's intended scroll position when replace-mode streaming temporarily
8- * produces content shorter than that position.
9- *
10- * @param targetScrollTop - the scroll position the user intends to hold
11- * @param clientHeight - visible height of the scroll container
12- * @param scrollHeight - total scrollable height (including current spacer)
13- * @param prevSpacerHeight - current spacer height (subtracted to get natural content height)
14- * @returns the new `minHeight` value to apply to the spacer element, or 0 if none needed
6+ * Returns the `minHeight` the spacer needs so `scrollTop` can safely reach
7+ * `targetScrollTop` when replace-mode streaming produces temporarily shorter content.
158 */
169export function computeSpacerShortage (
1710 targetScrollTop : number ,
@@ -25,35 +18,22 @@ export function computeSpacerShortage(
2518}
2619
2720/**
28- * Manages scroll for a streaming content container.
29- *
30- * Two modes based on whether the user has ever manually scrolled:
31- *
32- * **Never scrolled** — auto-follows new content to the bottom while
33- * streaming (MutationObserver keeps it pinned). The user can scroll up to
34- * detach; scrolling back to the bottom re-engages.
21+ * Manages scroll for a streaming file-preview container.
3522 *
36- * **Has scrolled** — position is locked. The hook injects a spacer element
37- * at the end of the container's content that inflates `scrollHeight` to at
38- * least `intendedScrollTop + clientHeight` on every content update. This
39- * prevents the browser from clamping `scrollTop` when replace-mode streaming
40- * temporarily produces a chunk shorter than the previous content (which would
41- * otherwise jump the viewport to the top before the content regrows).
42- *
43- * The "has scrolled" flag resets on unmount so each new chat / file gets a
44- * clean slate (parent remounts on key change).
23+ * Never-scrolled: auto-follows new content to the bottom (MutationObserver
24+ * keeps it pinned). Has-scrolled: position is locked via a spacer element
25+ * that inflates `scrollHeight` to prevent the browser from clamping `scrollTop`
26+ * when replace-mode streaming temporarily produces a shorter chunk.
4527 *
4628 * @param isStreaming - whether the container is currently receiving streaming content
47- * @param content - the current text content; drives the spacer recalculation
29+ * @param content - drives spacer recalculation; pass the current text value
4830 */
4931export function useScrollAnchor ( isStreaming : boolean , content ?: string ) {
5032 const containerRef = useRef < HTMLDivElement | null > ( null )
5133 const spacerRef = useRef < HTMLDivElement | null > ( null )
5234 const hasUserScrolledRef = useRef ( false )
5335 const stickyRef = useRef ( false )
54-
55- // The scroll position the user most recently settled on — updated only from
56- // genuine user scroll events, never from programmatic ones.
36+ // Tracks the user's last intentional position; updated only on genuine user events, never programmatic ones.
5737 const intendedScrollTopRef = useRef ( 0 )
5838
5939 const scrollToBottom = useCallback ( ( ) => {
@@ -62,11 +42,9 @@ export function useScrollAnchor(isStreaming: boolean, content?: string) {
6242 el . scrollTop = el . scrollHeight
6343 } , [ ] )
6444
65- // ── event listeners ─────────────────────────────────────────────────────
66-
6745 const onWheel = useCallback ( ( e : WheelEvent ) => {
6846 if ( e . deltaY >= 0 || hasUserScrolledRef . current ) return
69- // User scrolled up before any scroll event fired — detach immediately.
47+ // Upward wheel before any scroll event fires — mark detached immediately.
7048 hasUserScrolledRef . current = true
7149 stickyRef . current = false
7250 const el = containerRef . current
@@ -78,25 +56,20 @@ export function useScrollAnchor(isStreaming: boolean, content?: string) {
7856 if ( ! el ) return
7957
8058 if ( hasUserScrolledRef . current ) {
81- // Track their position so we can restore it after a content-shrink event.
8259 intendedScrollTopRef . current = el . scrollTop
8360 return
8461 }
8562
8663 const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight
8764 if ( distanceFromBottom > NEAR_BOTTOM_THRESHOLD ) {
88- // User scrolled away from the bottom.
8965 hasUserScrolledRef . current = true
9066 stickyRef . current = false
9167 intendedScrollTopRef . current = el . scrollTop
9268 } else {
93- // Re-engaged (scrolled back to bottom).
9469 stickyRef . current = true
9570 }
9671 } , [ ] )
9772
98- // ── container ref callback ───────────────────────────────────────────────
99-
10073 const callbackRef = useCallback (
10174 ( el : HTMLDivElement | null ) => {
10275 const prev = containerRef . current
@@ -113,26 +86,16 @@ export function useScrollAnchor(isStreaming: boolean, content?: string) {
11386 [ onScroll , onWheel ]
11487 )
11588
116- // ── stream-start: decide initial pin state ───────────────────────────────
117-
11889 useLayoutEffect ( ( ) => {
11990 if ( ! isStreaming ) return
12091 const el = containerRef . current
12192 if ( ! el ) return
122-
123- if ( hasUserScrolledRef . current ) {
124- // User has already scrolled — never override their position.
125- return
126- }
127-
128- // Fresh stream or user is at the bottom — engage sticky follow.
93+ if ( hasUserScrolledRef . current ) return
12994 const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight
13095 stickyRef . current = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD
13196 if ( stickyRef . current ) scrollToBottom ( )
13297 } , [ isStreaming , scrollToBottom ] )
13398
134- // ── sticky follow: MutationObserver while streaming ──────────────────────
135-
13699 useLayoutEffect ( ( ) => {
137100 if ( ! isStreaming ) return
138101 const el = containerRef . current
@@ -157,39 +120,20 @@ export function useScrollAnchor(isStreaming: boolean, content?: string) {
157120 }
158121 } , [ isStreaming , scrollToBottom ] )
159122
160- // ── scroll-clip prevention via spacer ───────────────────────────────────
161- //
162- // On every content update, if the user has scrolled:
163- // 1. Compute naturalScrollHeight (container height minus spacer contribution).
164- // 2. Compute the minimum scrollHeight needed to keep intendedScrollTop valid.
165- // 3. Set spacer.minHeight to fill any gap — this inflates scrollHeight before
166- // we read scrollTop, so the browser never clamps it to 0.
167- // 4. Restore scrollTop if it was already clipped (e.g. by prior content).
168- //
169- // When the user has NOT scrolled: clear the spacer so it doesn't interfere
170- // with auto-scroll bottom detection.
171-
172123 useLayoutEffect ( ( ) => {
173124 const el = containerRef . current
174125 const spacer = spacerRef . current
175126 if ( ! el ) return
176127
177- // Clear the spacer when the user hasn't scrolled (auto-follow mode) or when
178- // streaming has ended (content is stable; no more clip-prevention needed).
179128 if ( ! hasUserScrolledRef . current || ! isStreaming ) {
180129 if ( spacer ) spacer . style . minHeight = '0'
181130 return
182131 }
183132
184- // Capture the target BEFORE any layout read. Reading layout properties
185- // (clientHeight, scrollHeight, offsetHeight) forces a browser reflow. If
186- // scrollHeight is already smaller than scrollTop + clientHeight, the browser
187- // clamps scrollTop to 0 during that reflow and dispatches a 'scroll' event
188- // synchronously. Our onScroll handler would then overwrite intendedScrollTopRef
189- // with 0, corrupting the restore. Saving to a local variable here avoids that.
133+ // Capture before any layout read: reading scrollHeight forces a reflow which can
134+ // synchronously fire 'scroll' and overwrite intendedScrollTopRef with the clamped value.
190135 const targetScrollTop = intendedScrollTopRef . current
191136
192- // Read spacer and scroll heights BEFORE mutating the spacer.
193137 const prevSpacerHeight = spacer ? spacer . offsetHeight : 0
194138 const shortage = computeSpacerShortage (
195139 targetScrollTop ,
@@ -198,13 +142,8 @@ export function useScrollAnchor(isStreaming: boolean, content?: string) {
198142 prevSpacerHeight
199143 )
200144
201- // Inflate spacer so scrollHeight >= needed, preventing scrollTop clamping.
202145 if ( spacer ) spacer . style . minHeight = `${ shortage } px`
203-
204- // Restore scroll position (now valid because spacer ensures enough height).
205- if ( el . scrollTop < targetScrollTop ) {
206- el . scrollTop = targetScrollTop
207- }
146+ if ( el . scrollTop < targetScrollTop ) el . scrollTop = targetScrollTop
208147 } , [ content , isStreaming ] )
209148
210149 return {
0 commit comments