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>; scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>; messageListRef: React.RefObject<MessageListHandle | null>;
registerList: (list: TimelineListHandle | null) => void; registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void; onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void; onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange: () => void; onTimelineDataChange: () => void;
@@ -216,9 +213,6 @@ const ChatViewport = React.memo(({
scrollRef, scrollRef,
messageListRef, messageListRef,
registerList, registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange, onIsAtEndChange,
onListMetricsChange, onListMetricsChange,
onTimelineDataChange, onTimelineDataChange,
@@ -501,9 +495,6 @@ const ChatViewport = React.memo(({
endPinningReleased={endPinningReleased} endPinningReleased={endPinningReleased}
directory={directory} directory={directory}
registerList={registerList} registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
// Zero end inset: the footer spacer already reserves the // Zero end inset: the footer spacer already reserves the
// zone the floating status row covers; adding its height // zone the floating status row covers; adding its height
// again produced a double-tall blank band at rest. // again produced a double-tall blank band at rest.
@@ -1110,23 +1101,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
statusOverlayObserverRef.current?.disconnect(); statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null; 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 { const {
scrollRef, scrollRef,
scrollNode, scrollNode,
registerList, registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange, onIsAtEndChange,
onListMetricsChange, onListMetricsChange,
onManualNavigation, onManualNavigation,
@@ -1143,7 +1121,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
currentSessionKey, currentSessionKey,
sessionMessageCount, sessionMessageCount,
composerOverlayHeight, composerOverlayHeight,
lastUserMessageId,
sessionIsWorking, sessionIsWorking,
revealGate, revealGate,
onActiveTurnChange: handleActiveTurnChange, onActiveTurnChange: handleActiveTurnChange,
@@ -1554,9 +1531,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
directory={effectiveSessionDirectory} directory={effectiveSessionDirectory}
scrollRef={scrollRef} scrollRef={scrollRef}
registerList={registerList} registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
onIsAtEndChange={onIsAtEndChange} onIsAtEndChange={onIsAtEndChange}
onListMetricsChange={onListMetricsChange} onListMetricsChange={onListMetricsChange}
onTimelineDataChange={onTimelineDataChange} onTimelineDataChange={onTimelineDataChange}
@@ -20,7 +20,7 @@ import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionPartsForMessages } from '@/sync/sync-context'; import { useSessionPartsForMessages } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow'; import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring'; import { resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import { import {
USER_SHELL_MARKER, USER_SHELL_MARKER,
isUserShellMarkerMessage, isUserShellMarkerMessage,
@@ -43,8 +43,6 @@ const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
// • `maintainVisibleContentPosition` preserves the read position when older // • `maintainVisibleContentPosition` preserves the read position when older
// history is prepended, replacing the manual anchor-hold and the mobile // history is prepended, replacing the manual anchor-hold and the mobile
// quiet-window prepend deferral. // 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; const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
// Anchor hold for an explicit viewport restore (session re-entry): row // 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_STABLE_FRAMES = 30;
const ANCHOR_HOLD_MAX_FRAMES = 180; 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. // Presentation-only props forwarded to the scroll container the list renders.
// Deliberately narrow: the list owns scroll and layout callbacks on that // Deliberately narrow: the list owns scroll and layout callbacks on that
// element, so only styling, focus and click-through are caller-controlled. // element, so only styling, focus and click-through are caller-controlled.
@@ -69,13 +64,6 @@ type TimelineScrollContainerProps = {
'data-scroll-shadow'?: string; '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 useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler); const handlerRef = React.useRef(handler);
React.useEffect(() => { React.useEffect(() => {
@@ -324,11 +312,6 @@ interface MessageListProps {
// True while a real gesture owns the scroll; releases the list's own // True while a real gesture owns the scroll; releases the list's own
// end pinning so the state machine, not the library heuristic, decides. // end pinning so the state machine, not the library heuristic, decides.
endPinningReleased?: boolean; 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; composerOverlayHeight?: number;
onIsAtEndChange?: (isAtEnd: boolean) => void; onIsAtEndChange?: (isAtEnd: boolean) => void;
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void; onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
@@ -952,12 +935,6 @@ type TimelineListProps = {
streamingTailKey: string | null; streamingTailKey: string | null;
registerList: (list: LegendListRef | null) => void; registerList: (list: LegendListRef | null) => void;
endPinningReleased: boolean; endPinningReleased: boolean;
anchoredEndSpace?: {
anchorIndex: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
onSizeChanged?: (size: number) => void;
};
composerOverlayHeight: number; composerOverlayHeight: number;
onIsAtEndChange: (isAtEnd: boolean) => void; onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void; onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
@@ -972,7 +949,6 @@ const TimelineList = React.memo(({
entries, entries,
registerList, registerList,
endPinningReleased, endPinningReleased,
anchoredEndSpace,
composerOverlayHeight, composerOverlayHeight,
onIsAtEndChange, onIsAtEndChange,
onListMetricsChange, onListMetricsChange,
@@ -1067,10 +1043,7 @@ const TimelineList = React.memo(({
// animations); recycling a container into a different row would // animations); recycling a container into a different row would
// carry that state across. // carry that state across.
recycleItems={false} recycleItems={false}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={composerOverlayHeight} 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 // Live only while the session streams: outside a stream the
// owning hook keeps a pinned reader on the end with same-frame // owning hook keeps a pinned reader on the end with same-frame
// writes, and the list's own correction runs a frame later // 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 // re-wrap, a late measurement) — that is the visible bounce
// an idle reader saw on every panel toggle. Also off while the // an idle reader saw on every panel toggle. Also off while the
// width resizes, where the hook holds the measured end itself. // width resizes, where the hook holds the measured end itself.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased maintainScrollAtEnd={!streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased
? false ? false
// Animated: the block-step growth turns each correction // Animated: the block-step growth turns each correction
// into a glide and reveal + scroll read as one motion. // into a glide and reveal + scroll read as one motion.
@@ -1187,9 +1160,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
directory, directory,
registerList, registerList,
endPinningReleased = false, endPinningReleased = false,
anchorMessageId = null,
onAnchorReady,
onAnchorSizeChanged,
composerOverlayHeight = 0, composerOverlayHeight = 0,
onIsAtEndChange, onIsAtEndChange,
onListMetricsChange, onListMetricsChange,
@@ -1802,27 +1772,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}; };
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); }, [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(() => ({ const rowContext = React.useMemo(() => ({
scrollToBottom: stableScrollToBottom, scrollToBottom: stableScrollToBottom,
stickyUserHeader, stickyUserHeader,
@@ -1868,7 +1817,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
entries={allEntries} entries={allEntries}
streamingTailKey={trailingStreamingEntry?.key ?? null} streamingTailKey={trailingStreamingEntry?.key ?? null}
registerList={handleRegisterList} registerList={handleRegisterList}
anchoredEndSpace={anchoredEndSpace}
composerOverlayHeight={composerOverlayHeight} composerOverlayHeight={composerOverlayHeight}
onIsAtEndChange={stableIsAtEndChange} onIsAtEndChange={stableIsAtEndChange}
onListMetricsChange={stableListMetricsChange} onListMetricsChange={stableListMetricsChange}
@@ -1,10 +1,7 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom, getRowBottom,
resolveChatListAnchoredEndSpace,
resolveRealContentEndOffset, resolveRealContentEndOffset,
resolveTimelineIsAtEnd, resolveTimelineIsAtEnd,
type TimelineListMeasurementState, 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', () => { describe('resolveRealContentEndOffset', () => {
test('puts the last row bottom just above the composer overlay', () => { test('puts the last row bottom just above the composer overlay', () => {
const state = buildState({ const state = buildState({
@@ -209,20 +70,6 @@ describe('resolveRealContentEndOffset', () => {
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0); 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', () => { test('counts the footer rendered after the last row as real content', () => {
const state = buildState({ const state = buildState({
positions: [0, 1000], positions: [0, 1000],
@@ -265,29 +112,3 @@ describe('resolveTimelineIsAtEnd', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined); 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. // • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP // • `free-scrolling` — the user took over; nothing moves the scroll
// 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. // position until they opt back in.
// //
// This module is pure geometry: it reads measurements from the virtualized // 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 // list and answers where the real content ends and whether the viewport is
// the anchored turn". Keeping it free of DOM and React makes the mode machine // there. Keeping it free of DOM and React makes the rules testable without a
// testable without a renderer. // 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'; export type TimelineScrollMode = 'following-end' | '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 { export interface TimelineListMeasurementState {
readonly data: readonly unknown[]; readonly data: readonly unknown[];
@@ -33,17 +21,6 @@ export interface TimelineListMeasurementState {
readonly sizeAtIndex: (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 = ( export const getRowBottom = (
state: TimelineListMeasurementState, state: TimelineListMeasurementState,
index: number, index: number,
@@ -58,83 +35,29 @@ export const getRowBottom = (
) { ) {
return null; return null;
} }
// Rows measured at zero height would make an anchored turn look empty and // Rows measured at zero height would read as no content at all; treat
// suppress the reveal scroll; treat them as one pixel tall instead. // them as one pixel tall instead.
return top + Math.max(1, height); 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 // 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 // spacer) renders after the last row and is part of the real content; the
// reserved anchored end space; the list does not expose its size through // list does not expose its size through getState, so the caller passes the
// getState, so the caller passes the last reported value. // last reported value.
export const resolveRealContentEndOffset = ({ export const resolveRealContentEndOffset = ({
state, state,
composerOverlayHeight, composerOverlayHeight,
footerSize = 0, footerSize = 0,
extraInset = 0,
}: { }: {
readonly state: TimelineListMeasurementState; readonly state: TimelineListMeasurementState;
readonly composerOverlayHeight: number; readonly composerOverlayHeight: number;
readonly footerSize?: number; readonly footerSize?: number;
readonly extraInset?: number;
}): number | null => { }): number | null => {
const lastIndex = state.data.length - 1; const lastIndex = state.data.length - 1;
if (lastIndex < 0) return null; if (lastIndex < 0) return null;
const lastBottom = getRowBottom(state, lastIndex); const lastBottom = getRowBottom(state, lastIndex);
if (lastBottom === null) return null; 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); 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 // 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 // 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 // pinned reader out of it. Distance is measured against the full content
// length — reserved anchored end space included — so a parked anchored turn // length.
// counts as the live edge.
const FOLLOW_REARM_MIN_THRESHOLD_PX = 40; const FOLLOW_REARM_MIN_THRESHOLD_PX = 40;
export const resolveFollowRearmThresholdPx = (scrollLength: number): number => export const resolveFollowRearmThresholdPx = (scrollLength: number): number =>
Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2); Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2);
@@ -172,31 +94,3 @@ export const resolveTimelineIsAtEnd = (
} }
return state.isNearEnd ?? state.isAtEnd; 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 { useUIStore } from '@/stores/useUIStore';
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate'; import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import { import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom, getRowBottom,
resolveRealContentEndOffset, resolveRealContentEndOffset,
resolveTimelineIsAtEnd, resolveTimelineIsAtEnd,
@@ -25,16 +23,11 @@ import {
// Chat timeline scroll ownership. // Chat timeline scroll ownership.
// //
// The virtualized list owns the scroll position; this hook only decides which // 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: // issues ONE deterministic scroll command:
// //
// • `following-end` — pinned to the live edge. The list keeps us there // • `following-end` — pinned to the live edge. The list keeps us there
// through `maintainScrollAtEnd`; we only re-assert after a data change. // 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 // • `free-scrolling` — the user took over. Nothing moves until they opt
// back in by returning to the end. // back in by returning to the end.
// //
@@ -74,9 +67,6 @@ interface UseChatTimelineScrollOptions {
currentSessionKey: string | null; currentSessionKey: string | null;
sessionMessageCount: number; sessionMessageCount: number;
composerOverlayHeight: 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 // True while the session is producing output. Follow corrections glide
// only then. Outside a live stream — entering a session, a tab becoming // only then. Outside a live stream — entering a session, a tab becoming
// active, rows re-measuring after a switch — the viewport must land on // active, rows re-measuring after a switch — the viewport must land on
@@ -98,9 +88,6 @@ export interface UseChatTimelineScrollResult {
scrollNode: HTMLDivElement | null; scrollNode: HTMLDivElement | null;
isPinned: boolean; isPinned: boolean;
registerList: (list: TimelineListHandle | null) => void; registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void; onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void; onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
onManualNavigation: () => void; onManualNavigation: () => void;
@@ -120,21 +107,12 @@ export interface UseChatTimelineScrollResult {
// Hiding is always immediate. // Hiding is always immediate.
const SHOW_SCROLL_BUTTON_DELAY_MS = 150; const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
const SAVE_DEBOUNCE_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 = ({ export const useChatTimelineScroll = ({
currentSessionId, currentSessionId,
currentSessionKey, currentSessionKey,
sessionMessageCount, sessionMessageCount,
composerOverlayHeight, composerOverlayHeight,
lastUserMessageId,
sessionIsWorking, sessionIsWorking,
revealGate = null, revealGate = null,
onActiveTurnChange, onActiveTurnChange,
@@ -145,7 +123,6 @@ export const useChatTimelineScroll = ({
const listRef = React.useRef<TimelineListHandle | null>(null); const listRef = React.useRef<TimelineListHandle | null>(null);
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null); const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = React.useState(false); const [showScrollButton, setShowScrollButton] = React.useState(false);
// "Pinned" is the live edge, which history pagination uses to decide whether // "Pinned" is the live edge, which history pagination uses to decide whether
// it may load older pages without disturbing the read position. // it may load older pages without disturbing the read position.
@@ -163,19 +140,6 @@ export const useChatTimelineScroll = ({
// while `liveFollowGenerationRef` still equals it. // while `liveFollowGenerationRef` still equals it.
const userGenerationRef = React.useRef(0); const userGenerationRef = React.useRef(0);
const liveFollowGenerationRef = React.useRef<number | null>(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 showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const composerOverlayHeightRef = React.useRef(composerOverlayHeight); const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
@@ -215,23 +179,8 @@ export const useChatTimelineScroll = ({
}, SHOW_SCROLL_BUTTON_DELAY_MS); }, 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 // A real gesture: stop every automatic movement until the user opts back
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the // in.
// viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => { const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1; userGenerationRef.current += 1;
modeRef.current = 'free-scrolling'; modeRef.current = 'free-scrolling';
@@ -249,16 +198,6 @@ export const useChatTimelineScroll = ({
cancelShowButtonTimer(); cancelShowButtonTimer();
setShowScrollButton(true); 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]); }, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => ( const isLiveFollowActive = React.useCallback(() => (
@@ -327,7 +266,6 @@ export const useChatTimelineScroll = ({
modeRef.current = 'following-end'; modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow. // Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current; liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton(); hideScrollButton();
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' }); void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
// While a stream is growing the content, a single jump lands on the // 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 }); void listRef.current?.scrollToEnd({ animated: false });
}, delay)); }, delay));
} }
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]); }, [clearGoToBottomReasserts, hideScrollButton]);
// User preference: with auto-follow off, streaming growth never moves the // User preference: with auto-follow off, streaming growth never moves the
// viewport. Sending from the live edge still parks the new message at the // viewport. Sending from the live edge still lands on the end; sending
// top, but no glide or end-follow correction runs afterwards; sending from // from mid-history leaves the viewport untouched.
// mid-history leaves the viewport untouched.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled); const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled); const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled; streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// Sending arms the anchor. The message id is not known here (the optimistic // Sending is an explicit return to the live edge: the sent row and the
// row is created by the store), so the next new user message id claims it. // reply that follows it stay in view through ordinary end-follow.
// 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);
const scrollToBottomOnSend = React.useCallback(() => { const scrollToBottomOnSend = React.useCallback(() => {
// With auto-follow off, a reader who scrolled away from the end stays // 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 // exactly where they are; the scroll-to-bottom pill (already showing)
// scroll-to-bottom pill (already showing) leads to it. From the live // leads to the sent message.
// edge, sending anchors the new turn as usual.
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return; if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
anchorPositionInstantRef.current = !isAtEndRef.current; goToBottom('instant');
isAtEndRef.current = true; }, [goToBottom]);
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]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => { const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current; const sessionKey = currentSessionKeyRef.current;
@@ -412,11 +312,10 @@ export const useChatTimelineScroll = ({
setUserOwnsScroll(false); setUserOwnsScroll(false);
modeRef.current = 'following-end'; modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current; liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton(); hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false }); void listRef.current?.scrollToEnd({ animated: false });
return false; return false;
}, [clearAnchor, hideScrollButton]); }, [hideScrollButton]);
// ── list callbacks ────────────────────────────────────────────────────── // ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => { const registerList = React.useCallback((list: TimelineListHandle | null) => {
@@ -428,8 +327,8 @@ export const useChatTimelineScroll = ({
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => { const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our // While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline, the glide trails its // own doing (the glide trails its target between corrections) — not a
// target between corrections) — not a reason to offer the pill. Only a // reason to offer the pill. Only a
// real gesture (free-scrolling) shows it. // real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) { if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton(); hideScrollButton();
@@ -439,9 +338,7 @@ export const useChatTimelineScroll = ({
isAtEndRef.current = isAtEnd; isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd); setIsPinned(isAtEnd);
if (isAtEnd) { if (isAtEnd) {
if (modeRef.current !== 'anchoring-new-turn') { modeRef.current = 'following-end';
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current; liveFollowGenerationRef.current = userGenerationRef.current;
setUserOwnsScroll(false); setUserOwnsScroll(false);
hideScrollButton(); hideScrollButton();
@@ -453,105 +350,7 @@ export const useChatTimelineScroll = ({
queueSave(); queueSave();
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]); }, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it. // Whether the real rows are tall enough to scroll at all.
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.
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => { const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
const state = list.getState(); const state = list.getState();
if (state.data.length === 0) return false; if (state.data.length === 0) return false;
@@ -569,19 +368,10 @@ export const useChatTimelineScroll = ({
} }
const realContentBottom = lastTop + Math.max(1, lastHeight); const realContentBottom = lastTop + Math.max(1, lastHeight);
const visibleScrollLength = Math.max( const visibleScrollLength = Math.max(0, state.scrollLength - composerOverlayHeightRef.current);
0,
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
);
return realContentBottom > visibleScrollLength; 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 // 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 // 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 // still carries pre-wrap row sizes, so any end computed from it (the
@@ -612,7 +402,7 @@ export const useChatTimelineScroll = ({
quietTimer = setTimeout(() => { quietTimer = setTimeout(() => {
quietTimer = null; quietTimer = null;
widthResizingRef.current = false; widthResizingRef.current = false;
if (!isAtEndRef.current || pendingAnchorRef.current !== null) return; if (!isAtEndRef.current) return;
if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return; if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return;
const list = listRef.current; const list = listRef.current;
const state = list?.getState(); const state = list?.getState();
@@ -683,7 +473,6 @@ export const useChatTimelineScroll = ({
state, state,
composerOverlayHeight: composerOverlayHeightRef.current, composerOverlayHeight: composerOverlayHeightRef.current,
footerSize: listFooterSizeRef.current, footerSize: listFooterSizeRef.current,
extraInset: CHAT_LIST_ANCHOR_OFFSET,
}); });
if (offset !== null) { if (offset !== null) {
void list.scrollToOffset({ offset, animated: false }); 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 // block is several viewports tall, so every block left the reader a
// second behind and multiple screens above the live edge — measured // second behind and multiple screens above the live edge — measured
// at 45% of the stream time spent 500-1600px behind at 420x640. // at 45% of the stream time spent 500-1600px behind at 420x640.
if (modeRef.current === 'following-end') { if (modeRef.current !== 'following-end') return;
followEnd(); 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;
}
});
});
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]); }, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
// The streaming tail grows inside one row without changing the entries // 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: // A gesture is meaningful when the viewport can move up AT ALL:
// either the real rows overflow the viewport, or there is scrolled // either the real rows overflow the viewport, or there is scrolled
// history above (an anchored turn parks mid-conversation with // history above.
// 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).
const canScrollUp = () => { const canScrollUp = () => {
const list = listRef.current; const list = listRef.current;
if (!list) return false; if (!list) return false;
@@ -976,9 +711,8 @@ export const useChatTimelineScroll = ({
setUserOwnsScroll(false); setUserOwnsScroll(false);
modeRef.current = 'following-end'; modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current; liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton(); hideScrollButton();
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]); }, [currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the // Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction. // scroll position, so it does not jump on each correction.
@@ -989,10 +723,6 @@ export const useChatTimelineScroll = ({
React.useEffect(() => () => { React.useEffect(() => () => {
cancelShowButtonTimer(); cancelShowButtonTimer();
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); 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]); }, [cancelShowButtonTimer]);
// ── active-turn spy ───────────────────────────────────────────────────── // ── active-turn spy ─────────────────────────────────────────────────────
@@ -1074,9 +804,6 @@ export const useChatTimelineScroll = ({
scrollNode, scrollNode,
isPinned, isPinned,
registerList, registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange, onIsAtEndChange,
onListMetricsChange, onListMetricsChange,
onManualNavigation, onManualNavigation,