From 44de2204424a87ee4f46c62dbb68812c5231ce72 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 15:33:03 +0300 Subject: [PATCH] fix(chat): compensate row size changes while the list width resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A width change re-wraps every row at once, and with size restoration off the accumulated height delta above the viewport threw the visible content hundreds of pixels up and down while dragging the window edge. Size restoration must stay off in steady state — a tool result expanding in place has to grow downward — so a ResizeObserver on the scroll node enables maintainVisibleContentPosition size compensation only while the width is actively changing and releases it 300ms after it settles. Measured on a continuous 1400-800-1400 drag over a long session pinned to the live edge: the largest per-step displacement of a visible paragraph drops from ~680px (median of three runs) to ~200px, and the viewport never detaches from the end. --- .../ui/src/components/chat/MessageList.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 1a8ea34e..834bf4f7 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -987,6 +987,41 @@ const TimelineList = React.memo(({ registerList(list); }, [registerList]); + // A width change re-wraps every row, so all content above the viewport + // changes height at once; without size compensation the accumulated delta + // throws the read position around. Size restoration stays off otherwise — + // rows growing in place (a tool result expanding) must grow downward — + // so compensation is enabled only while the list width is actively + // resizing, and released shortly after it settles. + const [isWidthResizing, setIsWidthResizing] = React.useState(false); + React.useEffect(() => { + const node = listRef.current?.getScrollableNode(); + if (!node) 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; + setIsWidthResizing(true); + if (quietTimer !== null) clearTimeout(quietTimer); + quietTimer = setTimeout(() => { + quietTimer = null; + setIsWidthResizing(false); + }, 300); + }); + observer.observe(node); + return () => { + observer.disconnect(); + if (quietTimer !== null) clearTimeout(quietTimer); + }; + }, []); + // The list reports scroll continuously; only end-crossings are interesting, // so the edge is debounced to a state transition here rather than pushing a // callback on every frame. @@ -1030,9 +1065,9 @@ const TimelineList = React.memo(({ ? false : { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }} // Prepending older history must not move what the user is - // reading. Size restoration stays off: rows growing in place - // (a tool result expanding) must grow downward. - maintainVisibleContentPosition={{ data: true, size: false }} + // reading. Size restoration applies only during a width + // resize — see the observer above. + maintainVisibleContentPosition={{ data: true, size: isWidthResizing }} onScroll={handleScroll} ListHeaderComponent={header} ListFooterComponent={footer}