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:
@@ -1014,10 +1014,13 @@ const TimelineList = React.memo(({
|
|||||||
lastWidth = width;
|
lastWidth = width;
|
||||||
setIsWidthResizing(true);
|
setIsWidthResizing(true);
|
||||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
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 = setTimeout(() => {
|
||||||
quietTimer = null;
|
quietTimer = null;
|
||||||
setIsWidthResizing(false);
|
setIsWidthResizing(false);
|
||||||
}, 300);
|
}, 400);
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1067,8 +1070,9 @@ const TimelineList = React.memo(({
|
|||||||
// live edge — defines where the viewport rests.
|
// live edge — defines where the viewport rests.
|
||||||
// Also released while the width resizes: re-pinning against
|
// Also released while the width resizes: re-pinning against
|
||||||
// rows that are still re-measuring shakes the pinned
|
// rows that are still re-measuring shakes the pinned
|
||||||
// viewport; the owning hook re-asserts the end once the
|
// viewport; once the resize settles the owning hook
|
||||||
// resize settles.
|
// re-asserts the end for a streaming session and releases
|
||||||
|
// the pin for an idle one.
|
||||||
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
||||||
? false
|
? false
|
||||||
// Animated only while the session actively streams: there
|
// Animated only while the session actively streams: there
|
||||||
|
|||||||
@@ -581,10 +581,10 @@ export const useChatTimelineScroll = ({
|
|||||||
// list's size compensation instead. Deliberately NO snap back to the end
|
// list's size compensation instead. Deliberately NO snap back to the end
|
||||||
// afterwards for a mid-conversation reader: a slow drag settles
|
// afterwards for a mid-conversation reader: a slow drag settles
|
||||||
// repeatedly, and each snap reads as the very jump this suspension
|
// repeatedly, and each snap reads as the very jump this suspension
|
||||||
// removes. A reader who WAS at the end is the exception — after rows
|
// removes. A reader pinned to a STREAMING session is the exception — the
|
||||||
// re-wrap, stale cached sizes can leave a large phantom gap below the
|
// live edge is what they are watching, so the end is re-asserted once on
|
||||||
// last row, so re-asserting the end once on settle is what "staying
|
// settle. A pinned reader of an idle session gets no scroll at all: if the
|
||||||
// where the reader is" means for them.
|
// re-wrap moved the viewport off the end, the pin is released instead.
|
||||||
const widthResizingRef = React.useRef(false);
|
const widthResizingRef = React.useRef(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
||||||
@@ -604,7 +604,27 @@ export const useChatTimelineScroll = ({
|
|||||||
quietTimer = setTimeout(() => {
|
quietTimer = setTimeout(() => {
|
||||||
quietTimer = null;
|
quietTimer = null;
|
||||||
widthResizingRef.current = false;
|
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
|
// Not scrollToEnd: the list's end offset comes from the
|
||||||
// total content length, which still carries pre-wrap row
|
// total content length, which still carries pre-wrap row
|
||||||
// sizes (and any reserved anchored end space) right after a
|
// sizes (and any reserved anchored end space) right after a
|
||||||
@@ -632,7 +652,7 @@ export const useChatTimelineScroll = ({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||||
};
|
};
|
||||||
}, [scrollNode]);
|
}, [queueSave, scheduleShowScrollButton, scrollNode]);
|
||||||
|
|
||||||
// Keep the live edge in view after content growth. Within a viewport of
|
// 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
|
// the end the remaining distance is glided so a revealed block and the
|
||||||
@@ -920,6 +940,10 @@ export const useChatTimelineScroll = ({
|
|||||||
if (!content) return;
|
if (!content) return;
|
||||||
const pin = () => {
|
const pin = () => {
|
||||||
if (sessionIsWorkingRef.current) return;
|
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;
|
if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return;
|
||||||
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
||||||
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
||||||
|
|||||||
Reference in New Issue
Block a user