diff --git a/src/renderer/src/api/agent.ts b/src/renderer/src/api/agent.ts index cc997e77b5..ffbb9de2c8 100644 --- a/src/renderer/src/api/agent.ts +++ b/src/renderer/src/api/agent.ts @@ -172,10 +172,19 @@ export class AgentApiClient { } } - public async listSessions(agentId: string): Promise { + public async listSessions(agentId: string, options?: ListOptions): Promise { const url = this.getSessionPaths(agentId).base try { - const response = await this.axios.get(url) + const params = new URLSearchParams() + if (options?.limit !== undefined) params.append('limit', String(options.limit)) + if (options?.offset !== undefined) params.append('offset', String(options.offset)) + if (options?.sortBy) params.append('sortBy', options.sortBy) + if (options?.orderBy) params.append('orderBy', options.orderBy) + + const queryString = params.toString() + const fullUrl = queryString ? `${url}?${queryString}` : url + + const response = await this.axios.get(fullUrl) const result = ListAgentSessionsResponseSchema.safeParse(response.data) if (!result.success) { throw new Error('Not a valid Sessions array.') diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index a9977ba2e3..84481b26e4 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,29 +1,72 @@ -import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' +import type { + CreateAgentSessionResponse, + CreateSessionForm, + GetAgentSessionResponse, + ListAgentSessionsResponse +} from '@renderer/types' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' import { useAgentClient } from './useAgentClient' +const PAGE_SIZE = 20 + export const useSessions = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const key = agentId ? client.getSessionPaths(agentId).base : null - const fetcher = async () => { - if (!agentId) throw new Error('No active agent.') - const data = await client.listSessions(agentId) - return data.data + const getKey = (pageIndex: number, previousPageData: ListAgentSessionsResponse | null) => { + if (!agentId) return null + if (previousPageData && previousPageData.data.length === 0) return null + return [client.getSessionPaths(agentId).base, pageIndex] } - const { data, error, isLoading, mutate } = useSWR(key, fetcher) + + const fetcher = async ([, pageIndex]: [string, number]) => { + if (!agentId) throw new Error('No active agent.') + return await client.listSessions(agentId, { + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE + }) + } + + const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(getKey, fetcher) + + const sessions = useMemo(() => { + if (!data) return [] + return data.flatMap((page) => page.data) + }, [data]) + + const total = data?.[0]?.total ?? 0 + const hasMore = sessions.length < total + const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined') + + const loadMore = useCallback(() => { + if (!isLoadingMore && hasMore) { + setSize(size + 1) + } + }, [isLoadingMore, hasMore, setSize, size]) const createSession = useCallback( async (form: CreateSessionForm): Promise => { if (!agentId) return null try { const result = await client.createSession(agentId, form) - await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false }) + await mutate( + (prev) => { + if (!prev || prev.length === 0) { + return [{ data: [result], total: 1, limit: PAGE_SIZE, offset: 0 }] + } + const newTotal = prev[0].total + 1 + return prev.map((page, index) => ({ + ...page, + data: index === 0 ? [result, ...page.data] : page.data, + total: newTotal + })) + }, + { revalidate: false } + ) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) @@ -38,7 +81,14 @@ export const useSessions = (agentId: string | null) => { if (!agentId) return null try { const result = await client.getSession(agentId, id) - mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session))) + mutate( + (prev) => + prev?.map((page) => ({ + ...page, + data: page.data.map((session) => (session.id === result.id ? result : session)) + })), + { revalidate: false } + ) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed'))) @@ -53,7 +103,18 @@ export const useSessions = (agentId: string | null) => { if (!agentId) return false try { await client.deleteSession(agentId, id) - mutate((prev) => prev?.filter((session) => session.id !== id)) + mutate( + (prev) => { + if (!prev || prev.length === 0) return prev + const newTotal = prev[0].total - 1 + return prev.map((page) => ({ + ...page, + data: page.data.filter((session) => session.id !== id), + total: newTotal + })) + }, + { revalidate: false } + ) return true } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed'))) @@ -64,9 +125,14 @@ export const useSessions = (agentId: string | null) => { ) return { - sessions: data ?? [], + sessions, + total, + hasMore, error, isLoading, + isLoadingMore, + isValidating, + loadMore, createSession, getSession, deleteSession diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 2ec754d23b..a8014868cd 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -1,4 +1,4 @@ -import { DynamicVirtualList } from '@renderer/components/VirtualList' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession' import { useSessions } from '@renderer/hooks/agents/useSessions' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -12,7 +12,8 @@ import { import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { Alert, Spin } from 'antd' import { motion } from 'framer-motion' -import { memo, useCallback, useEffect } from 'react' +import { throttle } from 'lodash' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -25,11 +26,39 @@ interface SessionsProps { const Sessions: React.FC = ({ agentId }) => { const { t } = useTranslation() - const { sessions, isLoading, error, deleteSession } = useSessions(agentId) + const { sessions, isLoading, error, deleteSession, hasMore, loadMore, isLoadingMore } = useSessions(agentId) const { chat } = useRuntime() const { activeSessionIdMap } = chat const dispatch = useAppDispatch() const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId) + const listRef = useRef(null) + + // Throttled scroll handler to avoid excessive calls + const handleScroll = useMemo( + () => + throttle(() => { + const scrollElement = listRef.current?.scrollElement() + if (!scrollElement) return + + const { scrollTop, scrollHeight, clientHeight } = scrollElement + if (scrollHeight - scrollTop - clientHeight < 100 && hasMore && !isLoadingMore) { + loadMore() + } + }, 150), + [hasMore, isLoadingMore, loadMore] + ) + + // Handle scroll to load more + useEffect(() => { + const scrollElement = listRef.current?.scrollElement() + if (!scrollElement) return + + scrollElement.addEventListener('scroll', handleScroll) + return () => { + handleScroll.cancel() + scrollElement.removeEventListener('scroll', handleScroll) + } + }, [handleScroll]) const setActiveSessionId = useCallback( (agentId: string, sessionId: string | null) => { @@ -97,6 +126,7 @@ const Sessions: React.FC = ({ agentId }) => { return ( 9 * 4} @@ -108,14 +138,21 @@ const Sessions: React.FC = ({ agentId }) => { {t('agent.session.add.title')} }> - {(session) => ( - handleDeleteSession(session.id)} - onPress={() => setActiveSessionId(agentId, session.id)} - /> + {(session, index) => ( + <> + handleDeleteSession(session.id)} + onPress={() => setActiveSessionId(agentId, session.id)} + /> + {index === sessions.length - 1 && isLoadingMore && ( +
+ +
+ )} + )}
)