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;
|
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||||
onAnchorSizeChanged: (messageId: string) => void;
|
onAnchorSizeChanged: (messageId: string) => void;
|
||||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||||
|
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||||
onTimelineDataChange: () => void;
|
onTimelineDataChange: () => void;
|
||||||
renderedMessages: SessionMessageRecord[];
|
renderedMessages: SessionMessageRecord[];
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
@@ -219,6 +220,7 @@ const ChatViewport = React.memo(({
|
|||||||
onAnchorReady,
|
onAnchorReady,
|
||||||
onAnchorSizeChanged,
|
onAnchorSizeChanged,
|
||||||
onIsAtEndChange,
|
onIsAtEndChange,
|
||||||
|
onListMetricsChange,
|
||||||
onTimelineDataChange,
|
onTimelineDataChange,
|
||||||
renderedMessages,
|
renderedMessages,
|
||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
@@ -507,6 +509,7 @@ const ChatViewport = React.memo(({
|
|||||||
// again produced a double-tall blank band at rest.
|
// again produced a double-tall blank band at rest.
|
||||||
composerOverlayHeight={0}
|
composerOverlayHeight={0}
|
||||||
onIsAtEndChange={onIsAtEndChange}
|
onIsAtEndChange={onIsAtEndChange}
|
||||||
|
onListMetricsChange={onListMetricsChange}
|
||||||
onTimelineDataChange={onTimelineDataChange}
|
onTimelineDataChange={onTimelineDataChange}
|
||||||
listHeader={listHeader}
|
listHeader={listHeader}
|
||||||
listFooter={listFooter}
|
listFooter={listFooter}
|
||||||
@@ -543,6 +546,7 @@ const ChatViewport = React.memo(({
|
|||||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||||
&& prev.retryOverlay === next.retryOverlay
|
&& prev.retryOverlay === next.retryOverlay
|
||||||
&& prev.scrollToBottom === next.scrollToBottom
|
&& prev.scrollToBottom === next.scrollToBottom
|
||||||
|
&& prev.onListMetricsChange === next.onListMetricsChange
|
||||||
&& prev.endPinningReleased === next.endPinningReleased
|
&& prev.endPinningReleased === next.endPinningReleased
|
||||||
&& prev.revealWaited === next.revealWaited
|
&& prev.revealWaited === next.revealWaited
|
||||||
&& prev.revealGate === next.revealGate
|
&& prev.revealGate === next.revealGate
|
||||||
@@ -1124,6 +1128,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
onAnchorReady,
|
onAnchorReady,
|
||||||
onAnchorSizeChanged,
|
onAnchorSizeChanged,
|
||||||
onIsAtEndChange,
|
onIsAtEndChange,
|
||||||
|
onListMetricsChange,
|
||||||
onManualNavigation,
|
onManualNavigation,
|
||||||
onTimelineDataChange,
|
onTimelineDataChange,
|
||||||
goToBottom,
|
goToBottom,
|
||||||
@@ -1553,6 +1558,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
onAnchorReady={onAnchorReady}
|
onAnchorReady={onAnchorReady}
|
||||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||||
onIsAtEndChange={onIsAtEndChange}
|
onIsAtEndChange={onIsAtEndChange}
|
||||||
|
onListMetricsChange={onListMetricsChange}
|
||||||
onTimelineDataChange={onTimelineDataChange}
|
onTimelineDataChange={onTimelineDataChange}
|
||||||
messageListRef={messageListRef}
|
messageListRef={messageListRef}
|
||||||
renderedMessages={timelineController.renderedMessages}
|
renderedMessages={timelineController.renderedMessages}
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ interface MessageListProps {
|
|||||||
onAnchorSizeChanged?: (messageId: string) => void;
|
onAnchorSizeChanged?: (messageId: string) => void;
|
||||||
composerOverlayHeight?: number;
|
composerOverlayHeight?: number;
|
||||||
onIsAtEndChange?: (isAtEnd: boolean) => void;
|
onIsAtEndChange?: (isAtEnd: boolean) => void;
|
||||||
|
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
|
||||||
onTimelineDataChange?: () => void;
|
onTimelineDataChange?: () => void;
|
||||||
// Content that used to sit as siblings of the list inside the scroll
|
// 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
|
// container. The list owns that container now, so they render as its
|
||||||
@@ -959,6 +960,7 @@ type TimelineListProps = {
|
|||||||
};
|
};
|
||||||
composerOverlayHeight: number;
|
composerOverlayHeight: number;
|
||||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||||
|
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||||
onTimelineDataChange: () => void;
|
onTimelineDataChange: () => void;
|
||||||
listHeader?: React.ReactNode;
|
listHeader?: React.ReactNode;
|
||||||
listFooter?: React.ReactNode;
|
listFooter?: React.ReactNode;
|
||||||
@@ -973,6 +975,7 @@ const TimelineList = React.memo(({
|
|||||||
anchoredEndSpace,
|
anchoredEndSpace,
|
||||||
composerOverlayHeight,
|
composerOverlayHeight,
|
||||||
onIsAtEndChange,
|
onIsAtEndChange,
|
||||||
|
onListMetricsChange,
|
||||||
onTimelineDataChange,
|
onTimelineDataChange,
|
||||||
listHeader,
|
listHeader,
|
||||||
listFooter,
|
listFooter,
|
||||||
@@ -1068,29 +1071,30 @@ const TimelineList = React.memo(({
|
|||||||
contentInsetEndAdjustment={composerOverlayHeight}
|
contentInsetEndAdjustment={composerOverlayHeight}
|
||||||
// While a turn is anchored, the reserved end space — not the
|
// While a turn is anchored, the reserved end space — not the
|
||||||
// live edge — defines where the viewport rests.
|
// live edge — defines where the viewport rests.
|
||||||
// Also released while the width resizes: re-pinning against
|
// Live only while the session streams: outside a stream the
|
||||||
// rows that are still re-measuring shakes the pinned
|
// owning hook keeps a pinned reader on the end with same-frame
|
||||||
// viewport; once the resize settles the owning hook
|
// writes, and the list's own correction runs a frame later
|
||||||
// re-asserts the end for a streaming session and releases
|
// against a content length that can still be stale (a
|
||||||
// the pin for an idle one.
|
// re-wrap, a late measurement) — that is the visible bounce
|
||||||
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
// 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
|
? false
|
||||||
// Animated only while the session actively streams: there
|
// Animated: the block-step growth turns each correction
|
||||||
// the block-step growth turns each correction into a glide
|
// into a glide and reveal + scroll read as one motion.
|
||||||
// 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: rowContext.sessionIsWorking,
|
animated: true,
|
||||||
on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
|
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) and only for a reader who
|
||||||
maintainVisibleContentPosition={{ data: true, size: isWidthResizing }}
|
// 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}
|
onScroll={handleScroll}
|
||||||
|
onMetricsChange={onListMetricsChange}
|
||||||
ListHeaderComponent={header}
|
ListHeaderComponent={header}
|
||||||
ListFooterComponent={footer}
|
ListFooterComponent={footer}
|
||||||
{...scrollContainerProps}
|
{...scrollContainerProps}
|
||||||
@@ -1188,6 +1192,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
onAnchorSizeChanged,
|
onAnchorSizeChanged,
|
||||||
composerOverlayHeight = 0,
|
composerOverlayHeight = 0,
|
||||||
onIsAtEndChange,
|
onIsAtEndChange,
|
||||||
|
onListMetricsChange,
|
||||||
onTimelineDataChange,
|
onTimelineDataChange,
|
||||||
listHeader,
|
listHeader,
|
||||||
listFooter,
|
listFooter,
|
||||||
@@ -1435,6 +1440,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
onTimelineDataChange?.();
|
onTimelineDataChange?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stableListMetricsChange = useStableEvent((metrics: { readonly footerSize: number }) => {
|
||||||
|
onListMetricsChange?.(metrics);
|
||||||
|
});
|
||||||
|
|
||||||
const currentUserOrder = React.useMemo(() => {
|
const currentUserOrder = React.useMemo(() => {
|
||||||
return messages
|
return messages
|
||||||
.filter((message) => resolveMessageRole(message) === 'user')
|
.filter((message) => resolveMessageRole(message) === 'user')
|
||||||
@@ -1862,6 +1871,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
anchoredEndSpace={anchoredEndSpace}
|
anchoredEndSpace={anchoredEndSpace}
|
||||||
composerOverlayHeight={composerOverlayHeight}
|
composerOverlayHeight={composerOverlayHeight}
|
||||||
onIsAtEndChange={stableIsAtEndChange}
|
onIsAtEndChange={stableIsAtEndChange}
|
||||||
|
onListMetricsChange={stableListMetricsChange}
|
||||||
onTimelineDataChange={stableTimelineDataChange}
|
onTimelineDataChange={stableTimelineDataChange}
|
||||||
listHeader={listHeader}
|
listHeader={listHeader}
|
||||||
listFooter={listFooter}
|
listFooter={listFooter}
|
||||||
|
|||||||
@@ -223,6 +223,16 @@ describe('resolveRealContentEndOffset', () => {
|
|||||||
})).toBe(696);
|
})).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', () => {
|
test('returns null for an empty timeline and for unmeasured last rows', () => {
|
||||||
expect(resolveRealContentEndOffset({
|
expect(resolveRealContentEndOffset({
|
||||||
state: buildState({ positions: [], sizes: [] }),
|
state: buildState({ positions: [], sizes: [] }),
|
||||||
@@ -237,10 +247,13 @@ describe('resolveRealContentEndOffset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveTimelineIsAtEnd', () => {
|
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: 1400, scrollLength: 600 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
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', () => {
|
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
|
// have not been re-measured yet. Scrolling to it then lands below the real
|
||||||
// content and leaves a blank tail. `extraInset` reserves additional slack
|
// 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.
|
// 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 = ({
|
export const resolveRealContentEndOffset = ({
|
||||||
state,
|
state,
|
||||||
composerOverlayHeight,
|
composerOverlayHeight,
|
||||||
|
footerSize = 0,
|
||||||
extraInset = 0,
|
extraInset = 0,
|
||||||
}: {
|
}: {
|
||||||
readonly state: TimelineListMeasurementState;
|
readonly state: TimelineListMeasurementState;
|
||||||
readonly composerOverlayHeight: number;
|
readonly composerOverlayHeight: number;
|
||||||
|
readonly footerSize?: number;
|
||||||
readonly extraInset?: number;
|
readonly extraInset?: number;
|
||||||
}): number | null => {
|
}): number | null => {
|
||||||
const lastIndex = state.data.length - 1;
|
const lastIndex = state.data.length - 1;
|
||||||
@@ -129,16 +135,21 @@ export const resolveRealContentEndOffset = ({
|
|||||||
const lastBottom = getRowBottom(state, lastIndex);
|
const lastBottom = getRowBottom(state, lastIndex);
|
||||||
if (lastBottom === null) return null;
|
if (lastBottom === null) return null;
|
||||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
|
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
|
// "At the end" for follow purposes is half a viewport. Leaving the end is
|
||||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
// only ever decided by a real gesture, so this band never yanks a reader who
|
||||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
// is still on the end; what it decides is how close to the live edge a reader
|
||||||
// next stream chunk. Distance is measured against the full content length —
|
// who scrolled away must come back before follow re-arms and the pill hides.
|
||||||
// reserved anchored end space included — so a parked anchored turn counts as
|
// Half a screen reads as "I am back at the bottom" without having to land on
|
||||||
// the live edge.
|
// the last pixel, and stray row growth or late measurements cannot push a
|
||||||
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
// 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 = (
|
export const resolveTimelineIsAtEnd = (
|
||||||
state: {
|
state: {
|
||||||
@@ -157,7 +168,7 @@ export const resolveTimelineIsAtEnd = (
|
|||||||
&& typeof scrollLength === 'number'
|
&& typeof scrollLength === 'number'
|
||||||
&& Number.isFinite(contentLength)
|
&& Number.isFinite(contentLength)
|
||||||
) {
|
) {
|
||||||
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
return contentLength - (scroll + scrollLength) <= resolveFollowRearmThresholdPx(scrollLength);
|
||||||
}
|
}
|
||||||
return state.isNearEnd ?? state.isAtEnd;
|
return state.isNearEnd ?? state.isAtEnd;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getRowBottom,
|
getRowBottom,
|
||||||
resolveRealContentEndOffset,
|
resolveRealContentEndOffset,
|
||||||
resolveTimelineIsAtEnd,
|
resolveTimelineIsAtEnd,
|
||||||
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
|
resolveFollowRearmThresholdPx,
|
||||||
type TimelineListMeasurementState,
|
type TimelineListMeasurementState,
|
||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||||
@@ -102,6 +102,7 @@ export interface UseChatTimelineScrollResult {
|
|||||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||||
onAnchorSizeChanged: (messageId: string) => void;
|
onAnchorSizeChanged: (messageId: string) => void;
|
||||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||||
|
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||||
onManualNavigation: () => void;
|
onManualNavigation: () => void;
|
||||||
onTimelineDataChange: () => void;
|
onTimelineDataChange: () => void;
|
||||||
showScrollButton: boolean;
|
showScrollButton: boolean;
|
||||||
@@ -179,6 +180,12 @@ export const useChatTimelineScroll = ({
|
|||||||
|
|
||||||
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
||||||
composerOverlayHeightRef.current = 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);
|
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||||
sessionMessageCountRef.current = sessionMessageCount;
|
sessionMessageCountRef.current = sessionMessageCount;
|
||||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||||
@@ -575,16 +582,17 @@ export const useChatTimelineScroll = ({
|
|||||||
first: null,
|
first: null,
|
||||||
second: null,
|
second: null,
|
||||||
});
|
});
|
||||||
// While the list width is resizing, every pinning write fights the
|
// While the list width is resizing every row re-wraps, and the list's
|
||||||
// per-frame row re-measure and the pinned viewport shakes. Corrections
|
// total content length lags a frame behind the rows it contains: it
|
||||||
// stand down for the whole resize and the visible content is held by the
|
// still carries pre-wrap row sizes, so any end computed from it (the
|
||||||
// list's size compensation instead. Deliberately NO snap back to the end
|
// list's own maintainScrollAtEnd, the scroll node's scrollHeight) lands
|
||||||
// afterwards for a mid-conversation reader: a slow drag settles
|
// on a blank tail or short of the real end and the viewport bounces.
|
||||||
// repeatedly, and each snap reads as the very jump this suspension
|
// A pinned reader — streaming or idle — stays on the end throughout: the
|
||||||
// removes. A reader pinned to a STREAMING session is the exception — the
|
// pinned-end observer below re-asserts the MEASURED end of the last real
|
||||||
// live edge is what they are watching, so the end is re-asserted once on
|
// row on every layout write, and once the resize settles the end is
|
||||||
// settle. A pinned reader of an idle session gets no scroll at all: if the
|
// asserted one last time against the same measurement. An unpinned
|
||||||
// re-wrap moved the viewport off the end, the pin is released instead.
|
// reader is held in place by the list's size compensation instead and
|
||||||
|
// is never scrolled.
|
||||||
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;
|
||||||
@@ -605,45 +613,20 @@ export const useChatTimelineScroll = ({
|
|||||||
quietTimer = null;
|
quietTimer = null;
|
||||||
widthResizingRef.current = false;
|
widthResizingRef.current = false;
|
||||||
if (!isAtEndRef.current || pendingAnchorRef.current !== null) return;
|
if (!isAtEndRef.current || pendingAnchorRef.current !== null) return;
|
||||||
if (!sessionIsWorkingRef.current) {
|
if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return;
|
||||||
// An idle pinned reader asked for nothing — a width change
|
const list = listRef.current;
|
||||||
// must not scroll them. If the re-wrap left the viewport
|
const state = list?.getState();
|
||||||
// off the end, release the pin instead of snapping back;
|
const offset = state
|
||||||
// the scroll-to-bottom pill offers the way home.
|
? resolveRealContentEndOffset({
|
||||||
const listState = listRef.current?.getState();
|
state,
|
||||||
const atEndNow = listState ? resolveTimelineIsAtEnd(listState) : undefined;
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||||
if (atEndNow === false) {
|
footerSize: listFooterSizeRef.current,
|
||||||
isAtEndRef.current = false;
|
})
|
||||||
setIsPinned(false);
|
: null;
|
||||||
modeRef.current = 'free-scrolling';
|
if (list && offset !== null) {
|
||||||
liveFollowGenerationRef.current = null;
|
void list.scrollToOffset({ offset, animated: false });
|
||||||
scheduleShowScrollButton();
|
} else {
|
||||||
queueSave();
|
void list?.scrollToEnd({ animated: false });
|
||||||
}
|
|
||||||
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.
|
|
||||||
const list = listRef.current;
|
|
||||||
const state = list?.getState();
|
|
||||||
const offset = state
|
|
||||||
? resolveRealContentEndOffset({
|
|
||||||
state,
|
|
||||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
if (list && offset !== null) {
|
|
||||||
void list.scrollToOffset({ offset, animated: false });
|
|
||||||
} else {
|
|
||||||
void list?.scrollToEnd({ animated: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
});
|
});
|
||||||
@@ -652,7 +635,7 @@ export const useChatTimelineScroll = ({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||||
};
|
};
|
||||||
}, [queueSave, scheduleShowScrollButton, scrollNode]);
|
}, [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
|
||||||
@@ -699,6 +682,7 @@ export const useChatTimelineScroll = ({
|
|||||||
const offset = resolveRealContentEndOffset({
|
const offset = resolveRealContentEndOffset({
|
||||||
state,
|
state,
|
||||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||||
|
footerSize: listFooterSizeRef.current,
|
||||||
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
||||||
});
|
});
|
||||||
if (offset !== null) {
|
if (offset !== null) {
|
||||||
@@ -723,7 +707,7 @@ export const useChatTimelineScroll = ({
|
|||||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||||
if (lastBottom !== null) {
|
if (lastBottom !== null) {
|
||||||
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
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;
|
isAtEndRef.current = false;
|
||||||
setIsPinned(false);
|
setIsPinned(false);
|
||||||
scheduleShowScrollButton();
|
scheduleShowScrollButton();
|
||||||
@@ -933,18 +917,33 @@ export const useChatTimelineScroll = ({
|
|||||||
// sits on the end of a session that is not producing output, any growth
|
// 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)
|
// 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
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
if (!scrollNode || typeof MutationObserver === 'undefined') return;
|
if (!scrollNode || typeof MutationObserver === 'undefined') return;
|
||||||
const content = scrollNode.firstElementChild;
|
const content = scrollNode.firstElementChild;
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
const pin = () => {
|
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 (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;
|
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
||||||
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
||||||
};
|
};
|
||||||
@@ -1079,6 +1078,7 @@ export const useChatTimelineScroll = ({
|
|||||||
onAnchorReady,
|
onAnchorReady,
|
||||||
onAnchorSizeChanged,
|
onAnchorSizeChanged,
|
||||||
onIsAtEndChange,
|
onIsAtEndChange,
|
||||||
|
onListMetricsChange,
|
||||||
onManualNavigation,
|
onManualNavigation,
|
||||||
onTimelineDataChange,
|
onTimelineDataChange,
|
||||||
showScrollButton,
|
showScrollButton,
|
||||||
|
|||||||
Reference in New Issue
Block a user