From db3558f906fb6c5fab1a463504ef47d50bd5ddf4 Mon Sep 17 00:00:00 2001 From: Tom <42646381+panzeyu2013@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:31:32 +0800 Subject: [PATCH] fix: prevent blank chat viewport when switching sessions (#1553) When switching to a session with long context (especially in Electron desktop when changing servers), the chat viewport could render blank until the user scrolled. Two interacting issues caused this: 1. historyVirtualRows memo never recomputed after the first render because historyVirtualizer (from useVirtualizer's useState) is a stable reference. Frozen range meant items rendered at the top while paddingBottom filled the visible viewport after scrolling. 2. pendingInitialRestoreRef replay ran in useEffect (after paint), showing a frame at scrollTop:0 with the stale virtualizer range. Fixed by: adding a virtualVersion counter driven by useVirtualizer's onChange to bust the memo; switching the replay to useLayoutEffect so scroll position is set before the browser paints. --- packages/ui/src/components/chat/MessageList.tsx | 5 ++++- packages/ui/src/hooks/useChatAutoFollow.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index cc3d58fa..755a61fd 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1372,6 +1372,8 @@ const MessageList = React.forwardRef(({ scrollEl.scrollTop += prependedHeight; }); + const [virtualVersion, bumpVirtualVersion] = React.useReducer((v: number) => v + 1, 0); + const historyVirtualizer = useVirtualizer({ count: historyEntries.length, getScrollElement: resolveScrollContainer, @@ -1381,6 +1383,7 @@ const MessageList = React.forwardRef(({ useAnimationFrameWithResizeObserver: true, overscan: MESSAGE_LIST_OVERSCAN, enabled: shouldVirtualizeHistory, + onChange: bumpVirtualVersion, }); React.useLayoutEffect(() => { @@ -1437,7 +1440,7 @@ const MessageList = React.forwardRef(({ const historyVirtualRows = React.useMemo( () => (shouldVirtualizeHistory ? historyVirtualizer.getVirtualItems() : EMPTY_VIRTUAL_ROWS), - [historyVirtualizer, shouldVirtualizeHistory], + [historyVirtualizer, shouldVirtualizeHistory, virtualVersion], ); const allEntries = React.useMemo(() => { diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 59c281bd..7b37e272 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -411,7 +411,9 @@ export const useChatAutoFollow = ({ }, [sessionIsWorking, startFollowLoop]); // Replay a deferred restoreSnapshot once ChatViewport mounts. - React.useEffect(() => { + // useLayoutEffect ensures scroll position is set before the browser paints, + // preventing a visible flash of content at the wrong scroll position. + React.useLayoutEffect(() => { if (!containerEl) return; if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionId) { void restoreSnapshot();