diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e2006a8c..75025b37 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -171,9 +171,6 @@ type ChatViewportProps = { scrollRef: React.RefObject; messageListRef: React.RefObject; 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 = ({ 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 = ({ currentSessionKey, sessionMessageCount, composerOverlayHeight, - lastUserMessageId, sessionIsWorking, revealGate, onActiveTurnChange: handleActiveTurnChange, @@ -1554,9 +1531,6 @@ export const ChatContainer: React.FC = ({ directory={effectiveSessionDirectory} scrollRef={scrollRef} registerList={registerList} - anchorMessageId={anchorMessageId} - onAnchorReady={onAnchorReady} - onAnchorSizeChanged={onAnchorSizeChanged} onIsAtEndChange={onIsAtEndChange} onListMetricsChange={onListMetricsChange} onTimelineDataChange={onTimelineDataChange} diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 58fc9e7f..8e4f9531 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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(); // • `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 = (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(({ directory, registerList, endPinningReleased = false, - anchorMessageId = null, - onAnchorReady, - onAnchorSizeChanged, composerOverlayHeight = 0, onIsAtEndChange, onListMetricsChange, @@ -1802,27 +1772,6 @@ const MessageList = React.forwardRef(({ }; }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); - const anchoredEndSpace = React.useMemo(() => { - 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(({ entries={allEntries} streamingTailKey={trailingStreamingEntry?.key ?? null} registerList={handleRegisterList} - anchoredEndSpace={anchoredEndSpace} composerOverlayHeight={composerOverlayHeight} onIsAtEndChange={stableIsAtEndChange} onListMetricsChange={stableListMetricsChange} diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts index 404a91ba..1377bec2 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts @@ -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, - }); - }); -}); diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts index 1d4efe30..1a6ebd2a 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts @@ -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 = ( - 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; -}; diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 7057ac67..b6f34e22 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -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(null); const [scrollNode, setScrollNode] = React.useState(null); - const [anchorMessageId, setAnchorMessageId] = React.useState(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(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(null); - const positionedAnchorRef = React.useRef(null); - const settledAnchorRef = React.useRef(null); - const activeAnchorIndexRef = React.useRef(null); - const pendingAnchorRestoreRef = React.useRef<{ - readonly messageId: string; - readonly offset: number; - readonly userGeneration: number; - } | null>(null); - const anchorRestoreFrameRef = React.useRef(null); const showButtonTimerRef = React.useRef | 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(lastUserMessageId); - const armBaselineUserMessageIdRef = React.useRef(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 => { 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,