From eb8b9ed715c0bc6845ad3746dec595d20e0cb53d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 13 May 2026 14:28:29 +0300 Subject: [PATCH] perf(ui): cache turn window model and fix virtualized scroll-to-bottom - Cache turnWindowModel per sessionId to skip rebuild on re-visit - Compensate scroll position after virtualizer measurement settles (RAF-based) so large uncached sessions open at bottom, not top - Add MessageListHandle.scrollToBottom via virtualizer API --- .../ui/src/components/chat/MessageList.tsx | 37 ++++++++++++++++++- .../chat/hooks/useChatTimelineController.ts | 23 +++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index b5e4ffa7..ebf73005 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -414,6 +414,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + scrollToBottom: () => void; } type RenderEntry = @@ -1343,7 +1344,31 @@ const MessageList = React.forwardRef(({ if (!shouldVirtualizeHistory) { return; } + const scrollEl = resolveScrollContainer(); + const prevTotal = historyVirtualizer.getTotalSize(); + const nearBottom = scrollEl && prevTotal > 0 + ? scrollEl.scrollTop + scrollEl.clientHeight >= prevTotal - 10 + : false + historyVirtualizer.measure(); + + // measure() defers via useAnimationFrameWithResizeObserver. + // Wait two frames then, if we were near the estimated bottom, scroll + // to the real bottom after measurements settle. + const frame1 = requestAnimationFrame(() => { + const frame2 = requestAnimationFrame(() => { + if (!nearBottom) return + const el = resolveScrollContainer() + if (!el) return + const target = Math.max(0, el.scrollHeight - el.clientHeight) + if (target > 0 && Math.abs(el.scrollTop - target) > 5) { + el.scrollTop = target + } + }) + }) + return () => { + cancelAnimationFrame(frame1) + } }, [historyVirtualizer, shouldVirtualizeHistory]); const scheduleVirtualMeasure = React.useCallback(() => { @@ -1615,6 +1640,16 @@ const MessageList = React.forwardRef(({ return applyAnchor(); }, + + scrollToBottom: () => { + if (shouldVirtualizeHistory && historyEntries.length > 0) { + historyVirtualizer.scrollToIndex(historyEntries.length - 1, { align: 'end' }); + return; + } + const container = resolveScrollContainer(); + if (!container) return; + container.scrollTop = container.scrollHeight; + }, }; if (typeof ref === 'function') { @@ -1629,7 +1664,7 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, trailingStreamingEntry, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, historyVirtualizer, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, shouldVirtualizeHistory, trailingStreamingEntry, turnIndexMap, ref]); const disableFadeIn = false; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index b95f4a2d..463e227b 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -59,6 +59,9 @@ export interface UseChatTimelineControllerResult { handleActiveTurnChange: (turnId: string | null) => void; } +const TURN_MODEL_CACHE_MAX = 30 +const turnModelCache = new Map() + export const useChatTimelineController = ({ sessionId, messages, @@ -74,6 +77,14 @@ export const useChatTimelineController = ({ const previousTurnWindowModelRef = React.useRef(null); const previousMessagesRef = React.useRef(null); const turnWindowModel = React.useMemo(() => { + const key = sessionId ?? "" + const cached = key ? turnModelCache.get(key) : undefined + if (cached && cached.messages === messages) { + previousTurnWindowModelRef.current = cached.model + previousMessagesRef.current = messages + return cached.model + } + const incrementalModel = updateTurnWindowModelIncremental( previousTurnWindowModelRef.current, previousMessagesRef.current, @@ -82,8 +93,18 @@ export const useChatTimelineController = ({ const nextModel = incrementalModel ?? buildTurnWindowModel(messages); previousTurnWindowModelRef.current = nextModel; previousMessagesRef.current = messages; + + if (key && messages.length > 0) { + // LRU-like eviction: delete oldest when at capacity + if (turnModelCache.size >= TURN_MODEL_CACHE_MAX) { + const oldest = turnModelCache.keys().next().value + if (oldest !== undefined) turnModelCache.delete(oldest) + } + turnModelCache.set(key, { messages, model: nextModel }) + } + return nextModel; - }, [messages]); + }, [messages, sessionId]); const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount)); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);