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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user