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:
Bohdan Triapitsyn
2026-09-07 17:58:02 +03:00
parent 34c8887fc1
commit e3b0088c60
5 changed files with 39 additions and 675 deletions
@@ -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;
};
+22 -295
View File
@@ -6,8 +6,6 @@ import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveRealContentEndOffset,
resolveTimelineIsAtEnd,
@@ -25,16 +23,11 @@ import {
// Chat timeline scroll ownership.
//
// The virtualized list owns the scroll position; this hook only decides which
// of three mutually exclusive modes is active and, when a mode calls for it,
// of two mutually exclusive modes is active and, when a mode calls for it,
// issues ONE deterministic scroll command:
//
// • `following-end` — pinned to the live edge. The list keeps us there
// through `maintainScrollAtEnd`; we only re-assert after a data change.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into the reserved end space below
// it. The viewport does NOT move while the turn still fits; once the turn
// outgrows the usable viewport we scroll by the exact delta needed to keep
// its end visible.
// • `free-scrolling` — the user took over. Nothing moves until they opt
// back in by returning to the end.
//
@@ -74,9 +67,6 @@ interface UseChatTimelineScrollOptions {
currentSessionKey: string | null;
sessionMessageCount: number;
composerOverlayHeight: number;
// Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null;
// True while the session is producing output. Follow corrections glide
// only then. Outside a live stream — entering a session, a tab becoming
// active, rows re-measuring after a switch — the viewport must land on
@@ -98,9 +88,6 @@ export interface UseChatTimelineScrollResult {
scrollNode: HTMLDivElement | null;
isPinned: boolean;
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;
onManualNavigation: () => void;
@@ -120,21 +107,12 @@ export interface UseChatTimelineScrollResult {
// Hiding is always immediate.
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
const SAVE_DEBOUNCE_MS = 150;
// The anchor scroll is animated; `scrollend` is the authoritative completion
// signal, and this bounds the wait for browsers that drop it.
const ANCHOR_SETTLE_FALLBACK_MS = 750;
// Re-running the anchor positioning while the list is still mounting rows.
const ANCHOR_POSITION_ATTEMPTS = 12;
// Anchor restores only correct sub-pixel drift; anything larger is the user or
// a genuine relayout and must not be undone.
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
export const useChatTimelineScroll = ({
currentSessionId,
currentSessionKey,
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
sessionIsWorking,
revealGate = null,
onActiveTurnChange,
@@ -145,7 +123,6 @@ export const useChatTimelineScroll = ({
const listRef = React.useRef<TimelineListHandle | null>(null);
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = React.useState(false);
// "Pinned" is the live edge, which history pagination uses to decide whether
// it may load older pages without disturbing the read position.
@@ -163,19 +140,6 @@ export const useChatTimelineScroll = ({
// while `liveFollowGenerationRef` still equals it.
const userGenerationRef = React.useRef(0);
const liveFollowGenerationRef = React.useRef<number | null>(0);
// Anchor lifecycle: armed on send → pending until the row exists → positioned
// while the animated scroll runs → settled once it has come to rest.
const armedForNextUserMessageRef = React.useRef(false);
const pendingAnchorRef = React.useRef<string | null>(null);
const positionedAnchorRef = React.useRef<string | null>(null);
const settledAnchorRef = React.useRef<string | null>(null);
const activeAnchorIndexRef = React.useRef<number | null>(null);
const pendingAnchorRestoreRef = React.useRef<{
readonly messageId: string;
readonly offset: number;
readonly userGeneration: number;
} | null>(null);
const anchorRestoreFrameRef = React.useRef<number | null>(null);
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
@@ -215,23 +179,8 @@ export const useChatTimelineScroll = ({
}, SHOW_SCROLL_BUTTON_DELAY_MS);
}, []);
const clearAnchor = React.useCallback(() => {
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
setAnchorMessageId(null);
}, []);
// A real gesture: stop every automatic movement until the user opts back
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
// viewport back to the end — only the anchor machinery is disarmed.
// in.
const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
@@ -249,16 +198,6 @@ export const useChatTimelineScroll = ({
cancelShowButtonTimer();
setShowScrollButton(true);
}
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => (
@@ -327,7 +266,6 @@ export const useChatTimelineScroll = ({
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
// While a stream is growing the content, a single jump lands on the
@@ -345,62 +283,24 @@ export const useChatTimelineScroll = ({
void listRef.current?.scrollToEnd({ animated: false });
}, delay));
}
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
}, [clearGoToBottomReasserts, hideScrollButton]);
// User preference: with auto-follow off, streaming growth never moves the
// viewport. Sending from the live edge still parks the new message at the
// top, but no glide or end-follow correction runs afterwards; sending from
// mid-history leaves the viewport untouched.
// viewport. Sending from the live edge still lands on the end; sending
// from mid-history leaves the viewport untouched.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// Sending arms the anchor. The message id is not known here (the optimistic
// row is created by the store), so the next new user message id claims it.
// Whether the send-time anchor positioning may animate. Sending from the
// live edge parks the new message with a short smooth scroll; sending
// from mid-history teleports — a long smooth scroll through the
// virtualized timeline gets cancelled by rows mounting and measuring
// along the way and dies partway there.
const anchorPositionInstantRef = React.useRef(false);
// Sending is an explicit return to the live edge: the sent row and the
// reply that follows it stay in view through ordinary end-follow.
const scrollToBottomOnSend = React.useCallback(() => {
// With auto-follow off, a reader who scrolled away from the end stays
// exactly where they are: the sent message is not anchored and the
// scroll-to-bottom pill (already showing) leads to it. From the live
// edge, sending anchors the new turn as usual.
// exactly where they are; the scroll-to-bottom pill (already showing)
// leads to the sent message.
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
armedForNextUserMessageRef.current = true;
// The optimistic row is not committed yet; the next NEW user message id
// relative to this baseline claims the anchor, independent of whether
// the commit lands before or after this call.
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
hideScrollButton();
}, [hideScrollButton]);
// Claim the anchor as soon as the sent row exists in the timeline. The
// comparison is against the baseline captured when the send armed the
// anchor, so the claim works whether the optimistic row committed before
// or after the arming call.
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
React.useEffect(() => {
lastArmedUserMessageIdRef.current = lastUserMessageId;
if (!armedForNextUserMessageRef.current) return;
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = lastUserMessageId;
setAnchorMessageId(lastUserMessageId);
}, [lastUserMessageId]);
goToBottom('instant');
}, [goToBottom]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current;
@@ -412,11 +312,10 @@ export const useChatTimelineScroll = ({
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false });
return false;
}, [clearAnchor, hideScrollButton]);
}, [hideScrollButton]);
// ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => {
@@ -428,8 +327,8 @@ export const useChatTimelineScroll = ({
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline, the glide trails its
// target between corrections) — not a reason to offer the pill. Only a
// own doing (the glide trails its target between corrections) — not a
// reason to offer the pill. Only a
// real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton();
@@ -439,9 +338,7 @@ export const useChatTimelineScroll = ({
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
if (isAtEnd) {
if (modeRef.current !== 'anchoring-new-turn') {
modeRef.current = 'following-end';
}
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
setUserOwnsScroll(false);
hideScrollButton();
@@ -453,105 +350,7 @@ export const useChatTimelineScroll = ({
queueSave();
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
// The anchored end space can be remeasured long after the send (turn
// completion, images decoding). Only the send-time anchoring mode may
// position the viewport.
if (modeRef.current !== 'anchoring-new-turn') return;
if (pendingAnchorRef.current === messageId) {
pendingAnchorRef.current = null;
}
activeAnchorIndexRef.current = anchorIndex;
if (positionedAnchorRef.current === messageId) return;
positionedAnchorRef.current = messageId;
settledAnchorRef.current = null;
const positionAnchor = (remainingAttempts: number) => {
requestAnimationFrame(() => {
if (positionedAnchorRef.current !== messageId) return;
const list = listRef.current;
if (!list) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
const scrollNode = list.getScrollableNode();
if (!scrollNode) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
let finished = false;
const finishPositioning = () => {
if (finished) return;
finished = true;
clearTimeout(fallbackTimer);
scrollNode.removeEventListener('scrollend', finishPositioning);
if (positionedAnchorRef.current !== messageId) return;
// Re-assert the resting offset without animation so the
// smooth scroll's own momentum cannot drift past it.
const scrollOffset = list.getState().scroll;
void list.scrollToOffset({ offset: scrollOffset, animated: false });
settledAnchorRef.current = messageId;
};
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
void list.scrollToIndex({
index: anchorIndex,
animated: !anchorPositionInstantRef.current,
viewPosition: 0,
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
};
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
}, []);
// The anchored row can still change height after it settles (an image
// decoding, a code block highlighting). Hold the resting offset, but only
// against sub-pixel drift and only while the user has not taken over.
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
if (settledAnchorRef.current !== messageId) return;
if (!isLiveFollowActive()) return;
const scrollOffset = listRef.current?.getState().scroll;
if (scrollOffset === undefined) return;
if (pendingAnchorRestoreRef.current === null) {
pendingAnchorRestoreRef.current = {
messageId,
offset: scrollOffset,
userGeneration: userGenerationRef.current,
};
}
if (anchorRestoreFrameRef.current !== null) return;
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
anchorRestoreFrameRef.current = null;
const pending = pendingAnchorRestoreRef.current;
pendingAnchorRestoreRef.current = null;
if (
!pending
|| settledAnchorRef.current !== pending.messageId
|| pending.userGeneration !== userGenerationRef.current
) {
return;
}
const list = listRef.current;
const currentOffset = list?.getState().scroll;
if (
typeof currentOffset === 'number'
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
}
});
}, [isLiveFollowActive]);
// Whether the real rows (ignoring any reserved anchored end space) are tall
// enough to scroll. Without this, entering a short session would scroll into
// the reserved space and strand the content above the viewport.
// Whether the real rows are tall enough to scroll at all.
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
const state = list.getState();
if (state.data.length === 0) return false;
@@ -569,19 +368,10 @@ export const useChatTimelineScroll = ({
}
const realContentBottom = lastTop + Math.max(1, lastHeight);
const visibleScrollLength = Math.max(
0,
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
);
const visibleScrollLength = Math.max(0, state.scrollLength - composerOverlayHeightRef.current);
return realContentBottom > visibleScrollLength;
}, []);
// One deterministic correction per data change, two frames out so the list
// has measured the new rows. Nothing runs while the user owns the scroll.
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
first: null,
second: null,
});
// While the list width is resizing every row re-wraps, and the list's
// total content length lags a frame behind the rows it contains: it
// still carries pre-wrap row sizes, so any end computed from it (the
@@ -612,7 +402,7 @@ export const useChatTimelineScroll = ({
quietTimer = setTimeout(() => {
quietTimer = null;
widthResizingRef.current = false;
if (!isAtEndRef.current || pendingAnchorRef.current !== null) return;
if (!isAtEndRef.current) return;
if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return;
const list = listRef.current;
const state = list?.getState();
@@ -683,7 +473,6 @@ export const useChatTimelineScroll = ({
state,
composerOverlayHeight: composerOverlayHeightRef.current,
footerSize: listFooterSizeRef.current,
extraInset: CHAT_LIST_ANCHOR_OFFSET,
});
if (offset !== null) {
void list.scrollToOffset({ offset, animated: false });
@@ -726,58 +515,8 @@ export const useChatTimelineScroll = ({
// block is several viewports tall, so every block left the reader a
// second behind and multiple screens above the live edge — measured
// at 45% of the stream time spent 500-1600px behind at 420x640.
if (modeRef.current === 'following-end') {
followEnd();
return;
}
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
frames.first = requestAnimationFrame(() => {
frames.first = null;
frames.second = requestAnimationFrame(() => {
frames.second = null;
if (!isLiveFollowActive()) return;
// An anchor that exists but has not come to rest yet owns the
// viewport; correcting now would fight its animation.
if (pendingAnchorRef.current !== null) return;
if (
positionedAnchorRef.current !== null
&& settledAnchorRef.current !== positionedAnchorRef.current
) {
return;
}
const list = listRef.current;
if (!list) return;
if (modeRef.current === 'anchoring-new-turn') {
const anchorIndex = activeAnchorIndexRef.current;
if (anchorIndex === null) return;
const metrics = getAnchoredTurnMetrics({
state: list.getState(),
anchorIndex,
composerOverlayHeight: composerOverlayHeightRef.current,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
// The turn still fits: leave the viewport exactly where the
// user is reading.
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
// Animated: successive corrections restart the smooth scroll
// from the current position, so streaming reads as one
// continuous glide instead of a per-line hop. A real user
// gesture interrupts the native smooth scroll on its own.
void list.scrollToOffset({
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
animated: true,
});
return;
}
});
});
if (modeRef.current !== 'following-end') return;
followEnd();
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
// The streaming tail grows inside one row without changing the entries
@@ -805,11 +544,7 @@ export const useChatTimelineScroll = ({
// A gesture is meaningful when the viewport can move up AT ALL:
// either the real rows overflow the viewport, or there is scrolled
// history above (an anchored turn parks mid-conversation with
// reserved space below — the real rows may not overflow yet, but
// wheel-up is still a genuine opt-out; swallowing it left live-follow
// armed, which suppressed the pill and kept corrections armed under a
// viewport the user had taken).
// history above.
const canScrollUp = () => {
const list = listRef.current;
if (!list) return false;
@@ -976,9 +711,8 @@ export const useChatTimelineScroll = ({
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
}, [currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
@@ -989,10 +723,6 @@ export const useChatTimelineScroll = ({
React.useEffect(() => () => {
cancelShowButtonTimer();
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
}, [cancelShowButtonTimer]);
// ── active-turn spy ─────────────────────────────────────────────────────
@@ -1074,9 +804,6 @@ export const useChatTimelineScroll = ({
scrollNode,
isPinned,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onListMetricsChange,
onManualNavigation,