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..fe9f171f99 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,29 +1,68 @@ -import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' -import { formatErrorMessageWithPrefix } from '@renderer/utils/error' -import { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import useSWR from 'swr' +import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from "@renderer/types"; +import { formatErrorMessageWithPrefix } from "@renderer/utils/error"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import useSWRInfinite from "swr/infinite"; -import { useAgentClient } from './useAgentClient' +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: any) => { + 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 newData = [...prev] + newData[0] = { + ...newData[0], + data: [result, ...newData[0].data], + total: newData[0].total + 1 + } + return newData + }, + { revalidate: false } + ) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) @@ -38,7 +77,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 +99,15 @@ 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) => + prev?.map((page) => ({ + ...page, + data: page.data.filter((session) => session.id !== id), + total: page.total - 1 + })), + { revalidate: false } + ) return true } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed'))) @@ -64,9 +118,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..7c32b10d4f 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 { DraggableVirtualList, type DraggableVirtualListRef } from '@renderer/components/DraggableList' import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession' import { useSessions } from '@renderer/hooks/agents/useSessions' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -12,9 +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 { memo, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' import AddButton from './AddButton' import SessionItem from './SessionItem' @@ -25,11 +24,29 @@ 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, total } = useSessions(agentId) const { chat } = useRuntime() const { activeSessionIdMap } = chat const dispatch = useAppDispatch() const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId) + const listRef = useRef(null) + + // Handle scroll to load more + useEffect(() => { + const scrollElement = listRef.current?.scrollElement() + if (!scrollElement) return + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollElement + // Load more when scrolled to bottom (with 100px threshold) + if (scrollHeight - scrollTop - clientHeight < 100 && hasMore && !isLoadingMore) { + loadMore() + } + } + + scrollElement.addEventListener('scroll', handleScroll) + return () => scrollElement.removeEventListener('scroll', handleScroll) + }, [hasMore, isLoadingMore, loadMore]) const setActiveSessionId = useCallback( (agentId: string, sessionId: string | null) => { @@ -96,36 +113,39 @@ const Sessions: React.FC = ({ agentId }) => { } return ( - 9 * 4} - // FIXME: This component only supports CSSProperties - scrollerStyle={{ overflowX: 'hidden' }} - autoHideScrollbar + disabled + style={{ height: '100%', padding: '9px 0 10px 10px' }} + itemContainerStyle={{ paddingBottom: '8px' }} header={ - - {t('agent.session.add.title')} - + <> + + {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 && ( +
+ +
+ )} + )} -
+ ) } -const StyledVirtualList = styled(DynamicVirtualList)` - display: flex; - flex-direction: column; - padding: 12px 10px; - height: 100%; -` as typeof DynamicVirtualList - export default memo(Sessions)