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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
//
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into reserved space below it. The
|
||||
// viewport does NOT move until the turn outgrows the usable viewport.
|
||||
// • `free-scrolling` — the user took over; nothing moves the scroll
|
||||
// position until they opt back in.
|
||||
//
|
||||
// This module is pure geometry: it reads measurements from the virtualized
|
||||
// list and answers "how far, if at all, must we scroll to reveal the end of
|
||||
// the anchored turn". Keeping it free of DOM and React makes the mode machine
|
||||
// testable without a renderer.
|
||||
//
|
||||
// "Usable viewport" is the visible height minus the composer overlay (the
|
||||
// composer floats over the list) minus the anchor offset, so a turn is only
|
||||
// considered overflowing when it genuinely cannot be read.
|
||||
|
||||
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
|
||||
|
||||
// Distance from the top of the viewport at which an anchored user message
|
||||
// parks. Small enough to read as "at the top", large enough not to collide
|
||||
// with the timeline's top fade.
|
||||
export const CHAT_LIST_ANCHOR_OFFSET = 16;
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
readonly scroll: number;
|
||||
readonly scrollLength: number;
|
||||
readonly positionAtIndex: (index: number) => number | undefined;
|
||||
readonly sizeAtIndex: (index: number) => number | undefined;
|
||||
}
|
||||
|
||||
export interface AnchoredTurnMetrics {
|
||||
readonly anchorTop: number;
|
||||
readonly lastBottom: number;
|
||||
readonly turnHeight: number;
|
||||
readonly usableViewportHeight: number;
|
||||
readonly visibleUsableBottom: number;
|
||||
readonly overflowsUsableViewport: boolean;
|
||||
readonly targetScrollToRevealEnd: number;
|
||||
readonly scrollDeltaToRevealEnd: number;
|
||||
}
|
||||
|
||||
export const getRowBottom = (
|
||||
state: TimelineListMeasurementState,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const top = state.positionAtIndex(index);
|
||||
const height = state.sizeAtIndex(index);
|
||||
if (
|
||||
typeof top !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isFinite(top)
|
||||
|| !Number.isFinite(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Rows measured at zero height would make an anchored turn look empty and
|
||||
// suppress the reveal scroll; treat them as one pixel tall instead.
|
||||
return top + Math.max(1, height);
|
||||
};
|
||||
|
||||
export const getAnchoredTurnMetrics = ({
|
||||
state,
|
||||
anchorIndex,
|
||||
composerOverlayHeight,
|
||||
anchorOffset,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly anchorIndex: number;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly anchorOffset: number;
|
||||
}): AnchoredTurnMetrics | null => {
|
||||
if (state.data.length === 0) return null;
|
||||
|
||||
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
|
||||
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
|
||||
// The LAST row bottom, not the content length: the reserved anchored end
|
||||
// space lives past it, and targeting that reserved tail would scroll the
|
||||
// real content off the top.
|
||||
const lastBottom = getRowBottom(state, state.data.length - 1);
|
||||
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableViewportHeight = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeight - anchorOffset,
|
||||
);
|
||||
const turnHeight = Math.max(0, lastBottom - anchorTop);
|
||||
const visibleUsableBottom = state.scroll + usableViewportHeight;
|
||||
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
|
||||
// Never negative: revealing the end must not scroll the timeline backwards.
|
||||
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
|
||||
|
||||
return {
|
||||
anchorTop,
|
||||
lastBottom,
|
||||
turnHeight,
|
||||
usableViewportHeight,
|
||||
visibleUsableBottom,
|
||||
overflowsUsableViewport: turnHeight > usableViewportHeight,
|
||||
targetScrollToRevealEnd,
|
||||
scrollDeltaToRevealEnd,
|
||||
};
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is the NEAR-end threshold, not the exact
|
||||
// content bottom: the timeline's footer (status row plus bottom spacer) sits
|
||||
// below the last row, so requiring the exact bottom would drop out of follow —
|
||||
// and pop the scroll-to-bottom pill — while the user is still looking at the
|
||||
// live edge. `isAtEnd` is only the fallback for states that predate the
|
||||
// near-end signal.
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
|
||||
): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
|
||||
|
||||
export interface ChatListAnchoredEndSpace {
|
||||
readonly anchorIndex: number;
|
||||
readonly anchorOffset: number;
|
||||
}
|
||||
|
||||
// Finds the anchored row from the BACK of the list: a retried or re-sent
|
||||
// message id can appear more than once, and the live one is always the last.
|
||||
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
|
||||
items: readonly Item[],
|
||||
anchorId: AnchorId | null,
|
||||
getAnchorId: (item: Item) => AnchorId | null,
|
||||
options: { readonly anchorOffset?: number } = {},
|
||||
): ChatListAnchoredEndSpace | undefined => {
|
||||
if (anchorId === null) return undefined;
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item !== undefined && getAnchorId(item) === anchorId) {
|
||||
return {
|
||||
anchorIndex: index,
|
||||
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user