From 151e8ee8083ef5e6373382914e55a72583b79b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Yepes?= <23552631+ivnnv@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:20:28 -0500 Subject: [PATCH] FEAT: improve conversation history loading for long sessions (#371) * feat: add "Load all messages" button for long conversations Scrolling up through long conversations requires many "load more" cycles. This adds a "Load all messages" floating button that fetches the entire conversation history in one shot. - Floating overlay pill appears after each batch finishes loading, persists 2s - Shows loading spinner while fetching all messages - Shows green "All messages loaded" confirmation for 1s before disappearing - Preserves scroll position when bulk-loading (no viewport jump) - Ref-based guards prevent scroll handler from re-fetching after load-all - Performance warning shown; "Scroll to bottom" resets visible cap - Clean state reset on session switch - i18n keys for en and zh-CN Note: default page size (20) and visible cap (100) are unchanged. These could be increased in a follow-up or made configurable via settings. * re-implement load-all feature for new TS architecture --- .../chat/hooks/useChatSessionState.ts | 161 ++++++++++++++++-- src/components/chat/view/ChatInterface.tsx | 13 +- .../view/subcomponents/ChatMessagesPane.tsx | 63 ++++++- src/i18n/locales/en/chat.json | 6 +- src/i18n/locales/ko/chat.json | 6 +- src/i18n/locales/zh-CN/chat.json | 6 +- 6 files changed, 234 insertions(+), 21 deletions(-) diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index c792b94..7ac53e8 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; + import { api, authenticatedFetch } from '../../../utils/api'; import type { ChatMessage, Provider } from '../types/types'; import type { Project, ProjectSession } from '../../../types/app'; @@ -76,15 +77,22 @@ export function useChatSessionState({ const [tokenBudget, setTokenBudget] = useState | null>(null); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null); + const [allMessagesLoaded, setAllMessagesLoaded] = useState(false); + const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); + const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); + const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false); const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); + const allMessagesLoadedRef = useRef(false); const topLoadLockRef = useRef(false); const pendingScrollRestoreRef = useRef(null); const pendingInitialScrollRef = useRef(true); const messagesOffsetRef = useRef(0); const scrollPositionRef = useRef({ height: 0, top: 0 }); + const loadAllFinishedTimerRef = useRef | null>(null); + const loadAllOverlayTimerRef = useRef | null>(null); const createDiff = useMemo(() => createCachedDiffCalculator(), []); @@ -182,6 +190,15 @@ export function useChatSessionState({ container.scrollTop = container.scrollHeight; }, []); + const scrollToBottomAndReset = useCallback(() => { + scrollToBottom(); + if (allMessagesLoaded) { + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + setAllMessagesLoaded(false); + allMessagesLoadedRef.current = false; + } + }, [allMessagesLoaded, scrollToBottom]); + const isNearBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) { @@ -196,6 +213,7 @@ export function useChatSessionState({ if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) { return false; } + if (allMessagesLoadedRef.current) return false; if (!hasMoreMessages || !selectedSession || !selectedProject) { return false; } @@ -245,23 +263,24 @@ export function useChatSessionState({ const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); - const scrolledNearTop = container.scrollTop < 100; - if (!scrolledNearTop) { - topLoadLockRef.current = false; - return; - } - - if (topLoadLockRef.current) { - // After a top-load restore, release the lock once user has moved away from absolute top. - if (container.scrollTop > 20) { + if (!allMessagesLoadedRef.current) { + const scrolledNearTop = container.scrollTop < 100; + if (!scrolledNearTop) { topLoadLockRef.current = false; + return; } - return; - } - const didLoad = await loadOlderMessages(container); - if (didLoad) { - topLoadLockRef.current = true; + if (topLoadLockRef.current) { + if (container.scrollTop > 20) { + topLoadLockRef.current = false; + } + return; + } + + const didLoad = await loadOlderMessages(container); + if (didLoad) { + topLoadLockRef.current = true; + } } }, [isNearBottom, loadOlderMessages]); @@ -322,6 +341,14 @@ export function useChatSessionState({ messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + setAllMessagesLoaded(false); + allMessagesLoadedRef.current = false; + setIsLoadingAllMessages(false); + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); setTokenBudget(null); setIsLoading(false); @@ -571,6 +598,106 @@ export function useChatSessionState({ } }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); + // Show "Load all" overlay after a batch finishes loading, persist for 2s then hide + const prevLoadingRef = useRef(false); + useEffect(() => { + const wasLoading = prevLoadingRef.current; + prevLoadingRef.current = isLoadingMoreMessages; + + if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) { + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + setShowLoadAllOverlay(true); + loadAllOverlayTimerRef.current = setTimeout(() => { + setShowLoadAllOverlay(false); + }, 2000); + } + if (!hasMoreMessages && !isLoadingMoreMessages) { + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + setShowLoadAllOverlay(false); + } + return () => { + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + }; + }, [isLoadingMoreMessages, hasMoreMessages]); + + const loadAllMessages = useCallback(async () => { + if (!selectedSession || !selectedProject) return; + if (isLoadingAllMessages) return; + const sessionProvider = selectedSession.__provider || 'claude'; + if (sessionProvider === 'cursor') { + setVisibleMessageCount(Infinity); + setAllMessagesLoaded(true); + allMessagesLoadedRef.current = true; + setLoadAllJustFinished(true); + if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); + loadAllFinishedTimerRef.current = setTimeout(() => { + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + }, 1000); + return; + } + + const requestSessionId = selectedSession.id; + + allMessagesLoadedRef.current = true; + isLoadingMoreRef.current = true; + setIsLoadingAllMessages(true); + setShowLoadAllOverlay(true); + + const container = scrollContainerRef.current; + const previousScrollHeight = container ? container.scrollHeight : 0; + const previousScrollTop = container ? container.scrollTop : 0; + + try { + const response = await (api.sessionMessages as any)( + selectedProject.name, + requestSessionId, + null, + 0, + sessionProvider, + ); + + if (currentSessionId !== requestSessionId) return; + + if (response.ok) { + const data = await response.json(); + const allMessages = data.messages || data; + + if (container) { + pendingScrollRestoreRef.current = { + height: previousScrollHeight, + top: previousScrollTop, + }; + } + + setSessionMessages(Array.isArray(allMessages) ? allMessages : []); + setHasMoreMessages(false); + setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); + messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; + + setVisibleMessageCount(Infinity); + setAllMessagesLoaded(true); + + setLoadAllJustFinished(true); + if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); + loadAllFinishedTimerRef.current = setTimeout(() => { + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + }, 1000); + } else { + allMessagesLoadedRef.current = false; + setShowLoadAllOverlay(false); + } + } catch (error) { + console.error('Error loading all messages:', error); + allMessagesLoadedRef.current = false; + setShowLoadAllOverlay(false); + } finally { + isLoadingMoreRef.current = false; + setIsLoadingAllMessages(false); + } + }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId]); + const loadEarlierMessages = useCallback(() => { setVisibleMessageCount((previousCount) => previousCount + 100); }, []); @@ -599,11 +726,17 @@ export function useChatSessionState({ visibleMessageCount, visibleMessages, loadEarlierMessages, + loadAllMessages, + allMessagesLoaded, + isLoadingAllMessages, + loadAllJustFinished, + showLoadAllOverlay, claudeStatus, setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, + scrollToBottomAndReset, isNearBottom, handleScroll, loadSessionMessages, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2926440..80b25cd 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -96,11 +96,17 @@ function ChatInterface({ visibleMessageCount, visibleMessages, loadEarlierMessages, + loadAllMessages, + allMessagesLoaded, + isLoadingAllMessages, + loadAllJustFinished, + showLoadAllOverlay, claudeStatus, setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, + scrollToBottomAndReset, handleScroll, } = useChatSessionState({ selectedProject, @@ -297,6 +303,11 @@ function ChatInterface({ visibleMessageCount={visibleMessageCount} visibleMessages={visibleMessages} loadEarlierMessages={loadEarlierMessages} + loadAllMessages={loadAllMessages} + allMessagesLoaded={allMessagesLoaded} + isLoadingAllMessages={isLoadingAllMessages} + loadAllJustFinished={loadAllJustFinished} + showLoadAllOverlay={showLoadAllOverlay} createDiff={createDiff} onFileOpen={onFileOpen} onShowSettings={onShowSettings} @@ -327,7 +338,7 @@ function ChatInterface({ onClearInput={handleClearInput} isUserScrolledUp={isUserScrolledUp} hasMessages={chatMessages.length > 0} - onScrollToBottom={scrollToBottom} + onScrollToBottom={scrollToBottomAndReset} onSubmit={handleSubmit} isDragActive={isDragActive} attachedImages={attachedImages} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 21ad51f..ea38ed7 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -37,6 +37,11 @@ interface ChatMessagesPaneProps { visibleMessageCount: number; visibleMessages: ChatMessage[]; loadEarlierMessages: () => void; + loadAllMessages: () => void; + allMessagesLoaded: boolean; + isLoadingAllMessages: boolean; + loadAllJustFinished: boolean; + showLoadAllOverlay: boolean; createDiff: any; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; @@ -76,6 +81,11 @@ export default function ChatMessagesPane({ visibleMessageCount, visibleMessages, loadEarlierMessages, + loadAllMessages, + allMessagesLoaded, + isLoadingAllMessages, + loadAllJustFinished, + showLoadAllOverlay, createDiff, onFileOpen, onShowSettings, @@ -149,7 +159,8 @@ export default function ChatMessagesPane({ /> ) : ( <> - {isLoadingMoreMessages && ( + {/* Loading indicator for older messages (hide when load-all is active) */} + {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
@@ -158,23 +169,69 @@ export default function ChatMessagesPane({
)} - {hasMoreMessages && !isLoadingMoreMessages && ( + {/* Indicator showing there are more messages to load (hide when all loaded) */} + {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
{totalMessages > 0 && ( - {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })} | + {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '} {t('session.messages.scrollToLoad')} )}
)} + {/* Floating "Load all messages" overlay */} + {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && ( +
+ {loadAllJustFinished ? ( +
+ + + + {t('session.messages.allLoaded')} +
+ ) : ( + + )} +
+ )} + + {/* Performance warning when all messages are loaded */} + {allMessagesLoaded && ( +
+ {t('session.messages.perfWarning')} +
+ )} + + {/* Legacy message count indicator (for non-paginated view) */} {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} | + {' | '} +
)} diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 0db6d12..2e596aa 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -175,7 +175,11 @@ "showingOf": "Showing {{shown}} of {{total}} messages", "scrollToLoad": "Scroll up to load more", "showingLast": "Showing last {{count}} messages ({{total}} total)", - "loadEarlier": "Load earlier messages" + "loadEarlier": "Load earlier messages", + "loadAll": "Load all messages", + "loadingAll": "Loading all messages...", + "allLoaded": "All messages loaded", + "perfWarning": "All messages loaded — scrolling may be slower. Click \"Scroll to bottom\" to restore performance." } }, "shell": { diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 0e76556..7fe738f 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -175,7 +175,11 @@ "showingOf": "{{total}}개 중 {{shown}}개 표시", "scrollToLoad": "위로 스크롤하여 더 로드", "showingLast": "마지막 {{count}}개 메시지 표시 (총 {{total}}개)", - "loadEarlier": "이전 메시지 로드" + "loadEarlier": "이전 메시지 로드", + "loadAll": "모든 메시지 로드", + "loadingAll": "모든 메시지 로딩 중...", + "allLoaded": "모든 메시지 로드 완료", + "perfWarning": "모든 메시지가 로드됨 - 스크롤이 느려질 수 있습니다. \"맨 아래로 스크롤\"을 클릭하면 성능이 복구됩니다." } }, "shell": { diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 0ad37e6..d3984aa 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -175,7 +175,11 @@ "showingOf": "显示 {{shown}} / {{total}} 条消息", "scrollToLoad": "向上滚动以加载更多", "showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)", - "loadEarlier": "加载更早的消息" + "loadEarlier": "加载更早的消息", + "loadAll": "加载全部消息", + "loadingAll": "正在加载全部消息...", + "allLoaded": "全部消息已加载", + "perfWarning": "已加载全部消息 - 滚动可能变慢。点击「滚动到底部」恢复性能。" } }, "shell": {