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
This commit is contained in:
@@ -414,6 +414,7 @@ export interface MessageListHandle {
|
|||||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
||||||
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
||||||
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
||||||
|
scrollToBottom: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderEntry =
|
type RenderEntry =
|
||||||
@@ -1343,7 +1344,31 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
if (!shouldVirtualizeHistory) {
|
if (!shouldVirtualizeHistory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const scrollEl = resolveScrollContainer();
|
||||||
|
const prevTotal = historyVirtualizer.getTotalSize();
|
||||||
|
const nearBottom = scrollEl && prevTotal > 0
|
||||||
|
? scrollEl.scrollTop + scrollEl.clientHeight >= prevTotal - 10
|
||||||
|
: false
|
||||||
|
|
||||||
historyVirtualizer.measure();
|
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]);
|
}, [historyVirtualizer, shouldVirtualizeHistory]);
|
||||||
|
|
||||||
const scheduleVirtualMeasure = React.useCallback(() => {
|
const scheduleVirtualMeasure = React.useCallback(() => {
|
||||||
@@ -1615,6 +1640,16 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
|
|
||||||
return applyAnchor();
|
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') {
|
if (typeof ref === 'function') {
|
||||||
@@ -1629,7 +1664,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return () => {
|
return () => {
|
||||||
objectRef.current = null;
|
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;
|
const disableFadeIn = false;
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export interface UseChatTimelineControllerResult {
|
|||||||
handleActiveTurnChange: (turnId: string | null) => void;
|
handleActiveTurnChange: (turnId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TURN_MODEL_CACHE_MAX = 30
|
||||||
|
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
||||||
|
|
||||||
export const useChatTimelineController = ({
|
export const useChatTimelineController = ({
|
||||||
sessionId,
|
sessionId,
|
||||||
messages,
|
messages,
|
||||||
@@ -74,6 +77,14 @@ export const useChatTimelineController = ({
|
|||||||
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
||||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
||||||
const turnWindowModel = React.useMemo(() => {
|
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(
|
const incrementalModel = updateTurnWindowModelIncremental(
|
||||||
previousTurnWindowModelRef.current,
|
previousTurnWindowModelRef.current,
|
||||||
previousMessagesRef.current,
|
previousMessagesRef.current,
|
||||||
@@ -82,8 +93,18 @@ export const useChatTimelineController = ({
|
|||||||
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
|
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
|
||||||
previousTurnWindowModelRef.current = nextModel;
|
previousTurnWindowModelRef.current = nextModel;
|
||||||
previousMessagesRef.current = messages;
|
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;
|
return nextModel;
|
||||||
}, [messages]);
|
}, [messages, sessionId]);
|
||||||
|
|
||||||
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
||||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user