@@ -419,7 +419,19 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
419419 const inputHeight = useChatInputState ( state => state . inputHeight ) ;
420420
421421 const inputStackFooterPxRef = useRef ( 0 ) ;
422+ const previousInputStackFooterPxRef = useRef ( 0 ) ;
423+ // Snapshot scrollTop during render, before React commits the new (smaller)
424+ // footer height. The browser has not yet clamped, so this is the accurate
425+ // pre-clamp value. Used by the useLayoutEffect below to restore scrollTop
426+ // after footer-shrink compensation.
427+ const footerShrinkPreScrollTopRef = useRef ( 0 ) ;
422428 const inputStackFooterPx = computeFlowChatInputStackFooterPx ( inputHeight , isInputActive ) ;
429+ // Snapshot pre-commit scrollTop before we update the footer px ref.
430+ // The scrollerElement may not be set on first mount; that's fine —
431+ // useLayoutEffect checks scroller !== null before restoring.
432+ if ( scrollerElement ) {
433+ footerShrinkPreScrollTopRef . current = scrollerElement . scrollTop ;
434+ }
423435 inputStackFooterPxRef . current = inputStackFooterPx ;
424436
425437 const activeSessionState = useActiveSessionState ( ) ;
@@ -535,6 +547,62 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
535547 void scroller . scrollHeight ;
536548 } , [ getFooterHeightPx , getTotalBottomCompensationPx ] ) ;
537549
550+ // When the input collapses (e.g. user sends a message), the ResizeObserver
551+ // fires synchronously, `inputHeight` drops, and `inputStackFooterPx` shrinks.
552+ // React commits the smaller footer height, the browser clamps scrollTop
553+ // upward, and the viewport shows blank space ("white screen"). The existing
554+ // scroll-clamp protection in handleScroll is gated on isFollowingOutput
555+ // which is false at this point (follow has been armed, not yet activated).
556+ //
557+ // Detect the footer shrink in a useLayoutEffect (fires synchronously after
558+ // commit, before paint) and extend the bottom collapse reservation by the
559+ // shrink amount so the total footer height stays unchanged. The reservation
560+ // is later consumed organically by the grow branch of measureHeightChange as
561+ // streaming content arrives.
562+ useLayoutEffect ( ( ) => {
563+ const prevFooterPx = previousInputStackFooterPxRef . current ;
564+ const currFooterPx = inputStackFooterPx ;
565+ previousInputStackFooterPxRef . current = currFooterPx ;
566+
567+ const shrinkAmount = prevFooterPx - currFooterPx ;
568+ if ( shrinkAmount <= COMPENSATION_EPSILON_PX ) {
569+ return ;
570+ }
571+
572+ const scroller = scrollerElementRef . current ;
573+ // Use the pre-commit scrollTop captured during render (before React
574+ // applied the smaller footer height and the browser clamped).
575+ // This is the only reliable source of the pre-clamp value because
576+ // by the time useLayoutEffect runs, scroller.scrollTop has already
577+ // been reduced by the browser's clamp.
578+ const preClampScrollTop = footerShrinkPreScrollTopRef . current ;
579+
580+ const baseState = bottomReservationStateRef . current ;
581+ const currentCollapsePx = getReservationTotalPx ( baseState . collapse ) ;
582+ const nextReservationState : BottomReservationState = {
583+ ...baseState ,
584+ collapse : {
585+ ...baseState . collapse ,
586+ px : currentCollapsePx + shrinkAmount ,
587+ floorPx : 0 ,
588+ } ,
589+ } ;
590+ updateBottomReservationState ( nextReservationState ) ;
591+ applyFooterCompensationNow ( nextReservationState ) ;
592+
593+ // Restore scrollTop to the pre-clamp position if the browser already
594+ // clamped it during the commit. After extending the footer via
595+ // compensation, the total scrollHeight is back to its pre-shrink value,
596+ // so restoring the old scrollTop is safe.
597+ if ( scroller && preClampScrollTop !== undefined ) {
598+ const maxScrollTop = Math . max (
599+ 0 ,
600+ scroller . scrollHeight - scroller . clientHeight ,
601+ ) ;
602+ scroller . scrollTop = Math . min ( preClampScrollTop , maxScrollTop ) ;
603+ }
604+ } , [ inputStackFooterPx , updateBottomReservationState , applyFooterCompensationNow ] ) ;
605+
538606 const releaseAnchorLock = useCallback ( ( _reason : string ) => {
539607 if ( ! anchorLockRef . current . active ) return ;
540608 anchorLockRef . current = {
@@ -2412,7 +2480,11 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
24122480 schedulePinReservationReconcile ( 2 ) ;
24132481 scheduleTransientTurnPinStabilization ( 2 ) ;
24142482 scheduleFollowToLatestWithViewportState ( 'range-changed' ) ;
2415- scheduleHistoryProjectionHandoffRelease ( 1 ) ;
2483+ // Reset the handoff release timer rather than accelerating it.
2484+ // A session-open projection handoff needs the Virtuoso measurement
2485+ // to settle before releasing; calling with 3 resets the countdown
2486+ // so the handoff stays until 3 frames of stability pass.
2487+ scheduleHistoryProjectionHandoffRelease ( 3 ) ;
24162488 } , [
24172489 resolveLatestEndAnchorStabilization ,
24182490 scheduleFollowToLatestWithViewportState ,
@@ -2870,6 +2942,18 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
28702942 } ) ;
28712943 }
28722944
2945+ // When switching to a streaming session, arm follow output so the
2946+ // viewport tracks new content as it arrives. Without this, the
2947+ // Virtuoso stays at initialTopMostItemIndex (the user message),
2948+ // and if that position is not yet rendered or measured the
2949+ // viewport may show blank space.
2950+ if ( isStreamingOutput ) {
2951+ // Reset the one-shot streaming prime flag so arm-follow can
2952+ // fire again for the new session's latest turn.
2953+ hasPrimedMountedStreamingTurnFollowRef . current = false ;
2954+ armFollowOutputForNewTurn ( ) ;
2955+ }
2956+
28732957 return ;
28742958 }
28752959
@@ -3279,7 +3363,11 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
32793363 previousItemCount : 0 ,
32803364 nextItemCount : virtualItems . length ,
32813365 } ) ;
3282- scheduleHistoryProjectionHandoffRelease ( 2 ) ;
3366+ // Use a longer initial delay (5 frames). rangeChanged accelerations
3367+ // have been removed so the handoff is only released once the Virtuoso
3368+ // has had enough time to measure and position items, preventing a
3369+ // blank viewport ("white screen") on session switches.
3370+ scheduleHistoryProjectionHandoffRelease ( 5 ) ;
32833371 } , [
32843372 scheduleHistoryProjectionHandoffRelease ,
32853373 sessionOpenProjectionHandoff ,
0 commit comments