From 5c39db0e36515a86b79c89ddd23ab7597d0c3fa4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 15:54:39 +0300 Subject: [PATCH] fix(chat): release end pinning while the list width resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pinned viewport shook during window resizes while a scrolled-up one stayed calm: both pinning mechanisms — the growth corrections and the list's maintain-scroll-at-end — re-assert the end against rows that are still re-measuring, fighting the size compensation that keeps the free case stable. While the width is actively changing, stand both down and hold the reading position the same way the free path does, then re-assert the live edge once with a single instant write after the resize settles (only when the viewport was still following). --- .../ui/src/components/chat/MessageList.tsx | 6 ++- .../ui/src/hooks/useChatTimelineScroll.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) 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;