Skip to content

Commit 187011e

Browse files
committed
fix(flow-chat): prevent white screen on input collapse and blank viewport on streaming switch
1 parent f4e713e commit 187011e

1 file changed

Lines changed: 90 additions & 2 deletions

File tree

src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)