fix(chat): keep a pinned reader on the end through panel and window resizes
Opening a panel or resizing the window while pinned to the end of an idle session bounced the timeline and left it above the end with the pin released. Every row re-wraps on a width change and the list's total content length lags a frame behind the rows, so anything scrolling to the end from it (the list's own maintainScrollAtEnd, scrollHeight) landed on a blank tail or short of the real end; the idle branch then released the pin instead of recovering. An idle session no longer hands end-keeping to the list at all: the hook's same-frame pin owns it, and during a width resize it holds the measured bottom of the last real row plus the list footer, for streaming readers too. The footer size was missing from that measurement (the list does not expose it through getState) and left the viewport short by the tail spacer; it now arrives through onMetricsChange. Size compensation applies only to a reader who left the end. The follow re-arm band is half a viewport instead of 40px: leaving the end is decided by a real gesture only, so the band now only decides how close a reader must come back before follow re-arms. Testing: scroll module tests updated (footer in the real end, half-viewport band with a 40px floor); ui type-check and lint; measured in headless Chrome that scrollTop equals the maximum on every frame of a panel toggle, a height change and a combined resize, where the previous build ended 122px and 462px above the end.
This commit is contained in:
@@ -175,6 +175,7 @@ type ChatViewportProps = {
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
renderedMessages: SessionMessageRecord[];
|
||||
isLoadingOlder: boolean;
|
||||
@@ -219,6 +220,7 @@ const ChatViewport = React.memo(({
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onTimelineDataChange,
|
||||
renderedMessages,
|
||||
isLoadingOlder,
|
||||
@@ -507,6 +509,7 @@ const ChatViewport = React.memo(({
|
||||
// again produced a double-tall blank band at rest.
|
||||
composerOverlayHeight={0}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onListMetricsChange={onListMetricsChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
@@ -543,6 +546,7 @@ const ChatViewport = React.memo(({
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.onListMetricsChange === next.onListMetricsChange
|
||||
&& prev.endPinningReleased === next.endPinningReleased
|
||||
&& prev.revealWaited === next.revealWaited
|
||||
&& prev.revealGate === next.revealGate
|
||||
@@ -1124,6 +1128,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
goToBottom,
|
||||
@@ -1553,6 +1558,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onListMetricsChange={onListMetricsChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
messageListRef={messageListRef}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
|
||||
@@ -331,6 +331,7 @@ interface MessageListProps {
|
||||
onAnchorSizeChanged?: (messageId: string) => void;
|
||||
composerOverlayHeight?: number;
|
||||
onIsAtEndChange?: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
|
||||
onTimelineDataChange?: () => void;
|
||||
// Content that used to sit as siblings of the list inside the scroll
|
||||
// container. The list owns that container now, so they render as its
|
||||
@@ -959,6 +960,7 @@ type TimelineListProps = {
|
||||
};
|
||||
composerOverlayHeight: number;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
listHeader?: React.ReactNode;
|
||||
listFooter?: React.ReactNode;
|
||||
@@ -973,6 +975,7 @@ const TimelineList = React.memo(({
|
||||
anchoredEndSpace,
|
||||
composerOverlayHeight,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onTimelineDataChange,
|
||||
listHeader,
|
||||
listFooter,
|
||||
@@ -1068,29 +1071,30 @@ const TimelineList = React.memo(({
|
||||
contentInsetEndAdjustment={composerOverlayHeight}
|
||||
// While a turn is anchored, the reserved end space — not the
|
||||
// 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; 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
|
||||
// Live only while the session streams: outside a stream the
|
||||
// owning hook keeps a pinned reader on the end with same-frame
|
||||
// writes, and the list's own correction runs a frame later
|
||||
// against a content length that can still be stale (a
|
||||
// re-wrap, a late measurement) — that is the visible bounce
|
||||
// an idle reader saw on every panel toggle. Also off while the
|
||||
// width resizes, where the hook holds the measured end itself.
|
||||
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased
|
||||
? false
|
||||
// Animated only while the session actively streams: there
|
||||
// the block-step growth turns each correction into a glide
|
||||
// and reveal + scroll read as one motion. Outside of a live
|
||||
// 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: the block-step growth turns each correction
|
||||
// into a glide and reveal + scroll read as one motion.
|
||||
: {
|
||||
animated: rowContext.sessionIsWorking,
|
||||
animated: true,
|
||||
on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
|
||||
}}
|
||||
// Prepending older history must not move what the user is
|
||||
// reading. Size restoration applies only during a width
|
||||
// resize — see the observer above.
|
||||
maintainVisibleContentPosition={{ data: true, size: isWidthResizing }}
|
||||
// resize (see the observer above) and only for a reader who
|
||||
// left the end: a pinned reader is held on the end by the
|
||||
// owning hook, and compensating the rows above them would pull
|
||||
// the viewport away from it.
|
||||
maintainVisibleContentPosition={{ data: true, size: isWidthResizing && endPinningReleased }}
|
||||
onScroll={handleScroll}
|
||||
onMetricsChange={onListMetricsChange}
|
||||
ListHeaderComponent={header}
|
||||
ListFooterComponent={footer}
|
||||
{...scrollContainerProps}
|
||||
@@ -1188,6 +1192,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
onAnchorSizeChanged,
|
||||
composerOverlayHeight = 0,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onTimelineDataChange,
|
||||
listHeader,
|
||||
listFooter,
|
||||
@@ -1435,6 +1440,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
onTimelineDataChange?.();
|
||||
});
|
||||
|
||||
const stableListMetricsChange = useStableEvent((metrics: { readonly footerSize: number }) => {
|
||||
onListMetricsChange?.(metrics);
|
||||
});
|
||||
|
||||
const currentUserOrder = React.useMemo(() => {
|
||||
return messages
|
||||
.filter((message) => resolveMessageRole(message) === 'user')
|
||||
@@ -1862,6 +1871,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
anchoredEndSpace={anchoredEndSpace}
|
||||
composerOverlayHeight={composerOverlayHeight}
|
||||
onIsAtEndChange={stableIsAtEndChange}
|
||||
onListMetricsChange={stableListMetricsChange}
|
||||
onTimelineDataChange={stableTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
|
||||
@@ -223,6 +223,16 @@ describe('resolveRealContentEndOffset', () => {
|
||||
})).toBe(696);
|
||||
});
|
||||
|
||||
test('counts the footer rendered after the last row as real content', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
sizes: [1000, 200],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180, footerSize: 120 })).toBe(800);
|
||||
});
|
||||
|
||||
test('returns null for an empty timeline and for unmeasured last rows', () => {
|
||||
expect(resolveRealContentEndOffset({
|
||||
state: buildState({ positions: [], sizes: [] }),
|
||||
@@ -237,10 +247,13 @@ describe('resolveRealContentEndOffset', () => {
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
test('counts half a viewport from the full content length as the end', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1099, scrollLength: 600 })).toBe(false);
|
||||
// Tiny viewports keep a 40px floor.
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to the list flags when distances are unavailable', () => {
|
||||
|
||||
@@ -115,13 +115,19 @@ export const getAnchoredTurnMetrics = ({
|
||||
// have not been re-measured yet. Scrolling to it then lands below the real
|
||||
// content and leaves a blank tail. `extraInset` reserves additional slack
|
||||
// below the content when a caller wants the row to sit clear of the edge.
|
||||
// The list footer (question and permission cards, error notices, the tail
|
||||
// spacer) renders after the last row and is part of the real content, unlike
|
||||
// reserved anchored end space; the list does not expose its size through
|
||||
// getState, so the caller passes the last reported value.
|
||||
export const resolveRealContentEndOffset = ({
|
||||
state,
|
||||
composerOverlayHeight,
|
||||
footerSize = 0,
|
||||
extraInset = 0,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly footerSize?: number;
|
||||
readonly extraInset?: number;
|
||||
}): number | null => {
|
||||
const lastIndex = state.data.length - 1;
|
||||
@@ -129,16 +135,21 @@ export const resolveRealContentEndOffset = ({
|
||||
const lastBottom = getRowBottom(state, lastIndex);
|
||||
if (lastBottom === null) return null;
|
||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
|
||||
return Math.max(0, lastBottom - visibleLength);
|
||||
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
// next stream chunk. Distance is measured against the full content length —
|
||||
// reserved anchored end space included — so a parked anchored turn counts as
|
||||
// the live edge.
|
||||
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||
// "At the end" for follow purposes is half a viewport. Leaving the end is
|
||||
// only ever decided by a real gesture, so this band never yanks a reader who
|
||||
// is still on the end; what it decides is how close to the live edge a reader
|
||||
// who scrolled away must come back before follow re-arms and the pill hides.
|
||||
// Half a screen reads as "I am back at the bottom" without having to land on
|
||||
// the last pixel, and stray row growth or late measurements cannot push a
|
||||
// pinned reader out of it. Distance is measured against the full content
|
||||
// length — reserved anchored end space included — so a parked anchored turn
|
||||
// counts as the live edge.
|
||||
const FOLLOW_REARM_MIN_THRESHOLD_PX = 40;
|
||||
export const resolveFollowRearmThresholdPx = (scrollLength: number): number =>
|
||||
Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2);
|
||||
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: {
|
||||
@@ -157,7 +168,7 @@ export const resolveTimelineIsAtEnd = (
|
||||
&& typeof scrollLength === 'number'
|
||||
&& Number.isFinite(contentLength)
|
||||
) {
|
||||
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||
return contentLength - (scroll + scrollLength) <= resolveFollowRearmThresholdPx(scrollLength);
|
||||
}
|
||||
return state.isNearEnd ?? state.isAtEnd;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getRowBottom,
|
||||
resolveRealContentEndOffset,
|
||||
resolveTimelineIsAtEnd,
|
||||
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
|
||||
resolveFollowRearmThresholdPx,
|
||||
type TimelineListMeasurementState,
|
||||
type TimelineScrollMode,
|
||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||
@@ -102,6 +102,7 @@ export interface UseChatTimelineScrollResult {
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||
onManualNavigation: () => void;
|
||||
onTimelineDataChange: () => void;
|
||||
showScrollButton: boolean;
|
||||
@@ -179,6 +180,12 @@ export const useChatTimelineScroll = ({
|
||||
|
||||
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
||||
composerOverlayHeightRef.current = composerOverlayHeight;
|
||||
// Size of the list footer, reported by the list as it is measured; the
|
||||
// real content end sits below the last row by this much.
|
||||
const listFooterSizeRef = React.useRef(0);
|
||||
const onListMetricsChange = React.useCallback((metrics: { readonly footerSize: number }) => {
|
||||
listFooterSizeRef.current = Number.isFinite(metrics.footerSize) ? metrics.footerSize : 0;
|
||||
}, []);
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
@@ -575,16 +582,17 @@ export const useChatTimelineScroll = ({
|
||||
first: null,
|
||||
second: null,
|
||||
});
|
||||
// While the list width is resizing, every pinning write fights the
|
||||
// per-frame row re-measure and the pinned viewport shakes. Corrections
|
||||
// stand down for the whole resize and the visible content is held by the
|
||||
// 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 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.
|
||||
// While the list width is resizing every row re-wraps, and the list's
|
||||
// total content length lags a frame behind the rows it contains: it
|
||||
// still carries pre-wrap row sizes, so any end computed from it (the
|
||||
// list's own maintainScrollAtEnd, the scroll node's scrollHeight) lands
|
||||
// on a blank tail or short of the real end and the viewport bounces.
|
||||
// A pinned reader — streaming or idle — stays on the end throughout: the
|
||||
// pinned-end observer below re-asserts the MEASURED end of the last real
|
||||
// row on every layout write, and once the resize settles the end is
|
||||
// asserted one last time against the same measurement. An unpinned
|
||||
// reader is held in place by the list's size compensation instead and
|
||||
// is never scrolled.
|
||||
const widthResizingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
||||
@@ -605,38 +613,14 @@ export const useChatTimelineScroll = ({
|
||||
quietTimer = null;
|
||||
widthResizingRef.current = false;
|
||||
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
|
||||
// width change. Landing there parks the last row near the
|
||||
// top of the viewport with a blank tail below it. Target
|
||||
// the measured bottom of the last real row instead.
|
||||
if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return;
|
||||
const list = listRef.current;
|
||||
const state = list?.getState();
|
||||
const offset = state
|
||||
? resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
footerSize: listFooterSizeRef.current,
|
||||
})
|
||||
: null;
|
||||
if (list && offset !== null) {
|
||||
@@ -644,7 +628,6 @@ export const useChatTimelineScroll = ({
|
||||
} else {
|
||||
void list?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
});
|
||||
observer.observe(scrollNode);
|
||||
@@ -652,7 +635,7 @@ export const useChatTimelineScroll = ({
|
||||
observer.disconnect();
|
||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||
};
|
||||
}, [queueSave, scheduleShowScrollButton, scrollNode]);
|
||||
}, [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
|
||||
@@ -699,6 +682,7 @@ export const useChatTimelineScroll = ({
|
||||
const offset = resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
footerSize: listFooterSizeRef.current,
|
||||
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
if (offset !== null) {
|
||||
@@ -723,7 +707,7 @@ export const useChatTimelineScroll = ({
|
||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||
if (lastBottom !== null) {
|
||||
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
||||
if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) {
|
||||
if (lastBottom - visibleBottom > resolveFollowRearmThresholdPx(state.scrollLength)) {
|
||||
isAtEndRef.current = false;
|
||||
setIsPinned(false);
|
||||
scheduleShowScrollButton();
|
||||
@@ -933,18 +917,33 @@ export const useChatTimelineScroll = ({
|
||||
// sits on the end of a session that is not producing output, any growth
|
||||
// of the content (a footer that decides to render, a row re-measured)
|
||||
// keeps the end in view with one instant write. Output growth belongs to
|
||||
// followEnd, which glides.
|
||||
// followEnd, which glides. A width resize is the one case handled for a
|
||||
// streaming reader as well — see the resize observer above.
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode || typeof MutationObserver === 'undefined') return;
|
||||
const content = scrollNode.firstElementChild;
|
||||
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;
|
||||
if (widthResizingRef.current) {
|
||||
// Re-wrapping rows: the scroll node's scrollHeight carries the
|
||||
// list's stale total, so the end is the measured bottom of the
|
||||
// last real row. Held for a streaming reader too — output
|
||||
// growth is not what moves the viewport during a resize.
|
||||
const state = listRef.current?.getState();
|
||||
const offset = state
|
||||
? resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
footerSize: listFooterSizeRef.current,
|
||||
})
|
||||
: null;
|
||||
if (offset !== null && Math.abs(offset - scrollNode.scrollTop) > 1) {
|
||||
scrollNode.scrollTop = offset;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sessionIsWorkingRef.current) return;
|
||||
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
||||
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
||||
};
|
||||
@@ -1079,6 +1078,7 @@ export const useChatTimelineScroll = ({
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
showScrollButton,
|
||||
|
||||
Reference in New Issue
Block a user