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
This commit is contained in:
Bohdan Triapitsyn
2026-08-31 00:42:37 +03:00
parent 8372285718
commit 715c33b83c
2 changed files with 37 additions and 9 deletions
@@ -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
+30 -6
View File
@@ -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;