fix(chat): re-pin to the measured content end after a resize, not the list's content length

Opening or closing the context panel re-wrapped the timeline; the settle
handler then scrolled to LegendList's total content length, which still
carried pre-wrap sizes for unmounted rows, leaving a viewport-sized blank
tail under the last message.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 01:51:47 +03:00
parent 8d80224a1e
commit e26f647404
3 changed files with 105 additions and 9 deletions
@@ -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);
@@ -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
+28 -9
View File
@@ -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;
}
}
}
}