From 4a68052d2ebce81e00a3212b964626a86f6971a8 Mon Sep 17 00:00:00 2001 From: scyyw24 Date: Tue, 16 Dec 2025 22:20:56 +0000 Subject: [PATCH 1/4] 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) From 498eca1e319f1b3552bc0b820e7ac2c78ae30e5e Mon Sep 17 00:00:00 2001 From: scyyw24 Date: Tue, 16 Dec 2025 22:26:55 +0000 Subject: [PATCH 2/4] Update Sessions.tsx --- .../pages/home/Tabs/components/Sessions.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 7c32b10d4f..5b9f1360c6 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 { DraggableVirtualList, type DraggableVirtualListRef } from '@renderer/components/DraggableList' +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' @@ -14,6 +14,7 @@ import { Alert, Spin } from 'antd' import { motion } from 'framer-motion' 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' @@ -24,12 +25,12 @@ interface SessionsProps { const Sessions: React.FC = ({ agentId }) => { const { t } = useTranslation() - const { sessions, isLoading, error, deleteSession, hasMore, loadMore, isLoadingMore, total } = 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) + const listRef = useRef(null) // Handle scroll to load more useEffect(() => { @@ -113,20 +114,18 @@ const Sessions: React.FC = ({ agentId }) => { } return ( - 9 * 4} + // FIXME: This component only supports CSSProperties + scrollerStyle={{ overflowX: 'hidden' }} + autoHideScrollbar header={ - <> - - {t('agent.session.add.title')} - -
- + + {t('agent.session.add.title')} + }> {(session, index) => ( <> @@ -144,8 +143,15 @@ const Sessions: React.FC = ({ agentId }) => { )} )} -
+ ) } +const StyledVirtualList = styled(DynamicVirtualList)` + display: flex; + flex-direction: column; + padding: 12px 10px; + height: 100%; +` as typeof DynamicVirtualList + export default memo(Sessions) From 2119081731f59e6fc21dd67cf00bc50affde0a88 Mon Sep 17 00:00:00 2001 From: scyyw24 Date: Tue, 16 Dec 2025 22:32:13 +0000 Subject: [PATCH 3/4] fix ci/cd format error --- src/renderer/src/hooks/agents/useSessions.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index fe9f171f99..7c1f21e969 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,10 +1,10 @@ -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 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 From bb919e3b21c818dc8e3e05377513d65dfc7e30a5 Mon Sep 17 00:00:00 2001 From: scyyw24 Date: Thu, 18 Dec 2025 12:21:21 +0000 Subject: [PATCH 4/4] fix: session list pagination and scroll performance Improves session list pagination by correctly updating the total count when adding or deleting sessions. Optimizes the scroll event handler in the Sessions component by throttling loadMore calls to prevent excessive triggering. --- src/renderer/src/hooks/agents/useSessions.ts | 33 +++++++++++-------- .../pages/home/Tabs/components/Sessions.tsx | 33 ++++++++++++------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index 7c1f21e969..84481b26e4 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,4 +1,9 @@ -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, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +17,7 @@ export const useSessions = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const getKey = (pageIndex: number, previousPageData: any) => { + 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] @@ -53,13 +58,12 @@ export const useSessions = (agentId: string | null) => { 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 + const newTotal = prev[0].total + 1 + return prev.map((page, index) => ({ + ...page, + data: index === 0 ? [result, ...page.data] : page.data, + total: newTotal + })) }, { revalidate: false } ) @@ -100,12 +104,15 @@ export const useSessions = (agentId: string | null) => { try { await client.deleteSession(agentId, id) mutate( - (prev) => - prev?.map((page) => ({ + (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: page.total - 1 - })), + total: newTotal + })) + }, { revalidate: false } ) return true diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 5b9f1360c6..a8014868cd 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -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, useRef } from 'react' +import { throttle } from 'lodash' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -32,22 +33,32 @@ const Sessions: React.FC = ({ agentId }) => { 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 - 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]) + return () => { + handleScroll.cancel() + scrollElement.removeEventListener('scroll', handleScroll) + } + }, [handleScroll]) const setActiveSessionId = useCallback( (agentId: string, sessionId: string | null) => {