From 4a68052d2ebce81e00a3212b964626a86f6971a8 Mon Sep 17 00:00:00 2001 From: scyyw24 Date: Tue, 16 Dec 2025 22:20:56 +0000 Subject: [PATCH] fix: implement infinite scroll for sessions list Refactored session fetching to use paginated API calls and SWR's useSWRInfinite for infinite loading. Updated the Sessions component to use DraggableVirtualList, handle scroll events to load more sessions, and display a loading spinner when fetching additional data. Adjusted session creation, update, and deletion logic to work with paginated data. --- src/renderer/src/api/agent.ts | 13 ++- src/renderer/src/hooks/agents/useSessions.ts | 91 +++++++++++++++---- .../pages/home/Tabs/components/Sessions.tsx | 76 ++++++++++------ 3 files changed, 134 insertions(+), 46 deletions(-) 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)