@@ -17,6 +17,7 @@ import { TextShimmer } from "@/components/ui/text-shimmer";
1717import {
1818 BOTTOM_LOCK_THRESHOLD_PX ,
1919 USER_SCROLL_INTENT_WINDOW_MS ,
20+ getTopScrollProgress ,
2021 isWithinBottomLockThreshold ,
2122 shouldUnlockBottomLock ,
2223} from "@/lib/chat-scroll" ;
@@ -82,8 +83,6 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
8283 onScrolledFromTopRef . current = onScrolledFromTop ;
8384 const onTopScrollProgressRef = useRef ( onTopScrollProgress ) ;
8485 onTopScrollProgressRef . current = onTopScrollProgress ;
85- const topProgressRafRef = useRef < number | null > ( null ) ;
86- const pendingTopProgressRef = useRef ( 0 ) ;
8786 const lastTopProgressRef = useRef ( - 1 ) ;
8887 const prependAnchorRef = useRef < { scrollHeight : number ; scrollTop : number } | null > ( null ) ;
8988 const lastSessionIdRef = useRef < string | undefined | null > ( sessionId ) ;
@@ -94,6 +93,9 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
9493 ) ;
9594
9695 const getViewport = useCallback ( ( ) => {
96+ if ( viewportRef . current && ! viewportRef . current . isConnected ) {
97+ viewportRef . current = null ;
98+ }
9799 if ( ! viewportRef . current ) {
98100 viewportRef . current = scrollAreaRef . current ?. querySelector < HTMLElement > (
99101 "[data-radix-scroll-area-viewport]" ,
@@ -167,6 +169,22 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
167169 } ;
168170 } , [ expandRenderedHistory , messages . length , visibleStartIndex ] ) ;
169171
172+ const publishTopProgress = useCallback ( ( progress : number ) => {
173+ const clamped = Math . max ( 0 , Math . min ( 1 , progress ) ) ;
174+ const last = lastTopProgressRef . current ;
175+ if ( last < 0 || Math . abs ( clamped - last ) >= 0.01 || clamped === 0 || clamped === 1 ) {
176+ lastTopProgressRef . current = clamped ;
177+ onTopScrollProgressRef . current ?.( clamped ) ;
178+ }
179+ } , [ ] ) ;
180+
181+ const syncViewportState = useCallback ( ( viewport : HTMLElement ) => {
182+ const { scrollTop, scrollHeight, clientHeight } = viewport ;
183+ onScrolledFromTopRef . current ?.( scrollTop > 4 ) ;
184+ publishTopProgress ( getTopScrollProgress ( scrollTop ) ) ;
185+ return { scrollTop, scrollHeight, clientHeight } ;
186+ } , [ publishTopProgress ] ) ;
187+
170188 useLayoutEffect ( ( ) => {
171189 const anchor = prependAnchorRef . current ;
172190 if ( ! anchor ) return ;
@@ -176,8 +194,9 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
176194
177195 const delta = viewport . scrollHeight - anchor . scrollHeight ;
178196 viewport . scrollTop = anchor . scrollTop + delta ;
197+ syncViewportState ( viewport ) ;
179198 prependAnchorRef . current = null ;
180- } , [ getViewport , visibleStartIndex ] ) ;
199+ } , [ getViewport , syncViewportState , visibleStartIndex ] ) ;
181200
182201 const visibleMessages = useMemo (
183202 ( ) => visibleStartIndex === 0 ? messages : messages . slice ( visibleStartIndex ) ,
@@ -196,6 +215,7 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
196215 if ( shouldForce || Math . abs ( targetViewport . scrollTop - targetScrollTop ) > 1 ) {
197216 targetViewport . scrollTop = targetScrollTop ;
198217 }
218+ syncViewportState ( targetViewport ) ;
199219 } ;
200220
201221 suppressScrollTrackingRef . current += 1 ;
@@ -205,7 +225,7 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
205225 if ( nextViewport ) applyBottom ( nextViewport ) ;
206226 suppressScrollTrackingRef . current = Math . max ( 0 , suppressScrollTrackingRef . current - 1 ) ;
207227 } ) ;
208- } , [ getViewport ] ) ;
228+ } , [ getViewport , syncViewportState ] ) ;
209229
210230 const clearSettleTimers = useCallback ( ( ) => {
211231 if ( settleRafRef . current !== null ) {
@@ -229,32 +249,21 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
229249 scheduleSettleToBottom ( ) ;
230250 } , [ messages . length , isProcessing , scheduleSettleToBottom ] ) ;
231251
252+ useLayoutEffect ( ( ) => {
253+ if ( ! sessionId ) return ;
254+ bottomLockedRef . current = true ;
255+ userScrollIntentUntilRef . current = 0 ;
256+ lastTopProgressRef . current = - 1 ;
257+ jumpToBottom ( { force : true } ) ;
258+ } , [ jumpToBottom , sessionId ] ) ;
259+
232260 // Track whether user is near the bottom; this drives sticky auto-follow behavior.
233261 useEffect ( ( ) => {
234262 const viewport = getViewport ( ) ;
235263 if ( ! viewport ) return ;
236264
237- const flushTopProgress = ( ) => {
238- topProgressRafRef . current = null ;
239- const progress = pendingTopProgressRef . current ;
240- const last = lastTopProgressRef . current ;
241- if ( last < 0 || Math . abs ( progress - last ) >= 0.01 || progress === 0 || progress === 1 ) {
242- lastTopProgressRef . current = progress ;
243- onTopScrollProgressRef . current ?.( progress ) ;
244- }
245- } ;
246-
247265 const updateAutoFollow = ( ) => {
248- const { scrollTop, scrollHeight, clientHeight } = viewport ;
249- // Always report scroll-from-top state (controls header shadow visibility)
250- onScrolledFromTopRef . current ?.( scrollTop > 4 ) ;
251- // Smooth top ramp: slower range + smoothstep easing to avoid abrupt header/fade jumps.
252- const normalized = Math . max ( 0 , Math . min ( 1 , scrollTop / 96 ) ) ;
253- const easedProgress = normalized * normalized * ( 3 - 2 * normalized ) ;
254- pendingTopProgressRef . current = easedProgress ;
255- if ( topProgressRafRef . current === null ) {
256- topProgressRafRef . current = window . requestAnimationFrame ( flushTopProgress ) ;
257- }
266+ const { scrollTop, scrollHeight, clientHeight } = syncViewportState ( viewport ) ;
258267 // Auto-follow tracking is suppressed during programmatic scrolls to
259268 // prevent them from unlocking sticky follow mode
260269 if ( suppressScrollTrackingRef . current > 0 ) {
@@ -322,14 +331,11 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
322331 viewport . removeEventListener ( "pointerdown" , markUserScrollIntent ) ;
323332 viewport . removeEventListener ( "keydown" , handleKeydown ) ;
324333 viewport . removeEventListener ( "scroll" , throttledUpdateAutoFollow ) ;
325- if ( topProgressRafRef . current !== null ) {
326- window . cancelAnimationFrame ( topProgressRafRef . current ) ;
327- topProgressRafRef . current = null ;
328- }
329334 } ;
330- } , [ messages . length , clearSettleTimers , expandRenderedHistory , getViewport , visibleStartIndex ] ) ;
335+ } , [ messages . length , clearSettleTimers , expandRenderedHistory , getViewport , syncViewportState , visibleStartIndex ] ) ;
331336
332- // Force-scroll to bottom on session switch, bypassing the proximity guard
337+ // Force-scroll to bottom again after session changes so late layout shifts
338+ // still settle at the bottom even after the immediate layout pass above.
333339 useEffect ( ( ) => {
334340 if ( ! sessionId ) return ;
335341 bottomLockedRef . current = true ;
0 commit comments