From b8b8eafac90a9e63a29b0332fac70f9df0520cbd Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 20 May 2026 11:10:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=88=86=E9=A1=B5=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/conversations.rs | 30 ++- src-tauri/src/models/conversation.rs | 4 + src-tauri/src/web/handlers/conversations.rs | 13 +- .../conversation-detail-panel.tsx | 29 ++- src/components/message/message-list-view.tsx | 9 + .../message/virtualized-message-thread.tsx | 42 +++- src/contexts/conversation-runtime-context.tsx | 193 ++++++++++++++---- src/hooks/use-conversation-detail.ts | 11 +- src/lib/api.ts | 14 +- src/lib/tauri.ts | 14 +- src/lib/types.ts | 2 + 11 files changed, 310 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 8397aff7..d25363eb 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -283,6 +283,9 @@ pub async fn import_local_conversations( pub async fn get_folder_conversation_core( conn: &sea_orm::DatabaseConnection, conversation_id: i32, + offset: Option, + limit: Option, + latest: Option, ) -> Result { let summary = conversation_service::get_by_id(conn, conversation_id) .await @@ -370,11 +373,31 @@ pub async fn get_folder_conversation_core( } let mut summary = summary; - summary.message_count = turns.len() as u32; + let total_turns = turns.len(); + summary.message_count = total_turns as u32; + let wants_page = offset.is_some() || limit.is_some() || latest.unwrap_or(false); + let page_limit = limit.unwrap_or(total_turns).min(total_turns); + let page_offset = if latest.unwrap_or(false) { + total_turns.saturating_sub(page_limit) + } else { + offset.unwrap_or(0).min(total_turns) + }; + let turns = if wants_page { + let page_end = page_offset.saturating_add(page_limit).min(total_turns); + turns + .into_iter() + .skip(page_offset) + .take(page_end.saturating_sub(page_offset)) + .collect() + } else { + turns + }; Ok(DbConversationDetail { summary, turns, + turns_offset: wants_page.then_some(page_offset), + total_turns: wants_page.then_some(total_turns), session_stats, }) } @@ -384,8 +407,11 @@ pub async fn get_folder_conversation_core( pub async fn get_folder_conversation( db: tauri::State<'_, AppDatabase>, conversation_id: i32, + offset: Option, + limit: Option, + latest: Option, ) -> Result { - get_folder_conversation_core(&db.conn, conversation_id).await + get_folder_conversation_core(&db.conn, conversation_id, offset, limit, latest).await } /// Core logic for creating a conversation with git branch detection. diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index b1c5112b..20958e02 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -60,6 +60,10 @@ pub struct DbConversationDetail { pub summary: DbConversationSummary, pub turns: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub turns_offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub session_stats: Option, } diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index d337d2b9..01b117e0 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -106,6 +106,9 @@ pub async fn get_conversation( #[serde(rename_all = "camelCase")] pub struct GetFolderConversationParams { pub conversation_id: i32, + pub offset: Option, + pub limit: Option, + pub latest: Option, } pub async fn get_folder_conversation( @@ -113,8 +116,14 @@ pub async fn get_folder_conversation( Json(params): Json, ) -> Result, AppCommandError> { let db = &state.db; - let result = - conv_commands::get_folder_conversation_core(&db.conn, params.conversation_id).await?; + let result = conv_commands::get_folder_conversation_core( + &db.conn, + params.conversation_id, + params.offset, + params.limit, + params.latest, + ) + .await?; Ok(Json(result)) } diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 9d801604..6c164a78 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -30,6 +30,7 @@ import { useSessionStats } from "@/contexts/session-stats-context" import { useTaskContext } from "@/contexts/task-context" import { cn, copyTextToClipboard, randomUUID } from "@/lib/utils" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" +import { useIsMobile } from "@/hooks/use-mobile" import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue" import { MessageListView } from "@/components/message/message-list-view" import { ConversationShell } from "@/components/chat/conversation-shell" @@ -161,6 +162,7 @@ const ConversationTabView = memo(function ConversationTabView({ appendOptimisticTurn, completeTurn, getSession, + loadOlderTurns, refetchDetail, syncTurnMetadata, removeConversation, @@ -236,6 +238,11 @@ const ConversationTabView = memo(function ConversationTabView({ const createConversationPendingRef = useRef(false) const sessionIdRef = useRef(null) const syncCancelRef = useRef<(() => void) | null>(null) + const isMobile = useIsMobile() + const detailFetchOptions = useMemo( + () => (isMobile ? { paginated: true } : undefined), + [isMobile] + ) useEffect(() => { dbConvIdRef.current = dbConversationId @@ -265,7 +272,7 @@ const ConversationTabView = memo(function ConversationTabView({ loading: detailLoading, error: detailError, acpLoadError, - } = useConversationDetail(effectiveConversationId) + } = useConversationDetail(effectiveConversationId, detailFetchOptions) const runtimeSession = getSession(effectiveConversationId) const effectiveSessionStats = runtimeSession?.sessionStats ?? null @@ -498,8 +505,8 @@ const ConversationTabView = memo(function ConversationTabView({ signal: reloadSignal, sawLoading: false, } - refetchDetail(dbConversationId) - }, [dbConversationId, reloadSignal, refetchDetail]) + refetchDetail(dbConversationId, detailFetchOptions) + }, [dbConversationId, detailFetchOptions, reloadSignal, refetchDetail]) useEffect(() => { const pending = pendingReloadState.current @@ -811,8 +818,19 @@ const ConversationTabView = memo(function ConversationTabView({ if (acpLoadError) { acpActions.clearAcpLoadError(tabId) } - refetchDetail(dbConversationId) - }, [acpActions, acpLoadError, dbConversationId, refetchDetail, tabId]) + refetchDetail(dbConversationId, detailFetchOptions) + }, [ + acpActions, + acpLoadError, + dbConversationId, + detailFetchOptions, + refetchDetail, + tabId, + ]) + const handleLoadOlderTurns = useCallback(() => { + if (dbConversationId == null) return + void loadOlderTurns(dbConversationId) + }, [dbConversationId, loadOlderTurns]) // Open (or re-activate) the singleton draft tab BEFORE closing the failing // tab. closeTab auto-creates a replacement draft when it removes the last // tab, and `openNewConversationTab` reads `rawTabsRef.current` which @@ -842,6 +860,7 @@ const ConversationTabView = memo(function ConversationTabView({ onNewSession={ canShowDetailErrorActions ? handleOpenNewSession : undefined } + onLoadOlder={handleLoadOlderTurns} /> ) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index bdddff0e..e3825529 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -69,6 +69,7 @@ interface MessageListViewProps { hideEmptyState?: boolean onReload?: () => void onNewSession?: () => void + onLoadOlder?: () => void } interface ResolvedMessageGroup { @@ -415,6 +416,7 @@ export function MessageListView({ hideEmptyState = false, onReload, onNewSession, + onLoadOlder, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") @@ -422,6 +424,7 @@ export function MessageListView({ const session = getSession(conversationId) const liveMessage = session?.liveMessage ?? null const timelineTurns = getTimelineTurns(conversationId) + const persistedOffset = session?.detail?.turns_offset ?? 0 const { setSessionStats } = useSessionStats() @@ -701,6 +704,12 @@ export function MessageListView({ getItemKey={(item) => item.key} renderItem={renderThreadItem} emptyState={emptyState} + onNearTop={ + session?.hasOlderTurns && !session.olderTurnsLoading + ? onLoadOlder + : undefined + } + preserveScrollOnPrependKey={persistedOffset} /> diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx index 1002b2d2..f09ee157 100644 --- a/src/components/message/virtualized-message-thread.tsx +++ b/src/components/message/virtualized-message-thread.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useMemo, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef } from "react" import type { CSSProperties, ReactNode, RefObject } from "react" import { Virtualizer, type VirtualizerHandle } from "virtua" import { useStickToBottomContext } from "use-stick-to-bottom" @@ -45,6 +45,8 @@ interface VirtualizedMessageThreadProps { contentClassName?: string /** Extra props forwarded to MessageThreadContent. */ contentProps?: Omit + onNearTop?: () => void + preserveScrollOnPrependKey?: number | string | null } export function VirtualizedMessageThread({ @@ -59,9 +61,17 @@ export function VirtualizedMessageThread({ className, contentClassName, contentProps, + onNearTop, + preserveScrollOnPrependKey, }: VirtualizedMessageThreadProps) { const { scrollRef } = useStickToBottomContext() const virtualizerHandleRef = useRef(null) + const beforePrependRef = useRef<{ + key: number | string | null | undefined + scrollHeight: number + scrollTop: number + } | null>(null) + const nearTopLoadKeyRef = useRef(null) const scrollToIndex = useCallback( (index, opts) => { @@ -93,6 +103,36 @@ export function VirtualizedMessageThread({ return styles.middle } + useEffect(() => { + const el = scrollRef.current + if (!el || !onNearTop) return + const handleScroll = () => { + if (el.scrollTop > 240) return + if (nearTopLoadKeyRef.current === preserveScrollOnPrependKey) return + nearTopLoadKeyRef.current = preserveScrollOnPrependKey + beforePrependRef.current = { + key: preserveScrollOnPrependKey, + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop, + } + onNearTop() + } + el.addEventListener("scroll", handleScroll, { passive: true }) + return () => el.removeEventListener("scroll", handleScroll) + }, [onNearTop, preserveScrollOnPrependKey, scrollRef]) + + useEffect(() => { + const snapshot = beforePrependRef.current + const el = scrollRef.current + if (!snapshot || !el) return + if (snapshot.key === preserveScrollOnPrependKey) return + beforePrependRef.current = null + nearTopLoadKeyRef.current = null + const heightDelta = el.scrollHeight - snapshot.scrollHeight + if (heightDelta <= 0) return + el.scrollTop = snapshot.scrollTop + heightDelta + }, [preserveScrollOnPrependKey, scrollRef]) + return ( 0, externalId: nextExternalId ?? current.externalId, localTurns: [], sessionStats: action.detail.session_stats ?? current.sessionStats, @@ -735,6 +761,48 @@ function reducer( ...current, detailLoading: false, detailError: action.error, + olderTurnsLoading: false, + })) + + case "FETCH_OLDER_TURNS_START": + return updateSessionInState(state, action.conversationId, (current) => ({ + ...current, + olderTurnsLoading: true, + detailError: null, + })) + + case "FETCH_OLDER_TURNS_SUCCESS": + return updateSessionInState(state, action.conversationId, (current) => { + if (!current.detail) { + return { + ...current, + detail: action.detail, + olderTurnsLoading: false, + hasOlderTurns: (action.detail.turns_offset ?? 0) > 0, + } + } + + return { + ...current, + detail: { + ...current.detail, + turns: [...action.detail.turns, ...current.detail.turns], + turns_offset: action.detail.turns_offset, + total_turns: action.detail.total_turns, + session_stats: + action.detail.session_stats ?? current.detail.session_stats, + }, + olderTurnsLoading: false, + hasOlderTurns: (action.detail.turns_offset ?? 0) > 0, + sessionStats: action.detail.session_stats ?? current.sessionStats, + } + }) + + case "FETCH_OLDER_TURNS_ERROR": + return updateSessionInState(state, action.conversationId, (current) => ({ + ...current, + olderTurnsLoading: false, + detailError: action.error, })) case "COMPLETE_TURN": { @@ -995,8 +1063,9 @@ interface ConversationRuntimeContextValue { getSession: (conversationId: number) => ConversationRuntimeSession | null getConversationIdByExternalId: (externalId: string) => number | null getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[] - fetchDetail: (conversationId: number) => void - refetchDetail: (conversationId: number) => void + fetchDetail: (conversationId: number, options?: FetchDetailOptions) => void + refetchDetail: (conversationId: number, options?: FetchDetailOptions) => void + loadOlderTurns: (conversationId: number) => Promise completeTurn: ( conversationId: number, liveMessage?: LiveMessage | null @@ -1111,47 +1180,99 @@ export function ConversationRuntimeProvider({ [state.byConversationId] ) - const fetchDetail = useCallback((conversationId: number) => { - const session = stateRef.current.byConversationId.get(conversationId) - if (session?.detail || session?.detailLoading) return + const fetchDetail = useCallback( + (conversationId: number, options?: FetchDetailOptions) => { + const session = stateRef.current.byConversationId.get(conversationId) + if (session?.detail || session?.detailLoading) return + + // Skip fetch if session has active data (ongoing conversation) + if ( + session && + (session.optimisticTurns.length > 0 || + session.liveMessage !== null || + session.localTurns.length > 0) + ) { + return + } - // Skip fetch if session has active data (ongoing conversation) + dispatch({ type: "FETCH_DETAIL_START", conversationId }) + getFolderConversation( + conversationId, + options?.paginated + ? { + latest: true, + limit: CONVERSATION_TURN_PAGE_SIZE, + } + : undefined + ) + .then((detail) => { + dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) + }) + .catch((error: unknown) => { + dispatch({ + type: "FETCH_DETAIL_ERROR", + conversationId, + error: toErrorMessage(error), + }) + }) + }, + [] + ) + + const refetchDetail = useCallback( + (conversationId: number, options?: FetchDetailOptions) => { + dispatch({ type: "FETCH_DETAIL_START", conversationId }) + getFolderConversation( + conversationId, + options?.paginated + ? { + latest: true, + limit: CONVERSATION_TURN_PAGE_SIZE, + } + : undefined + ) + .then((detail) => { + dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) + }) + .catch((error: unknown) => { + dispatch({ + type: "FETCH_DETAIL_ERROR", + conversationId, + error: toErrorMessage(error), + }) + }) + }, + [] + ) + + const loadOlderTurns = useCallback(async (conversationId: number) => { + const session = stateRef.current.byConversationId.get(conversationId) + const currentOffset = session?.detail?.turns_offset ?? 0 if ( - session && - (session.optimisticTurns.length > 0 || - session.liveMessage !== null || - session.localTurns.length > 0) + !session?.detail || + session.olderTurnsLoading || + !session.hasOlderTurns || + currentOffset <= 0 ) { return } - dispatch({ type: "FETCH_DETAIL_START", conversationId }) - getFolderConversation(conversationId) - .then((detail) => { - dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) - }) - .catch((error: unknown) => { - dispatch({ - type: "FETCH_DETAIL_ERROR", - conversationId, - error: toErrorMessage(error), - }) - }) - }, []) - - const refetchDetail = useCallback((conversationId: number) => { - dispatch({ type: "FETCH_DETAIL_START", conversationId }) - getFolderConversation(conversationId) - .then((detail) => { - dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) + const limit = Math.min(CONVERSATION_TURN_PAGE_SIZE, currentOffset) + const offset = Math.max(0, currentOffset - limit) + dispatch({ type: "FETCH_OLDER_TURNS_START", conversationId }) + try { + const detail = await getFolderConversation(conversationId, { + offset, + limit, }) - .catch((error: unknown) => { - dispatch({ - type: "FETCH_DETAIL_ERROR", - conversationId, - error: toErrorMessage(error), - }) + dispatch({ type: "FETCH_OLDER_TURNS_SUCCESS", conversationId, detail }) + } catch (error: unknown) { + dispatch({ + type: "FETCH_OLDER_TURNS_ERROR", + conversationId, + error: toErrorMessage(error), }) + } }, []) const syncTurnMetadata = useCallback( @@ -1393,6 +1514,7 @@ export function ConversationRuntimeProvider({ getTimelineTurns, fetchDetail, refetchDetail, + loadOlderTurns, syncTurnMetadata, completeTurn, appendOptimisticTurn, @@ -1411,6 +1533,7 @@ export function ConversationRuntimeProvider({ getTimelineTurns, fetchDetail, refetchDetail, + loadOlderTurns, syncTurnMetadata, completeTurn, appendOptimisticTurn, diff --git a/src/hooks/use-conversation-detail.ts b/src/hooks/use-conversation-detail.ts index bcfe1be0..dd39c297 100644 --- a/src/hooks/use-conversation-detail.ts +++ b/src/hooks/use-conversation-detail.ts @@ -8,7 +8,10 @@ function isVirtualConversationId(conversationId: number): boolean { return !Number.isFinite(conversationId) || conversationId <= 0 } -export function useConversationDetail(conversationId: number): { +export function useConversationDetail( + conversationId: number, + options?: { paginated?: boolean } +): { detail: DbConversationDetail | null loading: boolean error: string | null @@ -21,10 +24,14 @@ export function useConversationDetail(conversationId: number): { useEffect(() => { if (isVirtual) return if (session?.detail || session?.detailLoading) return - fetchDetail(conversationId) + fetchDetail( + conversationId, + options?.paginated ? { paginated: true } : undefined + ) }, [ conversationId, isVirtual, + options?.paginated, session?.detail, session?.detailLoading, fetchDetail, diff --git a/src/lib/api.ts b/src/lib/api.ts index 276133f3..63212d0c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -747,9 +747,19 @@ export async function importLocalConversations( } export async function getFolderConversation( - conversationId: number + conversationId: number, + params?: { + offset?: number | null + limit?: number | null + latest?: boolean | null + } ): Promise { - return getTransport().call("get_folder_conversation", { conversationId }) + return getTransport().call("get_folder_conversation", { + conversationId, + offset: params?.offset ?? null, + limit: params?.limit ?? null, + latest: params?.latest ?? null, + }) } export async function removeFolderFromHistory(path: string): Promise { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 1dd628ee..44dc81d7 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -559,9 +559,19 @@ export async function importLocalConversations( } export async function getFolderConversation( - conversationId: number + conversationId: number, + params?: { + offset?: number | null + limit?: number | null + latest?: boolean | null + } ): Promise { - return invoke("get_folder_conversation", { conversationId }) + return invoke("get_folder_conversation", { + conversationId, + offset: params?.offset ?? null, + limit: params?.limit ?? null, + latest: params?.latest ?? null, + }) } export async function removeFolderFromHistory(path: string): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a0b1197..859e2c13 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -258,6 +258,8 @@ export interface ImportResult { export interface DbConversationDetail { summary: DbConversationSummary turns: MessageTurn[] + turns_offset?: number | null + total_turns?: number | null session_stats?: SessionStats | null }