From 85b63cc9e02662b485a9b4e8d2eb521487ef8a92 Mon Sep 17 00:00:00 2001 From: jry Date: Thu, 11 Jun 2026 20:54:38 +0800 Subject: [PATCH] paginate conversation history loading --- src-tauri/src/commands/conversations.rs | 38 +++++++++++- src-tauri/src/models/conversation.rs | 3 + src-tauri/src/web/handlers/conversations.rs | 4 ++ .../conversation-detail-panel.tsx | 11 ++++ src/components/message/message-list-view.tsx | 31 +++++++++- .../conversation-runtime-context.test.tsx | 9 +++ src/contexts/conversation-runtime-context.tsx | 62 ++++++++++++++++++- src/hooks/use-conversation-detail.ts | 2 + src/i18n/messages/en.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/lib/api.ts | 12 +++- src/lib/tauri.ts | 12 +++- src/lib/types.ts | 3 + 13 files changed, 182 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 51e2d529d..d0ff0349d 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -14,6 +14,27 @@ use crate::parsers::hermes::HermesParser; use crate::parsers::openclaw::OpenClawParser; use crate::parsers::opencode::OpenCodeParser; use crate::parsers::{path_eq_for_matching, AgentParser, ParseError}; + +const DEFAULT_CONVERSATION_TURNS_PAGE_SIZE: usize = 30; + +fn paginate_turns( + turns: Vec, + before_turn_index: Option, + page_size: Option, +) -> (Vec, bool, Option, usize) { + let total = turns.len(); + let page_size = page_size.unwrap_or(DEFAULT_CONVERSATION_TURNS_PAGE_SIZE).max(1); + let end = before_turn_index.unwrap_or(total).min(total); + let start = end.saturating_sub(page_size); + let has_more_history = start > 0; + let next_before_turn_index = has_more_history.then_some(start); + ( + turns[start..end].to_vec(), + has_more_history, + next_before_turn_index, + page_size, + ) +} use crate::web::event_bridge::{ emit_event, ConversationChange, EventEmitter, TabsChanged, CONVERSATION_CHANGED_EVENT, TABS_CHANGED_EVENT, @@ -481,6 +502,8 @@ fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationS pub async fn get_folder_conversation_core( conn: &sea_orm::DatabaseConnection, conversation_id: i32, + before_turn_index: Option, + page_size: Option, ) -> Result<(DbConversationDetail, Option), AppCommandError> { let summary = conversation_service::get_by_id(conn, conversation_id) .await @@ -589,10 +612,16 @@ pub async fn get_folder_conversation_core( .unwrap_or_default(); inject_delegation_meta(&mut turns, &children); + let (turns, has_more_history, next_before_turn_index, page_size) = + paginate_turns(turns, before_turn_index, page_size); + Ok(( DbConversationDetail { summary, turns, + has_more_history, + next_before_turn_index, + page_size, session_stats, in_flight_user_turn_id: None, }, @@ -762,8 +791,11 @@ pub async fn get_folder_conversation_with_live_core( manager: &crate::acp::manager::ConnectionManager, emitter: &EventEmitter, conversation_id: i32, + before_turn_index: Option, + page_size: Option, ) -> Result { - let (mut detail, parsed_title) = get_folder_conversation_core(conn, conversation_id).await?; + let (mut detail, parsed_title) = + get_folder_conversation_core(conn, conversation_id, before_turn_index, page_size).await?; // Per-turn auto-title backfill. The parse `get_folder_conversation_core` // just did already produced the session-file title; adopt it (and broadcast @@ -811,12 +843,16 @@ pub async fn get_folder_conversation( db: tauri::State<'_, AppDatabase>, manager: tauri::State<'_, crate::acp::manager::ConnectionManager>, conversation_id: i32, + before_turn_index: Option, + page_size: Option, ) -> Result { get_folder_conversation_with_live_core( &db.conn, &manager, &EventEmitter::Tauri(app), conversation_id, + before_turn_index, + page_size, ) .await } diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index 4985884fc..a61b5c671 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -79,6 +79,9 @@ pub struct ConversationDetail { pub struct DbConversationDetail { pub summary: DbConversationSummary, pub turns: Vec, + pub has_more_history: bool, + pub next_before_turn_index: Option, + pub page_size: usize, #[serde(skip_serializing_if = "Option::is_none")] pub session_stats: Option, /// Id of the persisted user turn the live-correlation pass identified as the diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index c6ac760ef..174d3be8c 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -125,6 +125,8 @@ pub async fn get_conversation( #[serde(rename_all = "camelCase")] pub struct GetFolderConversationParams { pub conversation_id: i32, + pub before_turn_index: Option, + pub page_size: Option, } pub async fn get_folder_conversation( @@ -137,6 +139,8 @@ pub async fn get_folder_conversation( &state.connection_manager, &state.emitter, params.conversation_id, + params.before_turn_index, + params.page_size, ) .await?; Ok(Json(result)) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 5311e7983..7a63644e3 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -198,6 +198,7 @@ const ConversationTabView = memo(function ConversationTabView({ removeOptimisticTurn, appendViewerUserTurn, completeTurn, + fetchOlderDetail, getSession, refetchDetail, syncTurnMetadata, @@ -301,12 +302,17 @@ const ConversationTabView = memo(function ConversationTabView({ const { detail, loading: detailLoading, + loadingMore: detailLoadingMore, error: detailError, acpLoadError, } = useConversationDetail(effectiveConversationId) const runtimeSession = getSession(effectiveConversationId) const effectiveSessionStats = runtimeSession?.sessionStats ?? null + const handleLoadOlderMessages = useCallback(() => { + if (dbConversationId == null) return + fetchOlderDetail(dbConversationId) + }, [dbConversationId, fetchOlderDetail]) useEffect(() => { if (!isActive) return @@ -1081,6 +1087,11 @@ const ConversationTabView = memo(function ConversationTabView({ sendSignal={sendSignal} sessionStats={effectiveSessionStats} detailLoading={detailLoading} + detailLoadingMore={detailLoadingMore} + hasMoreHistory={detail?.has_more_history ?? false} + onLoadOlder={ + hasPersistedConversation ? handleLoadOlderMessages : undefined + } detailError={detailError} acpLoadError={acpLoadError} hideEmptyState={!hasPersistedConversation || hasSentMessage} diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 8c77d2bd0..448d81c1a 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -74,6 +74,9 @@ interface MessageListViewProps { sendSignal?: number sessionStats?: SessionStats | null detailLoading?: boolean + detailLoadingMore?: boolean + hasMoreHistory?: boolean + onLoadOlder?: () => void detailError?: string | null /** * Set when the agent rejected `session/load` non-recoverably (e.g. the @@ -497,6 +500,9 @@ export function MessageListView({ sendSignal = 0, sessionStats = null, detailLoading = false, + detailLoadingMore = false, + hasMoreHistory = false, + onLoadOlder, detailError = null, acpLoadError = null, hideEmptyState = false, @@ -903,9 +909,28 @@ export function MessageListView({ - - + {(hasMoreHistory || detailLoadingMore) && ( +
+ +
+ )} + + { pinned_at: null, }, turns, + has_more_history: false, + next_before_turn_index: null, + page_size: 30, session_stats: null, } } @@ -900,6 +906,9 @@ describe("ConversationRuntimeProvider viewer user-turn synthesis", () => { pinned_at: null, }, turns, + has_more_history: false, + next_before_turn_index: null, + page_size: 30, session_stats: null, } } diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 8c56cb716..cfff23e10 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -48,6 +48,7 @@ export interface ConversationRuntimeSession { // DB data (cold open only) detail: DbConversationDetail | null detailLoading: boolean + detailLoadingMore: boolean detailError: string | null // ACP `session/load` failed in a non-recoverable way (currently only when @@ -112,11 +113,13 @@ type Action = | { type: "FETCH_DETAIL_START" conversationId: number + appendHistory?: boolean } | { type: "FETCH_DETAIL_SUCCESS" conversationId: number detail: DbConversationDetail + appendHistory?: boolean /** * Keep `liveMessage` / `optimisticTurns` / `localTurns` across this * detail load even though `syncState` isn't "awaiting_persist". The @@ -250,6 +253,7 @@ function createEmptySession( externalId: null, detail: null, detailLoading: false, + detailLoadingMore: false, detailError: null, acpLoadError: null, localTurns: [], @@ -808,7 +812,8 @@ function reducer( case "FETCH_DETAIL_START": return updateSessionInState(state, action.conversationId, (current) => ({ ...current, - detailLoading: true, + detailLoading: !action.appendHistory, + detailLoadingMore: Boolean(action.appendHistory), detailError: null, })) @@ -843,13 +848,22 @@ function reducer( const keepAllLiveBuffers = action.preserveLive === true || detailIsInFlight + const nextDetail = + action.appendHistory && current.detail + ? { + ...action.detail, + turns: [...action.detail.turns, ...current.detail.turns], + } + : action.detail + const nextSession: ConversationRuntimeSession = { ...current, - detail: action.detail, + detail: nextDetail, detailLoading: false, + detailLoadingMore: false, detailError: null, externalId: nextExternalId ?? current.externalId, - sessionStats: action.detail.session_stats ?? current.sessionStats, + sessionStats: nextDetail.session_stats ?? current.sessionStats, ...(isActivelyInteracting ? keepAllLiveBuffers ? {} @@ -876,6 +890,7 @@ function reducer( return updateSessionInState(state, action.conversationId, (current) => ({ ...current, detailLoading: false, + detailLoadingMore: false, detailError: action.error, })) @@ -1288,6 +1303,7 @@ interface ConversationRuntimeContextValue { getConversationIdByExternalId: (externalId: string) => number | null getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[] fetchDetail: (conversationId: number) => void + fetchOlderDetail: (conversationId: number) => void /** * Re-fetch persisted detail, bypassing the active-data guard. * `options.preserveLive` (default false) keeps the current `liveMessage`, @@ -1702,6 +1718,44 @@ export function ConversationRuntimeProvider({ [bumpFetchGeneration, isLatestGeneration] ) + const fetchOlderDetail = useCallback( + (conversationId: number) => { + const session = stateRef.current.byConversationId.get(conversationId) + const detail = session?.detail + if (!detail || session.detailLoading || session.detailLoadingMore) return + if (!detail.has_more_history || detail.next_before_turn_index == null) return + + const generation = bumpFetchGeneration(conversationId) + dispatch({ + type: "FETCH_DETAIL_START", + conversationId, + appendHistory: true, + }) + getFolderConversation(conversationId, { + beforeTurnIndex: detail.next_before_turn_index, + pageSize: detail.page_size, + }) + .then((nextDetail) => { + if (!isLatestGeneration(conversationId, generation)) return + dispatch({ + type: "FETCH_DETAIL_SUCCESS", + conversationId, + detail: nextDetail, + appendHistory: true, + }) + }) + .catch((error: unknown) => { + if (!isLatestGeneration(conversationId, generation)) return + dispatch({ + type: "FETCH_DETAIL_ERROR", + conversationId, + error: toErrorMessage(error), + }) + }) + }, + [bumpFetchGeneration, isLatestGeneration] + ) + const syncTurnMetadata = useCallback( ( dbConversationId: number, @@ -1973,6 +2027,7 @@ export function ConversationRuntimeProvider({ getConversationIdByExternalId, getTimelineTurns, fetchDetail, + fetchOlderDetail, refetchDetail, syncTurnMetadata, completeTurn, @@ -1994,6 +2049,7 @@ export function ConversationRuntimeProvider({ getConversationIdByExternalId, getTimelineTurns, fetchDetail, + fetchOlderDetail, refetchDetail, syncTurnMetadata, completeTurn, diff --git a/src/hooks/use-conversation-detail.ts b/src/hooks/use-conversation-detail.ts index ea4a3a925..9df2715b8 100644 --- a/src/hooks/use-conversation-detail.ts +++ b/src/hooks/use-conversation-detail.ts @@ -24,6 +24,7 @@ export function useConversationDetail( ): { detail: DbConversationDetail | null loading: boolean + loadingMore: boolean error: string | null acpLoadError: string | null } { @@ -49,6 +50,7 @@ export function useConversationDetail( return { detail: session?.detail ?? null, loading: session ? session.detailLoading : !isVirtual, + loadingMore: session?.detailLoadingMore ?? false, error: session?.detailError ?? null, acpLoadError: session?.acpLoadError ?? null, } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 80bf1a1e9..8862c3b53 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1934,7 +1934,9 @@ "tokenCacheWrite": "Cache write", "duration": "Duration", "completedAt": "Completed at", - "jumpToPreviousUserMessage": "Jump to user message" + "jumpToPreviousUserMessage": "Jump to user message", + "loadEarlierMessages": "Load earlier messages", + "loadingEarlierMessages": "Loading earlier messages..." }, "liveTurnStats": { "thinking": "Thinking...", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bdf62c37f..f4703fadc 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1934,7 +1934,9 @@ "tokenCacheWrite": "缓存写入", "duration": "耗时", "completedAt": "完成时间", - "jumpToPreviousUserMessage": "跳转到上一条用户消息" + "jumpToPreviousUserMessage": "跳转到上一条用户消息", + "loadEarlierMessages": "加载更早消息", + "loadingEarlierMessages": "正在加载更早消息..." }, "liveTurnStats": { "thinking": "思考中...", diff --git a/src/lib/api.ts b/src/lib/api.ts index da355f0d6..ec2e064f2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -883,9 +883,17 @@ export async function importLocalConversations( } export async function getFolderConversation( - conversationId: number + conversationId: number, + options?: { + beforeTurnIndex?: number | null + pageSize?: number | null + } ): Promise { - return getTransport().call("get_folder_conversation", { conversationId }) + return getTransport().call("get_folder_conversation", { + conversationId, + beforeTurnIndex: options?.beforeTurnIndex ?? null, + pageSize: options?.pageSize ?? null, + }) } export async function removeFolderFromHistory(path: string): Promise { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 867f05b25..e5b2f85bf 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -586,9 +586,17 @@ export async function importLocalConversations( } export async function getFolderConversation( - conversationId: number + conversationId: number, + options?: { + beforeTurnIndex?: number | null + pageSize?: number | null + } ): Promise { - return invoke("get_folder_conversation", { conversationId }) + return invoke("get_folder_conversation", { + conversationId, + beforeTurnIndex: options?.beforeTurnIndex ?? null, + pageSize: options?.pageSize ?? null, + }) } export async function removeFolderFromHistory(path: string): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 7d859c25f..51f266470 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -342,6 +342,9 @@ export interface ImportResult { export interface DbConversationDetail { summary: DbConversationSummary turns: MessageTurn[] + has_more_history: boolean + next_before_turn_index?: number | null + page_size: number session_stats?: SessionStats | null /** * Id of the persisted user turn the backend identified as the in-flight prompt