refactor(chat): drop the anchored end space after sending a message
Sending used to park the new user message near the top of the viewport by reserving blank space below it and holding the viewport there while the reply streamed in. The reserved space showed up on its own at times and the hold fought other corrections; with a reliable end-follow and pin in place the effect no longer pays for its machinery. The anchoring mode, its arm/claim/position/settle lifecycle, the reserved end space passed to the list and the anchored-turn geometry are removed. Sending from the live edge is now an instant return to the end, and the reply is kept in view by ordinary end-follow; sending from mid-history with auto-follow off still leaves the viewport untouched. Testing: scroll module tests trimmed to the remaining geometry; ui type-check, lint and dead-code report; web build.
This commit is contained in:
@@ -171,9 +171,6 @@ type ChatViewportProps = {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
@@ -216,9 +213,6 @@ const ChatViewport = React.memo(({
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onTimelineDataChange,
|
||||
@@ -501,9 +495,6 @@ const ChatViewport = React.memo(({
|
||||
endPinningReleased={endPinningReleased}
|
||||
directory={directory}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
// Zero end inset: the footer spacer already reserves the
|
||||
// zone the floating status row covers; adding its height
|
||||
// again produced a double-tall blank band at rest.
|
||||
@@ -1110,23 +1101,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
}, []);
|
||||
const lastUserMessageId = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index];
|
||||
if (message.info.role === 'user') {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionMessages]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
scrollNode,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
onManualNavigation,
|
||||
@@ -1143,7 +1121,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
sessionIsWorking,
|
||||
revealGate,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
@@ -1554,9 +1531,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onListMetricsChange={onListMetricsChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionPartsForMessages } from '@/sync/sync-context';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
|
||||
import { resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
|
||||
import {
|
||||
USER_SHELL_MARKER,
|
||||
isUserShellMarkerMessage,
|
||||
@@ -43,8 +43,6 @@ const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
|
||||
// • `maintainVisibleContentPosition` preserves the read position when older
|
||||
// history is prepended, replacing the manual anchor-hold and the mobile
|
||||
// quiet-window prepend deferral.
|
||||
// • `anchoredEndSpace` reserves the tail space that parks a just-sent
|
||||
// message near the top of the viewport.
|
||||
const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
|
||||
|
||||
// Anchor hold for an explicit viewport restore (session re-entry): row
|
||||
@@ -54,9 +52,6 @@ const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
|
||||
const ANCHOR_HOLD_STABLE_FRAMES = 30;
|
||||
const ANCHOR_HOLD_MAX_FRAMES = 180;
|
||||
|
||||
// Reserved tail space that parks an anchored row near the top of the viewport.
|
||||
// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
|
||||
// the reserved size is recomputed.
|
||||
// Presentation-only props forwarded to the scroll container the list renders.
|
||||
// Deliberately narrow: the list owns scroll and layout callbacks on that
|
||||
// element, so only styling, focus and click-through are caller-controlled.
|
||||
@@ -69,13 +64,6 @@ type TimelineScrollContainerProps = {
|
||||
'data-scroll-shadow'?: string;
|
||||
};
|
||||
|
||||
type TimelineAnchoredEndSpace = {
|
||||
anchorIndex: number;
|
||||
anchorOffset?: number;
|
||||
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
|
||||
onSizeChanged?: (size: number) => void;
|
||||
};
|
||||
|
||||
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
||||
const handlerRef = React.useRef(handler);
|
||||
React.useEffect(() => {
|
||||
@@ -324,11 +312,6 @@ interface MessageListProps {
|
||||
// True while a real gesture owns the scroll; releases the list's own
|
||||
// end pinning so the state machine, not the library heuristic, decides.
|
||||
endPinningReleased?: boolean;
|
||||
// The anchored row is identified by message id; the index it maps to is a
|
||||
// property of the row model, which only this component knows.
|
||||
anchorMessageId?: string | null;
|
||||
onAnchorReady?: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged?: (messageId: string) => void;
|
||||
composerOverlayHeight?: number;
|
||||
onIsAtEndChange?: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
|
||||
@@ -952,12 +935,6 @@ type TimelineListProps = {
|
||||
streamingTailKey: string | null;
|
||||
registerList: (list: LegendListRef | null) => void;
|
||||
endPinningReleased: boolean;
|
||||
anchoredEndSpace?: {
|
||||
anchorIndex: number;
|
||||
anchorOffset?: number;
|
||||
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
|
||||
onSizeChanged?: (size: number) => void;
|
||||
};
|
||||
composerOverlayHeight: number;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
||||
@@ -972,7 +949,6 @@ const TimelineList = React.memo(({
|
||||
entries,
|
||||
registerList,
|
||||
endPinningReleased,
|
||||
anchoredEndSpace,
|
||||
composerOverlayHeight,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
@@ -1067,10 +1043,7 @@ const TimelineList = React.memo(({
|
||||
// animations); recycling a container into a different row would
|
||||
// carry that state across.
|
||||
recycleItems={false}
|
||||
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
|
||||
contentInsetEndAdjustment={composerOverlayHeight}
|
||||
// While a turn is anchored, the reserved end space — not the
|
||||
// live edge — defines where the viewport rests.
|
||||
// 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
|
||||
@@ -1078,7 +1051,7 @@ const TimelineList = React.memo(({
|
||||
// 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
|
||||
maintainScrollAtEnd={!streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased
|
||||
? false
|
||||
// Animated: the block-step growth turns each correction
|
||||
// into a glide and reveal + scroll read as one motion.
|
||||
@@ -1187,9 +1160,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
directory,
|
||||
registerList,
|
||||
endPinningReleased = false,
|
||||
anchorMessageId = null,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
composerOverlayHeight = 0,
|
||||
onIsAtEndChange,
|
||||
onListMetricsChange,
|
||||
@@ -1802,27 +1772,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
};
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]);
|
||||
|
||||
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
|
||||
const resolved = resolveChatListAnchoredEndSpace(
|
||||
allEntries,
|
||||
anchorMessageId,
|
||||
(entry) => (entry.kind === 'turn' ? entry.turn.userMessage.info.id : entry.message.info.id),
|
||||
);
|
||||
if (!resolved || !anchorMessageId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...resolved,
|
||||
onReady: (info) => {
|
||||
if (info.anchorIndex === undefined) return;
|
||||
onAnchorReady?.(anchorMessageId, info.anchorIndex);
|
||||
},
|
||||
onSizeChanged: () => {
|
||||
onAnchorSizeChanged?.(anchorMessageId);
|
||||
},
|
||||
};
|
||||
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
|
||||
|
||||
const rowContext = React.useMemo(() => ({
|
||||
scrollToBottom: stableScrollToBottom,
|
||||
stickyUserHeader,
|
||||
@@ -1868,7 +1817,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
entries={allEntries}
|
||||
streamingTailKey={trailingStreamingEntry?.key ?? null}
|
||||
registerList={handleRegisterList}
|
||||
anchoredEndSpace={anchoredEndSpace}
|
||||
composerOverlayHeight={composerOverlayHeight}
|
||||
onIsAtEndChange={stableIsAtEndChange}
|
||||
onListMetricsChange={stableListMetricsChange}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveRealContentEndOffset,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
@@ -48,142 +45,6 @@ describe('getRowBottom', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('resolveRealContentEndOffset', () => {
|
||||
test('puts the last row bottom just above the composer overlay', () => {
|
||||
const state = buildState({
|
||||
@@ -209,20 +70,6 @@ describe('resolveRealContentEndOffset', () => {
|
||||
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('counts the footer rendered after the last row as real content', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
@@ -265,29 +112,3 @@ describe('resolveTimelineIsAtEnd', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
// Scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
// The timeline has two 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
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `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.
|
||||
// list and answers where the real content ends and whether the viewport is
|
||||
// there. Keeping it free of DOM and React makes the rules testable without a
|
||||
// renderer.
|
||||
|
||||
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 type TimelineScrollMode = 'following-end' | 'free-scrolling';
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
@@ -33,17 +21,6 @@ export interface TimelineListMeasurementState {
|
||||
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,
|
||||
@@ -58,83 +35,29 @@ export const getRowBottom = (
|
||||
) {
|
||||
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.
|
||||
// Rows measured at zero height would read as no content at all; 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,
|
||||
};
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// spacer) renders after the last row and is part of the real content; 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;
|
||||
if (lastIndex < 0) return null;
|
||||
const lastBottom = getRowBottom(state, lastIndex);
|
||||
if (lastBottom === null) return null;
|
||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
|
||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight);
|
||||
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
|
||||
};
|
||||
|
||||
@@ -145,8 +68,7 @@ export const resolveRealContentEndOffset = ({
|
||||
// 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.
|
||||
// length.
|
||||
const FOLLOW_REARM_MIN_THRESHOLD_PX = 40;
|
||||
export const resolveFollowRearmThresholdPx = (scrollLength: number): number =>
|
||||
Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2);
|
||||
@@ -172,31 +94,3 @@ export const resolveTimelineIsAtEnd = (
|
||||
}
|
||||
return 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