Fix session history loading (#1468)
Fix chat history pagination and scroll preservation Align session history loading with the expected scroll-up pagination UX while keeping OpenChamber-specific initial message limits for constrained runtimes. - Separate initial load sizes from older-history pagination size - Load older messages automatically when scrolling near the top - Continue fetching history until a visible older turn is available - Preserve the current viewport synchronously during prepends - Prevent history loading from fighting pinned-to-bottom follow behavior - Remove delayed scroll-to-bottom correction that caused jumpbacks - Fix the virtualizer fallback path that could render a large blank spacer - Track oldest loaded message per pagination iteration to avoid redundant fetches
This commit is contained in:
committed by
GitHub
parent
4a1ebd98da
commit
f9e9f30873
@@ -156,6 +156,7 @@ type ChatViewportProps = {
|
|||||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
handleLoadOlder: () => void;
|
handleLoadOlder: () => void;
|
||||||
|
handleHistoryScroll: () => void;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
sessionQuestions: QuestionRequest[];
|
sessionQuestions: QuestionRequest[];
|
||||||
sessionPermissions: PermissionRequest[];
|
sessionPermissions: PermissionRequest[];
|
||||||
@@ -181,6 +182,7 @@ const ChatViewport = React.memo(({
|
|||||||
handleMessageContentChange,
|
handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
handleLoadOlder,
|
handleLoadOlder,
|
||||||
|
handleHistoryScroll,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
sessionQuestions,
|
sessionQuestions,
|
||||||
sessionPermissions,
|
sessionPermissions,
|
||||||
@@ -217,6 +219,7 @@ const ChatViewport = React.memo(({
|
|||||||
hideTopShadow={isMobile && stickyUserHeader}
|
hideTopShadow={isMobile && stickyUserHeader}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={focusScrollContainer}
|
onClick={focusScrollContainer}
|
||||||
|
onScroll={handleHistoryScroll}
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
data-scrollbar="chat"
|
data-scrollbar="chat"
|
||||||
>
|
>
|
||||||
@@ -280,6 +283,7 @@ const ChatViewport = React.memo(({
|
|||||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||||
&& prev.handleLoadOlder === next.handleLoadOlder
|
&& prev.handleLoadOlder === next.handleLoadOlder
|
||||||
|
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
||||||
&& prev.scrollToBottom === next.scrollToBottom
|
&& prev.scrollToBottom === next.scrollToBottom
|
||||||
&& prev.sessionQuestions === next.sessionQuestions
|
&& prev.sessionQuestions === next.sessionQuestions
|
||||||
&& prev.sessionPermissions === next.sessionPermissions
|
&& prev.sessionPermissions === next.sessionPermissions
|
||||||
@@ -655,7 +659,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
||||||
|
|
||||||
const handleLoadOlder = React.useCallback(() => {
|
const handleLoadOlder = React.useCallback(() => {
|
||||||
void loadEarlier();
|
void loadEarlier({ userInitiated: true });
|
||||||
}, [loadEarlier]);
|
}, [loadEarlier]);
|
||||||
|
|
||||||
const navigation = useChatTurnNavigation({
|
const navigation = useChatTurnNavigation({
|
||||||
@@ -950,6 +954,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
handleMessageContentChange={handleMessageContentChange}
|
handleMessageContentChange={handleMessageContentChange}
|
||||||
getAnimationHandlers={getAnimationHandlers}
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
handleLoadOlder={handleLoadOlder}
|
handleLoadOlder={handleLoadOlder}
|
||||||
|
handleHistoryScroll={timelineController.handleHistoryScroll}
|
||||||
scrollToBottom={resumeToLatestInstant}
|
scrollToBottom={resumeToLatestInstant}
|
||||||
sessionQuestions={sessionQuestions}
|
sessionQuestions={sessionQuestions}
|
||||||
sessionPermissions={sessionPermissions}
|
sessionPermissions={sessionPermissions}
|
||||||
|
|||||||
@@ -1005,15 +1005,9 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (virtualRows.length === 0 && entries.length > 0) {
|
if (virtualRows.length === 0 && entries.length > 0) {
|
||||||
const fallbackStart = Math.max(0, entries.length - MESSAGE_LIST_OVERSCAN * 2);
|
|
||||||
const fallbackEntries = entries.slice(fallbackStart);
|
|
||||||
const fallbackHeight = fallbackEntries.reduce((total, entry) => total + estimateHistoryEntryHeight(entry), 0);
|
|
||||||
const fallbackPaddingTop = Math.max(0, totalSize - fallbackHeight);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="relative w-full">
|
<div ref={contentRef} className="relative w-full">
|
||||||
{fallbackPaddingTop > 0 ? <div aria-hidden="true" style={{ height: `${fallbackPaddingTop}px` }} /> : null}
|
{entries.map((entry) => (
|
||||||
{fallbackEntries.map((entry) => (
|
|
||||||
<div
|
<div
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
data-turn-entry={entry.key}
|
data-turn-entry={entry.key}
|
||||||
@@ -1108,7 +1102,7 @@ StreamingTailContent.displayName = 'StreamingTailContent';
|
|||||||
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
turnStart,
|
turnStart,
|
||||||
disableStaging: _disableStaging,
|
disableStaging = false,
|
||||||
messages,
|
messages,
|
||||||
sessionIsWorking = false,
|
sessionIsWorking = false,
|
||||||
activeStreamingMessageId = null,
|
activeStreamingMessageId = null,
|
||||||
@@ -1123,7 +1117,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
streamPerfCount('ui.message_list.render');
|
streamPerfCount('ui.message_list.render');
|
||||||
void _disableStaging;
|
|
||||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
|
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
|
||||||
@@ -1320,7 +1313,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
|
|
||||||
const grew = currentLen > previousLen;
|
const grew = currentLen > previousLen;
|
||||||
const firstChanged = previousFirstKey !== currentFirstKey;
|
const firstChanged = previousFirstKey !== currentFirstKey;
|
||||||
if (!shouldVirtualizeHistory || !grew || !firstChanged || previousLen === 0) {
|
if (!shouldVirtualizeHistory || isLoadingOlder || disableStaging || !grew || !firstChanged || previousLen === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,36 +1372,9 @@ 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();
|
||||||
|
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
|
||||||
// measure() defers via useAnimationFrameWithResizeObserver.
|
|
||||||
// Wait two frames then, if we were near the estimated bottom, scroll
|
|
||||||
// to the real bottom after measurements settle.
|
|
||||||
let frame2: number | null = null;
|
|
||||||
const frame1 = requestAnimationFrame(() => {
|
|
||||||
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)
|
|
||||||
if (frame2 !== null) {
|
|
||||||
cancelAnimationFrame(frame2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [historyVirtualizer, resolveScrollContainer, shouldVirtualizeHistory]);
|
|
||||||
|
|
||||||
const scheduleVirtualMeasure = React.useCallback(() => {
|
const scheduleVirtualMeasure = React.useCallback(() => {
|
||||||
if (!shouldVirtualizeHistory) {
|
if (!shouldVirtualizeHistory) {
|
||||||
@@ -1673,7 +1639,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
if (!applyAnchor()) {
|
if (!applyAnchor()) {
|
||||||
const index = messageIndexMap.get(anchor.messageId);
|
const index = messageIndexMap.get(anchor.messageId);
|
||||||
if (typeof index === 'number' && index < historyEntries.length) {
|
if (typeof index === 'number' && index < historyEntries.length) {
|
||||||
scrollHistoryIndexIntoView(index, 'auto');
|
return scrollHistoryIndexIntoView(index, 'auto');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,22 +50,26 @@ export interface UseChatTimelineControllerResult {
|
|||||||
activeTurnId: string | null;
|
activeTurnId: string | null;
|
||||||
showScrollToBottom: boolean;
|
showScrollToBottom: boolean;
|
||||||
turnWindowModel: TurnWindowModel;
|
turnWindowModel: TurnWindowModel;
|
||||||
loadEarlier: () => Promise<void>;
|
loadEarlier: (options?: { userInitiated?: boolean }) => Promise<void>;
|
||||||
revealBufferedTurns: () => Promise<boolean>;
|
revealBufferedTurns: () => Promise<boolean>;
|
||||||
resumeToBottom: () => void;
|
resumeToBottom: () => void;
|
||||||
resumeToBottomInstant: () => Promise<void>;
|
resumeToBottomInstant: () => Promise<void>;
|
||||||
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||||
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||||
|
handleHistoryScroll: () => void;
|
||||||
captureViewportAnchor: () => ViewportAnchor | null;
|
captureViewportAnchor: () => ViewportAnchor | null;
|
||||||
restoreViewportAnchor: (anchor: ViewportAnchor) => boolean;
|
restoreViewportAnchor: (anchor: ViewportAnchor) => boolean;
|
||||||
handleActiveTurnChange: (turnId: string | null) => void;
|
handleActiveTurnChange: (turnId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TURN_MODEL_CACHE_MAX = 30
|
const TURN_MODEL_CACHE_MAX = 30
|
||||||
|
const HISTORY_SCROLL_THRESHOLD = 200
|
||||||
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
||||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||||
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
|
const HISTORY_RENDER_WAIT_TIMEOUT_MS = 250
|
||||||
|
const HISTORY_INTERACTION_GUARD_MS = 2000
|
||||||
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
||||||
const getTurnModelCacheMax = () => {
|
const getTurnModelCacheMax = () => {
|
||||||
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
||||||
@@ -150,6 +154,8 @@ export const useChatTimelineController = ({
|
|||||||
const initializedSessionRef = React.useRef<string | null>(null);
|
const initializedSessionRef = React.useRef<string | null>(null);
|
||||||
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
||||||
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
||||||
|
const historyInteractionRef = React.useRef(false);
|
||||||
|
const historyInteractionTimerRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
const historySignals = React.useMemo(() => {
|
const historySignals = React.useMemo(() => {
|
||||||
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
||||||
@@ -178,10 +184,38 @@ export const useChatTimelineController = ({
|
|||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
historyMetaRef.current = historyMeta;
|
historyMetaRef.current = historyMeta;
|
||||||
|
|
||||||
|
const beginHistoryInteraction = React.useCallback(() => {
|
||||||
|
historyInteractionRef.current = true;
|
||||||
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(historyInteractionTimerRef.current);
|
||||||
|
historyInteractionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const settleHistoryInteraction = React.useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
historyInteractionRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyInteractionTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(historyInteractionTimerRef.current);
|
||||||
|
}
|
||||||
|
historyInteractionTimerRef.current = window.setTimeout(() => {
|
||||||
|
historyInteractionTimerRef.current = null;
|
||||||
|
historyInteractionRef.current = false;
|
||||||
|
}, HISTORY_INTERACTION_GUARD_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (initializedSessionRef.current === sessionId) {
|
if (initializedSessionRef.current === sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(historyInteractionTimerRef.current);
|
||||||
|
historyInteractionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
historyInteractionRef.current = false;
|
||||||
initializedSessionRef.current = sessionId;
|
initializedSessionRef.current = sessionId;
|
||||||
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
|
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
@@ -204,7 +238,13 @@ export const useChatTimelineController = ({
|
|||||||
setTurnStart((current) => {
|
setTurnStart((current) => {
|
||||||
const previousInitial = getInitialTurnStart(previousTurnCount);
|
const previousInitial = getInitialTurnStart(previousTurnCount);
|
||||||
const nextInitial = getInitialTurnStart(nextTurnCount);
|
const nextInitial = getInitialTurnStart(nextTurnCount);
|
||||||
if (isPinnedRef.current && current === previousInitial) {
|
if (
|
||||||
|
!historyInteractionRef.current
|
||||||
|
&& !isLoadingOlderRef.current
|
||||||
|
&& !pendingRevealWorkRef.current
|
||||||
|
&& isPinnedRef.current
|
||||||
|
&& current === previousInitial
|
||||||
|
) {
|
||||||
return nextInitial;
|
return nextInitial;
|
||||||
}
|
}
|
||||||
return clampTurnStart(current, nextTurnCount);
|
return clampTurnStart(current, nextTurnCount);
|
||||||
@@ -228,6 +268,25 @@ export const useChatTimelineController = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const waitForNextRenderCommitOrTimeout = React.useCallback((): Promise<void> => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
pendingRenderResolversRef.current.push(finish);
|
||||||
|
const timer = window.setTimeout(finish, HISTORY_RENDER_WAIT_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
|
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
|
||||||
const pending = pendingScrollRequestRef.current;
|
const pending = pendingScrollRequestRef.current;
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
@@ -271,6 +330,10 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(historyInteractionTimerRef.current);
|
||||||
|
historyInteractionTimerRef.current = null;
|
||||||
|
}
|
||||||
resolvePendingRenderWaiters();
|
resolvePendingRenderWaiters();
|
||||||
resolvePendingScrollRequest(false);
|
resolvePendingScrollRequest(false);
|
||||||
};
|
};
|
||||||
@@ -328,6 +391,7 @@ export const useChatTimelineController = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginHistoryInteraction();
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
prePrependScrollRef.current = {
|
prePrependScrollRef.current = {
|
||||||
@@ -343,10 +407,14 @@ export const useChatTimelineController = ({
|
|||||||
return next > 0 ? next : 0;
|
return next > 0 ? next : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForNextRenderCommit();
|
try {
|
||||||
setPendingRevealWork(false);
|
await waitForNextRenderCommit();
|
||||||
return true;
|
return true;
|
||||||
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
|
} finally {
|
||||||
|
setPendingRevealWork(false);
|
||||||
|
settleHistoryInteraction();
|
||||||
|
}
|
||||||
|
}, [beginHistoryInteraction, captureViewportAnchor, scrollRef, settleHistoryInteraction, waitForNextRenderCommit]);
|
||||||
|
|
||||||
const fetchOlderHistory = React.useCallback(async (input: {
|
const fetchOlderHistory = React.useCallback(async (input: {
|
||||||
preserveViewport: boolean;
|
preserveViewport: boolean;
|
||||||
@@ -374,6 +442,7 @@ export const useChatTimelineController = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginHistoryInteraction();
|
||||||
setIsLoadingOlder(true);
|
setIsLoadingOlder(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -382,31 +451,112 @@ export const useChatTimelineController = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadMoreMessages(targetSessionId, 'up');
|
let loadedMessageCount = beforeMessageCount;
|
||||||
|
let loadedOldestMessageId = beforeOldestMessageId;
|
||||||
|
let loadedLimit = beforeLimit;
|
||||||
|
const beforeTurnCount = turnModelRef.current.turnCount;
|
||||||
|
|
||||||
const afterMessages = messagesRef.current;
|
while (true) {
|
||||||
const afterMessageCount = afterMessages.length;
|
await loadMoreMessages(targetSessionId, 'up');
|
||||||
const afterOldestMessageId = afterMessages[0]?.info?.id ?? null;
|
if (sessionIdRef.current !== targetSessionId) {
|
||||||
const afterLimit = historyMetaRef.current?.limit ?? beforeLimit;
|
return false;
|
||||||
const historyGrew =
|
}
|
||||||
afterMessageCount > beforeMessageCount
|
|
||||||
|| (typeof beforeOldestMessageId === 'string'
|
|
||||||
&& typeof afterOldestMessageId === 'string'
|
|
||||||
&& beforeOldestMessageId !== afterOldestMessageId);
|
|
||||||
|
|
||||||
return historyGrew || afterLimit > beforeLimit;
|
await waitForNextRenderCommitOrTimeout();
|
||||||
|
|
||||||
|
const afterMessages = messagesRef.current;
|
||||||
|
const afterMessageCount = afterMessages.length;
|
||||||
|
const afterOldestMessageId = afterMessages[0]?.info?.id ?? null;
|
||||||
|
const afterLimit = historyMetaRef.current?.limit ?? loadedLimit;
|
||||||
|
const messageGrowth =
|
||||||
|
afterMessageCount > loadedMessageCount
|
||||||
|
|| (typeof loadedOldestMessageId === 'string'
|
||||||
|
&& typeof afterOldestMessageId === 'string'
|
||||||
|
&& loadedOldestMessageId !== afterOldestMessageId)
|
||||||
|
|| afterLimit > loadedLimit;
|
||||||
|
const turnGrowth = turnModelRef.current.turnCount - beforeTurnCount;
|
||||||
|
|
||||||
|
if (turnGrowth > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!messageGrowth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedMessageCount = afterMessageCount;
|
||||||
|
loadedOldestMessageId = afterOldestMessageId;
|
||||||
|
loadedLimit = afterLimit;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
|
settleHistoryInteraction();
|
||||||
}
|
}
|
||||||
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
|
}, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]);
|
||||||
|
|
||||||
const loadEarlier = React.useCallback(async () => {
|
const loadEarlier = React.useCallback(async (options?: { userInitiated?: boolean }) => {
|
||||||
if (await revealBufferedTurns()) {
|
beginHistoryInteraction();
|
||||||
|
if (options?.userInitiated) {
|
||||||
|
releaseAutoFollow();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await revealBufferedTurns()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (await fetchOlderHistory({ preserveViewport: true }));
|
||||||
|
} finally {
|
||||||
|
settleHistoryInteraction();
|
||||||
|
}
|
||||||
|
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, revealBufferedTurns, settleHistoryInteraction]);
|
||||||
|
|
||||||
|
const handleHistoryScroll = React.useCallback(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
if (isPinnedRef.current) return;
|
||||||
|
if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return;
|
||||||
|
if (!historySignalsRef.current.canLoadEarlier) return;
|
||||||
|
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||||
|
|
||||||
|
void loadEarlier({ userInitiated: true });
|
||||||
|
}, [loadEarlier, scrollRef]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!sessionId || isLoadingOlder || pendingRevealWork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPinned || !historySignals.canLoadEarlier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (await fetchOlderHistory({ preserveViewport: true }));
|
const frame = window.requestAnimationFrame(() => {
|
||||||
}, [fetchOlderHistory, revealBufferedTurns]);
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
if (!isPinnedRef.current) return;
|
||||||
|
if (!historySignalsRef.current.canLoadEarlier) return;
|
||||||
|
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||||
|
if (container.scrollHeight > container.clientHeight + 1) return;
|
||||||
|
|
||||||
|
void loadEarlier();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [
|
||||||
|
historySignals.canLoadEarlier,
|
||||||
|
isLoadingOlder,
|
||||||
|
isPinned,
|
||||||
|
loadEarlier,
|
||||||
|
pendingRevealWork,
|
||||||
|
renderedMessages.length,
|
||||||
|
scrollRef,
|
||||||
|
sessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
const scrollToTurn = React.useCallback(async (
|
const scrollToTurn = React.useCallback(async (
|
||||||
turnId: string,
|
turnId: string,
|
||||||
@@ -552,6 +702,7 @@ export const useChatTimelineController = ({
|
|||||||
resumeToBottomInstant,
|
resumeToBottomInstant,
|
||||||
scrollToTurn,
|
scrollToTurn,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
handleHistoryScroll,
|
||||||
captureViewportAnchor,
|
captureViewportAnchor,
|
||||||
restoreViewportAnchor,
|
restoreViewportAnchor,
|
||||||
handleActiveTurnChange,
|
handleActiveTurnChange,
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import {
|
|||||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const MESSAGE_PAGE_SIZE = 150
|
const INITIAL_MESSAGE_PAGE_SIZE = 150
|
||||||
const VSCODE_MESSAGE_PAGE_SIZE = 30
|
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
|
||||||
const MOBILE_MESSAGE_PAGE_SIZE = 30
|
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
|
||||||
|
const HISTORY_MESSAGE_PAGE_SIZE = 200
|
||||||
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||||
const MAX_SEEN_DIRS = 30
|
const MAX_SEEN_DIRS = 30
|
||||||
const VSCODE_SESSION_CACHE_LIMIT = 4
|
const VSCODE_SESSION_CACHE_LIMIT = 4
|
||||||
@@ -53,12 +54,12 @@ const getEffectiveSessionCacheLimit = () => {
|
|||||||
if (isMobileSurfaceRuntime()) return MOBILE_SESSION_CACHE_LIMIT
|
if (isMobileSurfaceRuntime()) return MOBILE_SESSION_CACHE_LIMIT
|
||||||
return SESSION_CACHE_LIMIT
|
return SESSION_CACHE_LIMIT
|
||||||
}
|
}
|
||||||
const getEffectiveMessagePageSize = () => {
|
const getInitialMessagePageSize = () => {
|
||||||
if (isVSCodeRuntime()) return VSCODE_MESSAGE_PAGE_SIZE
|
if (isVSCodeRuntime()) return VSCODE_INITIAL_MESSAGE_PAGE_SIZE
|
||||||
if (isMobileSurfaceRuntime()) return MOBILE_MESSAGE_PAGE_SIZE
|
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
|
||||||
return MESSAGE_PAGE_SIZE
|
return INITIAL_MESSAGE_PAGE_SIZE
|
||||||
}
|
}
|
||||||
const getDefaultMeta = (): SyncMeta => ({ limit: getEffectiveMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
||||||
|
|
||||||
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
||||||
const info = getSessionPrefetch(directory, sessionID)
|
const info = getSessionPrefetch(directory, sessionID)
|
||||||
@@ -78,7 +79,7 @@ function sortParts(parts: Part[]) {
|
|||||||
function isHeavyConstrainedSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
|
function isHeavyConstrainedSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
|
||||||
const messages = state.message[sessionID]
|
const messages = state.message[sessionID]
|
||||||
if (!messages || messages.length === 0) return false
|
if (!messages || messages.length === 0) return false
|
||||||
return messages.length > getEffectiveMessagePageSize()
|
return messages.length > getInitialMessagePageSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserMessage(message: Message): boolean {
|
function isUserMessage(message: Message): boolean {
|
||||||
@@ -291,7 +292,7 @@ export function useSync() {
|
|||||||
setMetaFor(sessionID, { loading: true })
|
setMetaFor(sessionID, { loading: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const limit = options?.before ? getEffectiveMessagePageSize() : m.limit
|
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
|
||||||
let page = await fetchMessages(sessionID, limit, options?.before)
|
let page = await fetchMessages(sessionID, limit, options?.before)
|
||||||
|
|
||||||
// Constrained shells keep the initial page small for switch performance. Some
|
// Constrained shells keep the initial page small for switch performance. Some
|
||||||
@@ -385,7 +386,7 @@ export function useSync() {
|
|||||||
if (shouldSkipSessionPrefetch({
|
if (shouldSkipSessionPrefetch({
|
||||||
hasMessages: cachedReady,
|
hasMessages: cachedReady,
|
||||||
info: prefetchInfo,
|
info: prefetchInfo,
|
||||||
pageSize: getEffectiveMessagePageSize(),
|
pageSize: getInitialMessagePageSize(),
|
||||||
})) return
|
})) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user