diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index f81f0e4e..04d73563 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1061,7 +1061,11 @@ const TimelineList = React.memo(({ contentInsetEndAdjustment={composerOverlayHeight} // While a turn is anchored, the reserved end space — not the // live edge — defines where the viewport rests. - maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled + // Also released while the width resizes: re-pinning against + // rows that are still re-measuring shakes the pinned + // viewport; the owning hook re-asserts the end once the + // resize settles. + maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing ? false : { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }} // Prepending older history must not move what the user is diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index b08deb47..974698c3 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -508,7 +508,45 @@ export const useChatTimelineScroll = ({ const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled); streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled; + // While the list width is resizing, every pinning write fights the + // per-frame row re-measure and the pinned viewport shakes. Corrections + // stand down for the whole resize (visible content is held by the list's + // size compensation instead), and the live edge is re-asserted once with + // a single instant write after the resize settles. + const widthResizingRef = React.useRef(false); + React.useEffect(() => { + if (!scrollNode || typeof ResizeObserver === 'undefined') return; + let lastWidth: number | null = null; + let quietTimer: ReturnType | null = null; + const observer = new ResizeObserver((observerEntries) => { + const width = observerEntries[observerEntries.length - 1]?.contentRect.width; + if (typeof width !== 'number') return; + if (lastWidth === null) { + lastWidth = width; + return; + } + if (Math.abs(width - lastWidth) < 1) return; + lastWidth = width; + widthResizingRef.current = true; + if (quietTimer !== null) clearTimeout(quietTimer); + quietTimer = setTimeout(() => { + quietTimer = null; + widthResizingRef.current = false; + if (modeRef.current !== 'following-end' || !isLiveFollowActive()) return; + const node = listRef.current?.getScrollableNode(); + if (!node) return; + node.scrollTop = node.scrollHeight + 4096; + }, 350); + }); + observer.observe(scrollNode); + return () => { + observer.disconnect(); + if (quietTimer !== null) clearTimeout(quietTimer); + }; + }, [isLiveFollowActive, scrollNode]); + const onTimelineDataChange = React.useCallback(() => { + if (widthResizingRef.current) return; if (!streamingAutoFollowEnabledRef.current) return; if (!isLiveFollowActive()) return;