refactor(chat): replace timeline scroll engine with anchored-turn LegendList

Sending a message now parks that message near the top of the viewport
and streams the reply into reserved end space below it, instead of
jumping to the bottom and chasing it.

- swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the
  streaming tail becomes a normal list row rather than a separately rendered
  block, so one component owns the scroll position
- add timelineScrollAnchoring: pure anchored-turn geometry plus the three
  scroll modes (following-end / anchoring-new-turn / free-scrolling)
- replace useChatAutoFollow with useChatTimelineScroll, which opts out of
  automatic movement on real gestures via a generation counter instead of the
  timer windows the old implementation needed to recognise its own writes
- move the load-older button, question/permission cards, recap, status row and
  bottom spacer into the list header/footer, since the list owns its container
- extract useScrollShadow so the shadows can attach to that container

maintainScrollAtEnd and maintainVisibleContentPosition replace the manual
prepend anchor-hold and the mobile quiet-window prepend deferral.

Validated: workspace type-check, lint, web build, ui tests per file.
Scroll behaviour itself is unverified and needs manual testing on web, desktop
and iOS.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 01:10:00 +03:00
parent 3b7d5e1c34
commit d2d8669564
18 changed files with 1739 additions and 1536 deletions
@@ -0,0 +1,225 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
} from './timelineScrollAnchoring';
const buildState = ({
positions,
sizes,
scroll = 0,
scrollLength = 700,
}: {
readonly positions: readonly number[];
readonly sizes: readonly number[];
readonly scroll?: number;
readonly scrollLength?: number;
}): TimelineListMeasurementState => ({
data: positions.map((_, index) => index),
scroll,
scrollLength,
positionAtIndex: (index) => positions[index],
sizeAtIndex: (index) => sizes[index],
});
describe('getRowBottom', () => {
test('measures row bottoms from list row position and size', () => {
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
expect(getRowBottom(state, 1)).toBe(160);
});
test('returns null for unmeasured rows', () => {
const state = buildState({ positions: [0], sizes: [80] });
expect(getRowBottom(state, 5)).toBeNull();
});
test('treats a zero-height row as one pixel tall', () => {
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
expect(getRowBottom(state, 1)).toBe(121);
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveTimelineIsAtEnd', () => {
test('prefers the near-end threshold over the exact content bottom', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
});
test('falls back to the exact end when near-end is unavailable', () => {
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
test('reports nothing without a state', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});