diff --git a/package.json b/package.json index 6c497902..2f27fb32 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@codemirror/view": "^6.41.1", "@opencode-ai/sdk": "^1.16.0", "@streamdown/math": "^1.0.2", + "@tanstack/react-virtual": "^3.14.3", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", diff --git a/src/constants/ui.ts b/src/constants/ui.ts index 14ed52af..6146e414 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -1,2 +1,14 @@ -/** 底部判断阈值 */ +/** + * 底部判断阈值 —— 三个功能共用同一个值: + * 1. to-bottom 按钮:用户滚动超出该距离,按钮显示 + * 2. auto-follow:新内容是否自动跟随(userScrolled 在该阈值内为 false) + * 3. auto-snap:内容增长时,接近底部是否自动吸附到底部 + */ export const AT_BOTTOM_THRESHOLD_PX = 60 + +/** + * Sub-pixel 容差 —— 用于"scrollTop 是否本质上等于标记的 bottom 位置", + * 区分"我们自己 scrollToBottom 触发的 scroll"和"用户的 scroll"。 + * 与 AT_BOTTOM_THRESHOLD_PX 是不同概念,单独命名避免 magic number。 + */ +export const BOTTOM_NUDGE_TOLERANCE_PX = 2 diff --git a/src/features/chat/ChatArea.test.ts b/src/features/chat/ChatArea.test.ts index 8b31bf70..4b6ff7a5 100644 --- a/src/features/chat/ChatArea.test.ts +++ b/src/features/chat/ChatArea.test.ts @@ -183,12 +183,12 @@ describe('buildChatPages', () => { const pages = buildChatPages(messages, 2) - // render order: newest page first + // render order: oldest page first expect(pages).toHaveLength(3) - expect(pages[0].messageIds).toEqual(['user-2', 'assistant-3']) + expect(pages[0].messageIds).toEqual(['user-1']) // assistant-1 + assistant-2 应该保持在同一个 group/page,不被拆开 expect(pages[1].messageIds).toEqual(['assistant-1', 'assistant-2']) - expect(pages[2].messageIds).toEqual(['user-1']) + expect(pages[2].messageIds).toEqual(['user-2', 'assistant-3']) }) }) @@ -441,7 +441,8 @@ describe('reconcileStableChatPages', () => { ] const nextPages = reconcileStableChatPages({ currentPages, nextMessages, allocateKey: alloc, pageMessageCount: 2 }) - expect(nextPages.slice(0, currentPages.length).map(page => page.key)).toEqual(currentPages.map(page => page.key)) + // 老的页在 oldest-first 中应保持末尾(新消息 prepend 到开头) + expect(nextPages.slice(nextPages.length - currentPages.length).map(page => page.key)).toEqual(currentPages.map(page => page.key)) }) it('only rebuilds the newest page segment when appending new messages', () => { @@ -456,8 +457,8 @@ describe('reconcileStableChatPages', () => { const nextMessages = [...currentMessages, createUserMessage('user-3', 7), createAssistantMessage('assistant-3', [], 8, 9)] const nextPages = reconcileStableChatPages({ currentPages, nextMessages, allocateKey: alloc, pageMessageCount: 2 }) - // 老的第二页 key 应该保住,避免整段历史一起重切 - expect(nextPages[nextPages.length - 1].key).toBe(currentPages[currentPages.length - 1].key) + // 最旧的页 key 应该保住,避免整段历史一起重切 + expect(nextPages[0].key).toBe(currentPages[0].key) }) }) diff --git a/src/features/chat/ChatArea.tsx b/src/features/chat/ChatArea.tsx index 48595b97..cba2be73 100644 --- a/src/features/chat/ChatArea.tsx +++ b/src/features/chat/ChatArea.tsx @@ -24,28 +24,24 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { animate } from 'motion/mini' +import { useVirtualizer } from '@tanstack/react-virtual' import { MessageRenderer } from '../message' import { MessageErrorView } from '../message/parts' import { messageStore } from '../../store' import { useTheme } from '../../hooks/useTheme' +import { useAutoScroll } from '../../hooks/useAutoScroll' +import { useScrollGestureDetector } from './useScrollGestureDetector' +import { useMobileChatPagerGestureBridge } from './useMobileChatPagerGestureBridge' import type { Message, MessageError } from '../../types/message' import { RetryStatusInline, type RetryStatusInlineData } from './RetryStatusInline' import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility' import { AT_BOTTOM_THRESHOLD_PX } from '../../constants' import { useChatViewport } from './chatViewport' import { - PREMEASURE_PAGE_RADIUS, buildContentKeyedChatPages, - buildExpandedPageSelection, - buildPageOffsets, - buildPageRenderSegments, - computeAnchorRestoreScrollDelta, buildTurnDurationMap, - computeExpandedPageRange, - computePremeasureMessageBudget, - findPagesToPremeasure, + computeAnchorRestoreScrollDelta, seedMeasuredPageHeightsFromPreviousPages, - type PagePremeasureDirection, type ChatPage, type StableChatPage, } from './chatPageModel' @@ -54,7 +50,6 @@ const LOAD_MORE_ROOT_MARGIN = '240px 0px 0px 0px' const LOAD_MORE_WHEEL_COOLDOWN_MS = 90 const LOAD_MORE_DEFER_MS = 100 const PENDING_SCROLL_TARGET_KEEPALIVE_MS = 900 -const RESIZE_PREMEASURE_PAUSE_MS = 420 type LoadMoreAnchorSnapshot = { messageId: string @@ -65,12 +60,6 @@ type LoadMoreAnchorSnapshot = { /** Stable no-op to avoid creating a new closure on every render. */ const NOOP = () => {} -function pageHasStreamingMessage(page: ChatPage): boolean { - return page.rows.some(row => - row.messages.some(message => message.isStreaming || (message.info.role === 'assistant' && message.info.time.completed == null)), - ) -} - function captureLoadMoreAnchor(root: HTMLElement, pageCountBefore = 0): LoadMoreAnchorSnapshot | null { const rootRect = root.getBoundingClientRect() const candidates = root.querySelectorAll('[data-message-id]') @@ -113,7 +102,6 @@ interface ChatAreaProps { canUndo?: boolean registerMessage?: (id: string, element: HTMLElement | null) => void retryStatus?: RetryStatusInlineData | null - bottomPadding?: number onVisibleMessageIdsChange?: (ids: string[]) => void onAtBottomChange?: (atBottom: boolean) => void } @@ -149,7 +137,6 @@ export const ChatArea = memo( hasMoreHistory: _hasMoreHistory = false, registerMessage, retryStatus = null, - bottomPadding = 0, onVisibleMessageIdsChange, onAtBottomChange, }, @@ -163,27 +150,16 @@ export const ChatArea = memo( const loadMoreRef = useRef(onLoadMore) const isLoadingRef = useRef(false) const [isLoadingMore, setIsLoadingMore] = useState(false) - const [scrollOffsetFromBottom, setScrollOffsetFromBottom] = useState(0) - const [premeasureDirection, setPremeasureDirection] = useState('idle') - const [viewportHeight, setViewportHeight] = useState(0) const [measuredPageHeights, setMeasuredPageHeights] = useState>({}) - const [staleMeasuredPageKeys, setStaleMeasuredPageKeys] = useState>(() => new Set()) - const [forcedPremeasurePageKeys, setForcedPremeasurePageKeys] = useState>(() => new Set()) const [pendingScrollMessageId, setPendingScrollMessageId] = useState(null) - const [pendingLoadMoreAnchorMessageId, setPendingLoadMoreAnchorMessageId] = useState(null) - const [isResizePremeasurePaused, setIsResizePremeasurePaused] = useState(false) const scrollSnapshotRafRef = useRef(null) const pendingLoadMoreAnchorRef = useRef(null) - const pendingLayoutAnchorRef = useRef(null) const pendingLoadMoreTimerRef = useRef(null) const pendingScrollClearTimerRef = useRef(null) const pendingAnchorClearRafRef = useRef(null) const pendingSessionResetRafRef = useRef(null) - const resizePremeasurePauseTimerRef = useRef(null) - const lastScrollRootSizeRef = useRef({ width: 0, height: 0 }) const measuredPageHeightKeysRef = useRef([]) const previousActivePagesRef = useRef<{ sessionId?: string | null; pages: StableChatPage[] }>({ pages: [] }) - const lastStreamingPageKeysRef = useRef>(new Set()) const settlingScrollMessageIdRef = useRef(null) const loadMoreRequestIdRef = useRef(0) const topSentinelVisibleRef = useRef(false) @@ -197,10 +173,17 @@ export const ChatArea = memo( const loadMoreBlockedRef = useRef(true) const { isWideMode } = useTheme() - const { presentation } = useChatViewport() - const atBottomThreshold = presentation.isCompact ? 150 : AT_BOTTOM_THRESHOLD_PX - const messagePaddingClass = presentation.isCompact ? 'px-3' : 'px-5' + const { presentation, interaction } = useChatViewport() + const atBottomThreshold = AT_BOTTOM_THRESHOLD_PX + const messagePaddingClass = presentation.isCompact ? 'px-3' : 'px-6' const messageMaxWidthClass = isWideMode ? 'max-w-[95%] xl:max-w-6xl' : 'max-w-2xl' + const autoScroll = useAutoScroll({ + working: _isStreaming, + reverse: false, + bottomThreshold: atBottomThreshold, + overflowAnchor: 'dynamic', + }) + const gestureDetector = useScrollGestureDetector() const shouldUseExternalViewModel = pageRecords != null && visibleMessagesProp != null const visibleMessageEntries = useMemo( () => (shouldUseExternalViewModel ? [] : buildVisibleMessageEntries(messages)), @@ -244,108 +227,28 @@ export const ChatArea = memo( }) }, [activePages, sessionId]) - const pendingTargetPageIndex = useMemo( - () => - pendingScrollMessageId == null ? -1 : activePages.findIndex(page => page.messageIds.includes(pendingScrollMessageId)), - [activePages, pendingScrollMessageId], - ) - - const pendingLoadMoreAnchorPageIndex = useMemo( - () => - pendingLoadMoreAnchorMessageId == null - ? -1 - : activePages.findIndex(page => page.messageIds.includes(pendingLoadMoreAnchorMessageId)), - [activePages, pendingLoadMoreAnchorMessageId], - ) - - const expandedPageRange = useMemo( - () => - computeExpandedPageRange({ - pages: activePages, - measuredPageHeights, - scrollOffsetFromBottom, - viewportHeight, - }), - [activePages, measuredPageHeights, scrollOffsetFromBottom, viewportHeight], - ) - - const expandedPageSelection = useMemo( - () => buildExpandedPageSelection(expandedPageRange, [pendingTargetPageIndex, pendingLoadMoreAnchorPageIndex]), - [expandedPageRange, pendingLoadMoreAnchorPageIndex, pendingTargetPageIndex], - ) - - const streamingPageKeys = useMemo(() => { - const keys = new Set() - for (const page of activePages) { - if (pageHasStreamingMessage(page)) keys.add(page.key) - } - return keys - }, [activePages]) - - useLayoutEffect(() => { - if (streamingPageKeys.size > 0) { - lastStreamingPageKeysRef.current = streamingPageKeys - return - } - - const completedKeys = lastStreamingPageKeysRef.current - if (completedKeys.size === 0) return - - lastStreamingPageKeysRef.current = new Set() - setForcedPremeasurePageKeys(previous => { - const next = new Set(previous) - for (const key of completedKeys) next.add(key) - return next - }) - }, [streamingPageKeys]) - - const renderSegments = useMemo( - () => - buildPageRenderSegments({ - pages: activePages, - expandedPageSelection, - measuredPageHeights, - }), - [activePages, expandedPageSelection, measuredPageHeights], - ) - - const pagesToPremeasure = useMemo(() => { - if (isResizePremeasurePaused) return [] - return findPagesToPremeasure({ - pages: activePages, - expandedPageRange, - measuredPageHeights, - stalePageKeys: staleMeasuredPageKeys, - direction: premeasureDirection, - radius: PREMEASURE_PAGE_RADIUS, - messageBudget: computePremeasureMessageBudget(viewportHeight), - }) - }, [activePages, expandedPageRange, isResizePremeasurePaused, measuredPageHeights, premeasureDirection, staleMeasuredPageKeys, viewportHeight]) - - const forcedPagesToPremeasure = useMemo(() => { - if (streamingPageKeys.size === 0 && forcedPremeasurePageKeys.size === 0) return [] - - const pagesToForce: StableChatPage[] = [] - for (let index = 0; index < activePages.length; index++) { + // Virtualizer for page-level DOM management and scroll anchoring. + // Each page is a virtual item. `anchorTo: 'end'` pins the bottom when + // the last item grows (streaming). `measureElement` tracks dynamic heights. + const pageVirtualizer = useVirtualizer({ + count: activePages.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => { const page = activePages[index] - if (expandedPageSelection.has(index)) continue - if (streamingPageKeys.has(page.key) || forcedPremeasurePageKeys.has(page.key)) pagesToForce.push(page) - } - return pagesToForce - }, [activePages, expandedPageSelection, forcedPremeasurePageKeys, streamingPageKeys]) - - const effectivePagesToPremeasure = useMemo(() => { - if (forcedPagesToPremeasure.length === 0) return pagesToPremeasure - - const seen = new Set(pagesToPremeasure.map(page => page.key)) - const merged = [...pagesToPremeasure] - for (const page of forcedPagesToPremeasure) { - if (seen.has(page.key)) continue - seen.add(page.key) - merged.push(page) - } - return merged - }, [forcedPagesToPremeasure, pagesToPremeasure]) + return measuredPageHeights[page.key] ?? page.estimatedHeight + }, + getItemKey: (index) => activePages[index]?.key ?? index, + anchorTo: 'end', + followOnAppend: true, + overscan: 20, + scrollEndThreshold: atBottomThreshold, + }) + + // When an item above the viewport changes size, adjust scrollTop to keep + // the visible content stable. Items below the viewport changing size + // should NOT trigger any scroll adjustment. This is the core jitter fix. + pageVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => + item.end <= (instance.scrollOffset ?? 0) const clearPendingLoadMoreTimer = useCallback(() => { if (pendingLoadMoreTimerRef.current === null) return @@ -353,26 +256,19 @@ export const ChatArea = memo( pendingLoadMoreTimerRef.current = null }, []) - const clearPendingScrollTimer = useCallback(() => { - if (pendingScrollClearTimerRef.current === null) return - window.clearTimeout(pendingScrollClearTimerRef.current) - pendingScrollClearTimerRef.current = null - }, []) - - const clearResizePremeasurePauseTimer = useCallback(() => { - if (resizePremeasurePauseTimerRef.current === null) return - window.clearTimeout(resizePremeasurePauseTimerRef.current) - resizePremeasurePauseTimerRef.current = null - }, []) - const clearPendingLoadMoreAnchorMessage = useCallback(() => { if (pendingAnchorClearRafRef.current !== null) cancelAnimationFrame(pendingAnchorClearRafRef.current) pendingAnchorClearRafRef.current = requestAnimationFrame(() => { pendingAnchorClearRafRef.current = null - setPendingLoadMoreAnchorMessageId(null) }) }, []) + const clearPendingScrollTimer = useCallback(() => { + if (pendingScrollClearTimerRef.current === null) return + window.clearTimeout(pendingScrollClearTimerRef.current) + pendingScrollClearTimerRef.current = null + }, []) + const resetSessionViewState = useCallback(() => { if (pendingSessionResetRafRef.current !== null) cancelAnimationFrame(pendingSessionResetRafRef.current) pendingSessionResetRafRef.current = requestAnimationFrame(() => { @@ -380,11 +276,7 @@ export const ChatArea = memo( setIsLoadingMore(false) measuredPageHeightKeysRef.current = [] setMeasuredPageHeights({}) - setStaleMeasuredPageKeys(new Set()) - setForcedPremeasurePageKeys(new Set()) setPendingScrollMessageId(null) - setPremeasureDirection('idle') - setIsResizePremeasurePaused(false) }) }, []) @@ -392,96 +284,112 @@ export const ChatArea = memo( return () => { clearPendingLoadMoreTimer() clearPendingScrollTimer() - clearResizePremeasurePauseTimer() if (scrollSnapshotRafRef.current !== null) cancelAnimationFrame(scrollSnapshotRafRef.current) if (pendingAnchorClearRafRef.current !== null) cancelAnimationFrame(pendingAnchorClearRafRef.current) if (pendingSessionResetRafRef.current !== null) cancelAnimationFrame(pendingSessionResetRafRef.current) + if (heightBatchRafRef.current !== null) cancelAnimationFrame(heightBatchRafRef.current) } - }, [clearPendingLoadMoreTimer, clearPendingScrollTimer, clearResizePremeasurePauseTimer]) + }, [clearPendingLoadMoreTimer, clearPendingScrollTimer]) const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { scrollRef.current = node setScrollRoot(prev => (prev === node ? prev : node)) - }, []) + autoScroll.scrollRef(node) + gestureDetector.setRoot(node) + }, [autoScroll, gestureDetector]) + + useMobileChatPagerGestureBridge( + scrollRef, + interaction.sidebarBehavior === 'overlay', + scrollRoot, + ) + + const setContentWrapperRef = useCallback((node: HTMLDivElement | null) => { + autoScroll.contentRef(node) + }, [autoScroll]) const updateScrollOffsetSnapshot = useCallback(() => { const root = scrollRef.current if (!root) return - const nextOffset = Math.abs(root.scrollTop) if (scrollSnapshotRafRef.current !== null) cancelAnimationFrame(scrollSnapshotRafRef.current) scrollSnapshotRafRef.current = requestAnimationFrame(() => { scrollSnapshotRafRef.current = null - setScrollOffsetFromBottom(prev => { - const delta = nextOffset - prev - if (Math.abs(delta) < 1) return prev - setPremeasureDirection(delta > 0 ? 'older' : 'newer') - return nextOffset - }) + // no-op: kept for forward compat (was used for premeasure direction) }) }, []) - useEffect(() => { - const root = scrollRoot - if (!root || typeof ResizeObserver === 'undefined') return - - const syncViewport = () => { - const nextSize = { width: root.clientWidth, height: root.clientHeight } - const previousSize = lastScrollRootSizeRef.current - const hasPreviousSize = previousSize.width > 0 || previousSize.height > 0 - const widthChanged = Math.abs(previousSize.width - nextSize.width) >= 1 - const heightChanged = Math.abs(previousSize.height - nextSize.height) >= 1 - - if (widthChanged || heightChanged) { - lastScrollRootSizeRef.current = nextSize - if (hasPreviousSize) { - setIsResizePremeasurePaused(true) - clearResizePremeasurePauseTimer() - resizePremeasurePauseTimerRef.current = window.setTimeout(() => { - resizePremeasurePauseTimerRef.current = null - setStaleMeasuredPageKeys(new Set(measuredPageHeightKeysRef.current)) - setIsResizePremeasurePaused(false) - }, RESIZE_PREMEASURE_PAUSE_MS) - } - } - - setViewportHeight(prev => (Math.abs(prev - nextSize.height) < 1 ? prev : nextSize.height)) - } + const handleScrollContainerScroll = useCallback(() => { + // OpenCode-style gate: only process scroll events that were triggered + // by a recent user gesture (wheel, touch, pointer). Virtualizer-driven + // scroll adjustments (from measureElement / anchorTo) generate scroll + // events too — those must be ignored, otherwise handleScroll sees the + // user "away from bottom" and calls stop(), killing auto-follow. + if (!gestureDetector.hasGesture()) return + autoScroll.handleScroll() + updateScrollOffsetSnapshot() + }, [autoScroll, gestureDetector, updateScrollOffsetSnapshot]) + + const handleScrollContainerWheel = useCallback( + (event: React.WheelEvent) => { + gestureDetector.onWheel({ + deltaY: event.deltaY, + deltaMode: event.deltaMode, + target: event.target, + }) + // Feed load-more cooldown (also depends on wheel, not just touch/key). + lastWheelInputAtRef.current = Date.now() + }, + [gestureDetector], + ) - syncViewport() - const observer = new ResizeObserver(syncViewport) - observer.observe(root) - return () => observer.disconnect() - }, [clearResizePremeasurePauseTimer, scrollRoot]) + const handleScrollContainerPointerDown = useCallback( + (event: React.PointerEvent) => { + gestureDetector.onPointerDown({ target: event.target }) + }, + [gestureDetector], + ) - useEffect(() => { - const root = scrollRef.current - if (!root) return + const handleScrollContainerTouchStart = useCallback( + (event: React.TouchEvent) => { + gestureDetector.onTouchStart({ touches: event.touches }) + }, + [gestureDetector], + ) - const onScroll = () => { - const hasOverflow = root.scrollHeight > root.clientHeight + 1 - const distFromBottom = Math.abs(root.scrollTop) - const atBottom = !hasOverflow || distFromBottom <= atBottomThreshold - const previous = isAtBottomRef.current - isAtBottomRef.current = atBottom - if (previous !== atBottom) onAtBottomChange?.(atBottom) + const handleScrollContainerTouchMove = useCallback( + (event: React.TouchEvent) => { + gestureDetector.onTouchMove({ touches: event.touches, target: event.target }) + }, + [gestureDetector], + ) - if (!atBottom) loadMoreBlockedRef.current = false - updateScrollOffsetSnapshot() - } + const handleScrollContainerTouchEnd = useCallback(() => { + gestureDetector.onTouchEnd() + }, [gestureDetector]) - const onWheel = () => { - lastWheelInputAtRef.current = Date.now() - } + const handleScrollContainerKeyDown = useCallback( + (event: React.KeyboardEvent) => { + gestureDetector.onKeyDown({ key: event.key }) + }, + [gestureDetector], + ) - root.addEventListener('scroll', onScroll, { passive: true }) - root.addEventListener('wheel', onWheel, { passive: true }) + // Sync the offset snapshot once on mount / scrollRoot change so the + // first frame has accurate data. + useEffect(() => { updateScrollOffsetSnapshot() - return () => { - root.removeEventListener('scroll', onScroll) - root.removeEventListener('wheel', onWheel) - } - }, [atBottomThreshold, onAtBottomChange, updateScrollOffsetSnapshot]) + }, [scrollRoot, updateScrollOffsetSnapshot]) + + // Bridge `autoScroll.userScrolled` to the existing at-bottom state, + // the legacy `isAtBottomRef` consumers, and the load-more gate. + useEffect(() => { + const atBottom = !autoScroll.userScrolled + const previous = isAtBottomRef.current + isAtBottomRef.current = atBottom + if (previous !== atBottom) onAtBottomChange?.(atBottom) + if (!atBottom) loadMoreBlockedRef.current = false + }, [autoScroll.userScrolled, onAtBottomChange]) const prevSessionIdRef = useRef(sessionId) useEffect(() => { @@ -491,7 +399,6 @@ export const ChatArea = memo( loadMoreBlockedRef.current = true pendingLoadMoreAnchorRef.current = null previousActivePagesRef.current = { sessionId, pages: [] } - lastStreamingPageKeysRef.current = new Set() clearPendingLoadMoreAnchorMessage() topSentinelVisibleRef.current = false loadMoreRequestIdRef.current += 1 @@ -506,7 +413,7 @@ export const ChatArea = memo( requestAnimationFrame(() => { const root = scrollRef.current if (!root) return - root.scrollTop = 0 + root.scrollTop = root.scrollHeight updateScrollOffsetSnapshot() animate(root, { opacity: [0, 1] }, { duration: 0.2, ease: 'easeOut' }) }) @@ -527,12 +434,44 @@ export const ChatArea = memo( requestAnimationFrame(() => { const root = scrollRef.current if (root && isAtBottomRef.current) { - root.scrollTop = 0 + root.scrollTop = root.scrollHeight updateScrollOffsetSnapshot() } }) }, [loadState, updateScrollOffsetSnapshot]) + // Core load-more logic: capture the current top-of-viewport anchor so + // the user's scroll position doesn't shift when the new history is + // prepended, then fire the actual `onLoadMore` callback. Returns a + // Promise that resolves when the fetch settles (or immediately if + // one of the no-op guards short-circuits). Shared by the + // IntersectionObserver-driven `tryLoadMore` (which adds user- + // engagement gates before reaching here) and the explicit + // `loadMoreHistory` imperative handle (which bypasses them). + const performLoadMore = useCallback((): Promise => { + const fn = loadMoreRef.current + if (!fn) return Promise.resolve() + const sid = sessionId + if (!sid) return Promise.resolve() + if (isLoadingRef.current) return Promise.resolve() + + const root = scrollRef.current + if (root) { + const anchor = captureLoadMoreAnchor(root, activePages.length) + pendingLoadMoreAnchorRef.current = anchor + } + + const requestId = ++loadMoreRequestIdRef.current + const requestSessionId = sid + isLoadingRef.current = true + setIsLoadingMore(true) + return Promise.resolve(fn()).finally(() => { + if (loadMoreRequestIdRef.current !== requestId || sessionId !== requestSessionId) return + isLoadingRef.current = false + setIsLoadingMore(false) + }) + }, [activePages.length, sessionId]) + const tryLoadMore = useCallback(() => { if (isLoadingRef.current) return if (!topSentinelVisibleRef.current) return @@ -556,23 +495,8 @@ export const ChatArea = memo( return } - const root = scrollRef.current - if (root) { - const anchor = captureLoadMoreAnchor(root, activePages.length) - pendingLoadMoreAnchorRef.current = anchor - setPendingLoadMoreAnchorMessageId(anchor?.messageId ?? null) - } - - const requestId = ++loadMoreRequestIdRef.current - const requestSessionId = sid - isLoadingRef.current = true - setIsLoadingMore(true) - Promise.resolve(fn()).finally(() => { - if (loadMoreRequestIdRef.current !== requestId || sessionId !== requestSessionId) return - isLoadingRef.current = false - setIsLoadingMore(false) - }) - }, [activePages.length, clearPendingLoadMoreTimer, sessionId]) + performLoadMore() + }, [clearPendingLoadMoreTimer, performLoadMore, sessionId]) useEffect(() => { tryLoadMoreRef.current = tryLoadMore @@ -624,24 +548,6 @@ export const ChatArea = memo( } }, [activePages, clearPendingLoadMoreAnchorMessage, updateScrollOffsetSnapshot]) - useLayoutEffect(() => { - const anchor = pendingLayoutAnchorRef.current - const root = scrollRef.current - if (!anchor || !root) return - - const target = root.querySelector(`[data-message-id="${anchor.messageId}"]`) - pendingLayoutAnchorRef.current = null - if (!target) return - - const rootRect = root.getBoundingClientRect() - const nextTopOffset = target.getBoundingClientRect().top - rootRect.top - const delta = computeAnchorRestoreScrollDelta(anchor.topOffset, nextTopOffset) - if (Math.abs(delta) >= 1) { - root.scrollTop += delta - updateScrollOffsetSnapshot() - } - }, [activePages, measuredPageHeights, renderSegments, updateScrollOffsetSnapshot]) - const onVisibleIdsChangeRef = useRef(onVisibleMessageIdsChange) useEffect(() => { onVisibleIdsChangeRef.current = onVisibleMessageIdsChange @@ -677,7 +583,7 @@ export const ChatArea = memo( elements.forEach(element => observer.observe(element)) return () => observer.disconnect() - }, [activePages, expandedPageRange.endIndex, expandedPageRange.startIndex]) + }, [activePages]) useEffect(() => { if (!pendingScrollMessageId) return @@ -694,32 +600,55 @@ export const ChatArea = memo( settlingScrollMessageIdRef.current = null setPendingScrollMessageId(current => (current === pendingScrollMessageId ? null : current)) }, PENDING_SCROLL_TARGET_KEEPALIVE_MS) - }, [activePages, clearPendingScrollTimer, expandedPageRange.endIndex, expandedPageRange.startIndex, pendingScrollMessageId]) + }, [activePages, clearPendingScrollTimer, pendingScrollMessageId]) + + const scrollToBottomRef = useRef(autoScroll.scrollToBottom) + scrollToBottomRef.current = autoScroll.scrollToBottom + + // rAF-batched height collection. Multiple pages can grow in the same + // frame (SmoothHeight animations, streaming tokens). Collect all + // pending heights and commit them once per frame so we trigger only one + // setMeasuredPageHeights, one React render, and one scrollToBottom. + const pendingHeightsRef = useRef | null>(null) + const heightBatchRafRef = useRef(null) const updateMeasuredPageHeight = useCallback((pageKey: string, nextHeight: number) => { if (nextHeight <= 0) return - setMeasuredPageHeights(previous => { - const current = previous[pageKey] ?? null - if (current !== null && Math.abs(current - nextHeight) < 1) return previous - const root = scrollRef.current - if (root && !isAtBottomRef.current) { - pendingLayoutAnchorRef.current = captureLoadMoreAnchor(root) - } - const next = { ...previous, [pageKey]: nextHeight } - measuredPageHeightKeysRef.current = Object.keys(next) - return next - }) - setForcedPremeasurePageKeys(previous => { - if (!previous.has(pageKey)) return previous - const next = new Set(previous) - next.delete(pageKey) - return next - }) - setStaleMeasuredPageKeys(previous => { - if (!previous.has(pageKey)) return previous - const next = new Set(previous) - next.delete(pageKey) - return next + + // Collect heights in a ref — no state update yet. This avoids N + // setMeasuredPageHeights calls and N React renders per frame. + if (pendingHeightsRef.current === null) { + pendingHeightsRef.current = {} + } + pendingHeightsRef.current[pageKey] = nextHeight + + if (heightBatchRafRef.current !== null) return // rAF already scheduled + + heightBatchRafRef.current = requestAnimationFrame(() => { + heightBatchRafRef.current = null + const pending = pendingHeightsRef.current + pendingHeightsRef.current = null + if (!pending) return + + // Commit all collected heights in a single state update. + setMeasuredPageHeights(previous => { + let changed = false + const next = { ...previous } + for (const [key, height] of Object.entries(pending)) { + const current = previous[key] ?? null + if (current !== null && Math.abs(current - height) < 1) continue + next[key] = height + changed = true + } + if (!changed) return previous + measuredPageHeightKeysRef.current = Object.keys(next) + return next + }) + + // scrollHeight already reflects the new content — scroll now so the + // bottom stays visible. Only one scroll per frame regardless of how + // many pages grew. + scrollToBottomRef.current() }) }, []) @@ -734,32 +663,38 @@ export const ChatArea = memo( return } + if (!activePages.length) return + const targetPageIndex = activePages.findIndex(page => page.messageIds.includes(messageId)) if (targetPageIndex === -1) return - const pageOffsets = buildPageOffsets(activePages, measuredPageHeights) - root.scrollTo({ top: -pageOffsets[targetPageIndex], behavior: behavior === 'smooth' ? 'auto' : behavior }) + // Use the virtualizer to scroll to the page containing the target + // message. The virtualizer handles scroll-position math internally. + pageVirtualizer.scrollToIndex(targetPageIndex, { align: 'start' }) updateScrollOffsetSnapshot() settlingScrollMessageIdRef.current = null clearPendingScrollTimer() setPendingScrollMessageId(messageId) }, - [activePages, clearPendingScrollTimer, measuredPageHeights, updateScrollOffsetSnapshot], + [activePages, clearPendingScrollTimer, pageVirtualizer, updateScrollOffsetSnapshot], ) useImperativeHandle( ref, () => ({ - scrollToBottom: (instant = false) => { - const root = scrollRef.current - if (!root) return - root.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' }) + scrollToBottom: (_instant = false) => { + // The user explicitly asked to follow the bottom again — clear + // `userScrolled` and snap there immediately. The legacy `instant` + // param is ignored: a "go to bottom" button shouldn't smooth-scroll + // past the entire conversation. + autoScroll.resume() }, scrollToBottomIfAtBottom: () => { - const root = scrollRef.current - if (!root) return - if (Math.abs(root.scrollTop) > 2) return - root.scrollTop = 0 + // SSE "scroll request" handler: only follow if the user hasn't + // intentionally scrolled away. Gated by `userScrolled` rather than + // a numerical `scrollTop` check — semantically the same in + // practice, but resilient to resize-induced position drift. + autoScroll.scrollToBottom() }, scrollToLastMessage: () => { if (visibleMessages.length === 0) return @@ -774,31 +709,11 @@ export const ChatArea = memo( requestScrollToMessage(messageId, 'smooth') }, }), - [requestScrollToMessage, visibleMessages], + [autoScroll, requestScrollToMessage, visibleMessages], ) return ( -
- {effectivePagesToPremeasure.length > 0 && ( - - )} +
{loadState === 'loading' && visibleMessages.length === 0 && (
@@ -811,28 +726,70 @@ export const ChatArea = memo(
-
- -
0 ? `${bottomPadding + 48}px` : '256px', - }} - /> +