diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ef0ab2b3..e2006a8c 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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 = ({ onAnchorReady, onAnchorSizeChanged, onIsAtEndChange, + onListMetricsChange, onManualNavigation, onTimelineDataChange, goToBottom, @@ -1553,6 +1558,7 @@ export const ChatContainer: React.FC = ({ onAnchorReady={onAnchorReady} onAnchorSizeChanged={onAnchorSizeChanged} onIsAtEndChange={onIsAtEndChange} + onListMetricsChange={onListMetricsChange} onTimelineDataChange={onTimelineDataChange} messageListRef={messageListRef} renderedMessages={timelineController.renderedMessages} diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 20f5c8bc..58fc9e7f 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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(({ onAnchorSizeChanged, composerOverlayHeight = 0, onIsAtEndChange, + onListMetricsChange, onTimelineDataChange, listHeader, listFooter, @@ -1435,6 +1440,10 @@ const MessageList = React.forwardRef(({ 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(({ anchoredEndSpace={anchoredEndSpace} composerOverlayHeight={composerOverlayHeight} onIsAtEndChange={stableIsAtEndChange} + onListMetricsChange={stableListMetricsChange} onTimelineDataChange={stableTimelineDataChange} listHeader={listHeader} listFooter={listFooter} diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts index 48eafa3e..404a91ba 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts @@ -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', () => { diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts index dd2312a0..1d4efe30 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts @@ -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; }; diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 5ca016e2..7057ac67 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -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,45 +613,20 @@ 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. - 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 }); - } + 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) { + void list.scrollToOffset({ offset, animated: false }); + } else { + void list?.scrollToEnd({ animated: false }); } }, 350); }); @@ -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,