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 9c5a8ae1..48eafa3e 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts @@ -5,6 +5,7 @@ import { getAnchoredTurnMetrics, getRowBottom, resolveChatListAnchoredEndSpace, + resolveRealContentEndOffset, resolveTimelineIsAtEnd, type TimelineListMeasurementState, } from './timelineScrollAnchoring'; @@ -183,6 +184,58 @@ describe('getAnchoredTurnMetrics', () => { }); }); +describe('resolveRealContentEndOffset', () => { + test('puts the last row bottom just above the composer overlay', () => { + const state = buildState({ + positions: [0, 1000], + sizes: [1000, 200], + scroll: 0, + scrollLength: 700, + }); + + expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(680); + }); + + test('ignores content length inflated by reserved end space or stale sizes', () => { + // The list still reports a far larger content length than the measured + // rows; the end offset must follow the rows, not that length. + const state = buildState({ + positions: [0, 300], + sizes: [300, 100], + scroll: 900, + scrollLength: 700, + }); + + expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0); + }); + + test('reserves extra slack below the content when asked', () => { + const state = buildState({ + positions: [0, 1000], + sizes: [1000, 200], + scrollLength: 700, + }); + + expect(resolveRealContentEndOffset({ + state, + composerOverlayHeight: 180, + extraInset: CHAT_LIST_ANCHOR_OFFSET, + })).toBe(696); + }); + + test('returns null for an empty timeline and for unmeasured last rows', () => { + expect(resolveRealContentEndOffset({ + state: buildState({ positions: [], sizes: [] }), + composerOverlayHeight: 180, + })).toBeNull(); + + expect(resolveRealContentEndOffset({ + state: buildState({ positions: [0, 100], sizes: [100] }), + composerOverlayHeight: 180, + })).toBeNull(); + }); +}); + describe('resolveTimelineIsAtEnd', () => { test('uses a tight distance band against the full content length', () => { expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true); diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts index cd670a1f..dd2312a0 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts @@ -108,6 +108,30 @@ export const getAnchoredTurnMetrics = ({ }; }; +// The scroll offset that puts the LAST REAL ROW's bottom just above the +// composer overlay. Distinct from the list's own end offset, which is derived +// from the total content length: that length includes any reserved anchored +// end space and, right after rows re-wrap on a width change, row sizes that +// 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. +export const resolveRealContentEndOffset = ({ + state, + composerOverlayHeight, + extraInset = 0, +}: { + readonly state: TimelineListMeasurementState; + readonly composerOverlayHeight: number; + readonly extraInset?: number; +}): number | null => { + const lastIndex = state.data.length - 1; + if (lastIndex < 0) return null; + 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); +}; + // "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 diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index dcccdf37..5b8d8606 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -8,6 +8,7 @@ import { CHAT_LIST_ANCHOR_OFFSET, getAnchoredTurnMetrics, getRowBottom, + resolveRealContentEndOffset, resolveTimelineIsAtEnd, TIMELINE_FOLLOW_REARM_THRESHOLD_PX, type TimelineListMeasurementState, @@ -588,7 +589,25 @@ export const useChatTimelineScroll = ({ quietTimer = null; widthResizingRef.current = false; if (isAtEndRef.current && pendingAnchorRef.current === null) { - void listRef.current?.scrollToEnd({ animated: false }); + // 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); }); @@ -637,15 +656,15 @@ export const useChatTimelineScroll = ({ const lastIndex = state.data.length - 1; const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; if (lastBottom !== null && state.scroll > lastBottom) { - const visibleLength = Math.max( - 0, - state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET, - ); - void list.scrollToOffset({ - offset: Math.max(0, lastBottom - visibleLength), - animated: false, + const offset = resolveRealContentEndOffset({ + state, + composerOverlayHeight: composerOverlayHeightRef.current, + extraInset: CHAT_LIST_ANCHOR_OFFSET, }); - return; + if (offset !== null) { + void list.scrollToOffset({ offset, animated: false }); + return; + } } } }