From 3896948bb2e71fdd58308209ca5393b77f26d045 Mon Sep 17 00:00:00 2001 From: jry Date: Thu, 11 Jun 2026 20:54:38 +0800 Subject: [PATCH] add paginated conversation detail API for mobile --- src-tauri/src/commands/conversations.rs | 122 ++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/models/conversation.rs | 13 +++ src-tauri/src/models/mod.rs | 4 +- src-tauri/src/web/handlers/conversations.rs | 25 ++++ src-tauri/src/web/router.rs | 4 + src/lib/api.ts | 13 +++ src/lib/tauri.ts | 13 +++ src/lib/types.ts | 10 ++ 9 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 420bfaea7..0223f5b66 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, @@ -821,6 +842,50 @@ pub async fn get_folder_conversation( .await } +pub async fn get_folder_conversation_paginated_core( + conn: &sea_orm::DatabaseConnection, + manager: &crate::acp::manager::ConnectionManager, + emitter: &EventEmitter, + conversation_id: i32, + before_turn_index: Option, + page_size: Option, +) -> Result { + let detail = + get_folder_conversation_with_live_core(conn, manager, emitter, conversation_id).await?; + let (turns, has_more_history, next_before_turn_index, page_size) = + paginate_turns(detail.turns, before_turn_index, page_size); + Ok(DbConversationDetailPage { + summary: detail.summary, + turns, + has_more_history, + next_before_turn_index, + page_size, + session_stats: detail.session_stats, + in_flight_user_turn_id: detail.in_flight_user_turn_id, + }) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_folder_conversation_paginated( + app: tauri::AppHandle, + 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_paginated_core( + &db.conn, + &manager, + &EventEmitter::Tauri(app), + conversation_id, + before_turn_index, + page_size, + ) + .await +} + /// Emit a `conversation://changed` Upsert for `conversation_id` so every /// client's sidebar inserts-or-replaces the row in real time. Re-fetches the /// fresh summary via `get_by_id`, which filters out soft-deleted rows — so an @@ -1564,6 +1629,63 @@ mod tests { } } + fn pagination_turns(count: usize) -> Vec { + (0..count) + .map(|index| user_text_turn(&format!("turn-{index}"), "turn", at(index as i64))) + .collect() + } + + #[test] + fn paginate_turns_returns_latest_default_page() { + let (page, has_more_history, next_before_turn_index, page_size) = + paginate_turns(pagination_turns(100), None, None); + + assert_eq!(page.len(), 30); + assert_eq!(page.first().map(|turn| turn.id.as_str()), Some("turn-70")); + assert_eq!(page.last().map(|turn| turn.id.as_str()), Some("turn-99")); + assert!(has_more_history); + assert_eq!(next_before_turn_index, Some(70)); + assert_eq!(page_size, 30); + } + + #[test] + fn paginate_turns_returns_page_before_cursor() { + let (page, has_more_history, next_before_turn_index, page_size) = + paginate_turns(pagination_turns(100), Some(70), Some(30)); + + assert_eq!(page.len(), 30); + assert_eq!(page.first().map(|turn| turn.id.as_str()), Some("turn-40")); + assert_eq!(page.last().map(|turn| turn.id.as_str()), Some("turn-69")); + assert!(has_more_history); + assert_eq!(next_before_turn_index, Some(40)); + assert_eq!(page_size, 30); + } + + #[test] + fn paginate_turns_marks_first_page_as_complete_history() { + let (page, has_more_history, next_before_turn_index, page_size) = + paginate_turns(pagination_turns(12), Some(12), Some(30)); + + assert_eq!(page.len(), 12); + assert_eq!(page.first().map(|turn| turn.id.as_str()), Some("turn-0")); + assert_eq!(page.last().map(|turn| turn.id.as_str()), Some("turn-11")); + assert!(!has_more_history); + assert_eq!(next_before_turn_index, None); + assert_eq!(page_size, 30); + } + + #[test] + fn paginate_turns_clamps_zero_page_size_to_one() { + let (page, has_more_history, next_before_turn_index, page_size) = + paginate_turns(pagination_turns(3), None, Some(0)); + + assert_eq!(page.len(), 1); + assert_eq!(page.first().map(|turn| turn.id.as_str()), Some("turn-2")); + assert!(has_more_history); + assert_eq!(next_before_turn_index, Some(2)); + assert_eq!(page_size, 1); + } + fn pending_text(message_id: &str, text: &str) -> crate::acp::session_state::PendingUserMessage { crate::acp::session_state::PendingUserMessage { message_id: message_id.into(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef206bc20..8e6b85043 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -782,6 +782,7 @@ mod tauri_app { conversations::save_opened_tabs, conversations::import_local_conversations, conversations::get_folder_conversation, + conversations::get_folder_conversation_paginated, conversations::list_folders, conversations::get_stats, conversations::get_sidebar_data, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index 4985884fc..804c01121 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -91,6 +91,19 @@ pub struct DbConversationDetail { pub in_flight_user_turn_id: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct DbConversationDetailPage { + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_flight_user_turn_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FolderInfo { pub path: String, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index e81c669b7..cec3f525a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -14,8 +14,8 @@ pub use agent::AgentType; pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; pub use conversation::{ AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary, - DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats, - SidebarData, + DbConversationDetail, DbConversationDetailPage, DbConversationSummary, FolderInfo, + ImportResult, SessionStats, SidebarData, }; pub use folder::{ FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedTab, OpenedTabsSnapshot, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index f20866310..177f16d30 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -127,6 +127,14 @@ pub struct GetFolderConversationParams { pub conversation_id: i32, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetFolderConversationPaginatedParams { + pub conversation_id: i32, + pub before_turn_index: Option, + pub page_size: Option, +} + pub async fn get_folder_conversation( Extension(state): Extension>, Json(params): Json, @@ -142,6 +150,23 @@ pub async fn get_folder_conversation( Ok(Json(result)) } +pub async fn get_folder_conversation_paginated( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let db = &state.db; + let result = conv_commands::get_folder_conversation_paginated_core( + &db.conn, + &state.connection_manager, + &state.emitter, + params.conversation_id, + params.before_turn_index, + params.page_size, + ) + .await?; + Ok(Json(result)) +} + pub async fn list_folders() -> Result>, AppCommandError> { let result = conv_commands::list_folders().await?; Ok(Json(result)) diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 60ded77f7..85bce16ad 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -96,6 +96,10 @@ pub fn build_router( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), ) + .route( + "/get_folder_conversation_paginated", + post(handlers::conversations::get_folder_conversation_paginated), + ) .route( "/list_opened_tabs", post(handlers::conversations::list_opened_tabs), diff --git a/src/lib/api.ts b/src/lib/api.ts index c6af4b131..cb016cdb8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -18,6 +18,7 @@ import type { ConversationSummary, ConversationDetail, DbConversationDetail, + DbConversationDetailPage, FolderInfo, AgentStats, SidebarData, @@ -890,6 +891,18 @@ export async function getFolderConversation( return getTransport().call("get_folder_conversation", { conversationId }) } +export async function getFolderConversationPaginated(params: { + conversationId: number + beforeTurnIndex?: number | null + pageSize?: number | null +}): Promise { + return getTransport().call("get_folder_conversation_paginated", { + conversationId: params.conversationId, + beforeTurnIndex: params.beforeTurnIndex ?? null, + pageSize: params.pageSize ?? null, + }) +} + export async function removeFolderFromHistory(path: string): Promise { return getTransport().call("remove_folder_from_history", { path }) } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 867f05b25..fe5b902f8 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -5,6 +5,7 @@ import type { ConversationSummary, ConversationDetail, DbConversationDetail, + DbConversationDetailPage, FolderInfo, AgentStats, SidebarData, @@ -591,6 +592,18 @@ export async function getFolderConversation( return invoke("get_folder_conversation", { conversationId }) } +export async function getFolderConversationPaginated(params: { + conversationId: number + beforeTurnIndex?: number | null + pageSize?: number | null +}): Promise { + return invoke("get_folder_conversation_paginated", { + conversationId: params.conversationId, + beforeTurnIndex: params.beforeTurnIndex ?? null, + pageSize: params.pageSize ?? null, + }) +} + export async function removeFolderFromHistory(path: string): Promise { return invoke("remove_folder_from_history", { path }) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 627a78d88..da906220d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -381,6 +381,16 @@ export interface DbConversationDetail { in_flight_user_turn_id?: string | null } +export interface DbConversationDetailPage { + summary: DbConversationSummary + turns: MessageTurn[] + has_more_history: boolean + next_before_turn_index?: number | null + page_size: number + session_stats?: SessionStats | null + in_flight_user_turn_id?: string | null +} + export type ConversationStatus = | "in_progress" | "pending_review"