From 715c33b83cde2f276e71da205adb0162d3b4a329 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 31 Aug 2026 00:42:37 +0300 Subject: [PATCH] fix: preserve chat timeline pinning during width resize Keeps idle readers from snapping back to the end while rows re-wrap Releases the pin instead of scrolling if a resize moves an idle view off the end Re-asserts the live edge after resize settle for active streaming sessions --- .../ui/src/components/chat/MessageList.tsx | 10 ++++-- .../ui/src/hooks/useChatTimelineScroll.ts | 36 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 07ce00a3..20f5c8bc 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1014,10 +1014,13 @@ const TimelineList = React.memo(({ lastWidth = width; setIsWidthResizing(true); if (quietTimer !== null) clearTimeout(quietTimer); + // Released after the owning hook's 350ms settle decision — while a + // pin release is still pending, re-enabled end maintenance would + // snap the viewport back before the hook can let it go. quietTimer = setTimeout(() => { quietTimer = null; setIsWidthResizing(false); - }, 300); + }, 400); }); observer.observe(node); return () => { @@ -1067,8 +1070,9 @@ const TimelineList = React.memo(({ // live edge — defines where the viewport rests. // 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. + // viewport; once the resize settles the owning hook + // re-asserts the end for a streaming session and releases + // the pin for an idle one. maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased ? false // Animated only while the session actively streams: there diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 0fb88e51..5ca016e2 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -581,10 +581,10 @@ export const useChatTimelineScroll = ({ // list's size compensation instead. Deliberately NO snap back to the end // afterwards for a mid-conversation reader: a slow drag settles // repeatedly, and each snap reads as the very jump this suspension - // removes. A reader who WAS at the end is the exception — after rows - // re-wrap, stale cached sizes can leave a large phantom gap below the - // last row, so re-asserting the end once on settle is what "staying - // where the reader is" means for them. + // removes. A reader pinned to a STREAMING session is the exception — the + // live edge is what they are watching, so the end is re-asserted once on + // settle. A pinned reader of an idle session gets no scroll at all: if the + // re-wrap moved the viewport off the end, the pin is released instead. const widthResizingRef = React.useRef(false); React.useEffect(() => { if (!scrollNode || typeof ResizeObserver === 'undefined') return; @@ -604,7 +604,27 @@ export const useChatTimelineScroll = ({ quietTimer = setTimeout(() => { quietTimer = null; widthResizingRef.current = false; - if (isAtEndRef.current && pendingAnchorRef.current === null) { + if (!isAtEndRef.current || pendingAnchorRef.current !== null) return; + if (!sessionIsWorkingRef.current) { + // An idle pinned reader asked for nothing — a width change + // must not scroll them. If the re-wrap left the viewport + // off the end, release the pin instead of snapping back; + // the scroll-to-bottom pill offers the way home. + const listState = listRef.current?.getState(); + const atEndNow = listState ? resolveTimelineIsAtEnd(listState) : undefined; + if (atEndNow === false) { + isAtEndRef.current = false; + setIsPinned(false); + modeRef.current = 'free-scrolling'; + liveFollowGenerationRef.current = null; + scheduleShowScrollButton(); + queueSave(); + } + return; + } + { + // A streaming session keeps its live edge in view, so the + // end is re-asserted once on settle. // Not scrollToEnd: the list's end offset comes from the // total content length, which still carries pre-wrap row // sizes (and any reserved anchored end space) right after a @@ -632,7 +652,7 @@ export const useChatTimelineScroll = ({ observer.disconnect(); if (quietTimer !== null) clearTimeout(quietTimer); }; - }, [scrollNode]); + }, [queueSave, scheduleShowScrollButton, scrollNode]); // Keep the live edge in view after content growth. Within a viewport of // the end the remaining distance is glided so a revealed block and the @@ -920,6 +940,10 @@ export const useChatTimelineScroll = ({ if (!content) return; const pin = () => { if (sessionIsWorkingRef.current) return; + // A width resize re-wraps every row; pinning against each mutation + // scrolls the idle reader around. The resize settle handler above + // decides whether the pin survives the resize. + if (widthResizingRef.current) return; if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return; const end = scrollNode.scrollHeight - scrollNode.clientHeight; if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;