fix(chat): animate end following only during a live stream
The animated maintainScrollAtEnd also ran outside streaming, so opening a historical session glided visibly through the whole conversation as late row measurements corrected the end position, and an in-flight glide could supersede explicit navigation. Corrections are instant unless the session is actively working. Returning to the bottom during a stream also lands on the end as of that moment, and the list's own follow may not have re-armed after the user's earlier gestures — the queued-send jump then fell behind the growing reply. goToBottom now re-asserts the edge a few times (150/400/800ms) until it holds; a new user gesture cancels the window.
This commit is contained in:
@@ -1071,10 +1071,17 @@ const TimelineList = React.memo(({
|
|||||||
// resize settles.
|
// resize settles.
|
||||||
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
||||||
? false
|
? false
|
||||||
// Animated: with block-level reveal the content grows in
|
// Animated only while the session actively streams: there
|
||||||
// paragraph/line steps, and the animated follow turns each
|
// the block-step growth turns each correction into a glide
|
||||||
// step into a glide — reveal and scroll read as one motion.
|
// and reveal + scroll read as one motion. Outside of a live
|
||||||
: { animated: true, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }}
|
// stream — opening a historical session, late measurements —
|
||||||
|
// corrections must be instant: an animated catch-up scrolls
|
||||||
|
// visibly through the whole conversation on open, and an
|
||||||
|
// in-flight glide can supersede explicit navigation.
|
||||||
|
: {
|
||||||
|
animated: rowContext.sessionIsWorking,
|
||||||
|
on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
|
||||||
|
}}
|
||||||
// Prepending older history must not move what the user is
|
// Prepending older history must not move what the user is
|
||||||
// reading. Size restoration applies only during a width
|
// reading. Size restoration applies only during a width
|
||||||
// resize — see the observer above.
|
// resize — see the observer above.
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ export const useChatTimelineScroll = ({
|
|||||||
}, [flushSave]);
|
}, [flushSave]);
|
||||||
|
|
||||||
// ── scroll commands ─────────────────────────────────────────────────────
|
// ── scroll commands ─────────────────────────────────────────────────────
|
||||||
|
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
|
const clearGoToBottomReasserts = React.useCallback(() => {
|
||||||
|
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
|
||||||
|
goToBottomReassertTimersRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
setIsPinned(true);
|
setIsPinned(true);
|
||||||
@@ -291,7 +297,22 @@ export const useChatTimelineScroll = ({
|
|||||||
clearAnchor();
|
clearAnchor();
|
||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
||||||
}, [clearAnchor, hideScrollButton]);
|
// While a stream is growing the content, a single jump lands on the
|
||||||
|
// end as of that moment and the list's own follow may not have
|
||||||
|
// re-armed yet — re-assert a few times until the edge holds, then the
|
||||||
|
// library follows onward. A new user gesture invalidates the window.
|
||||||
|
clearGoToBottomReasserts();
|
||||||
|
const generation = userGenerationRef.current;
|
||||||
|
for (const delay of [150, 400, 800]) {
|
||||||
|
goToBottomReassertTimersRef.current.push(setTimeout(() => {
|
||||||
|
if (userGenerationRef.current !== generation) return;
|
||||||
|
if (modeRef.current !== 'following-end') return;
|
||||||
|
const state = listRef.current?.getState();
|
||||||
|
if (state && resolveTimelineIsAtEnd(state) === true) return;
|
||||||
|
void listRef.current?.scrollToEnd({ animated: false });
|
||||||
|
}, delay));
|
||||||
|
}
|
||||||
|
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
|
||||||
|
|
||||||
// Sending arms the anchor. The message id is not known here (the optimistic
|
// Sending arms the anchor. The message id is not known here (the optimistic
|
||||||
// row is created by the store), so the next new user message id claims it.
|
// row is created by the store), so the next new user message id claims it.
|
||||||
|
|||||||
Reference in New Issue
Block a user