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.
This commit is contained in:
scyyw24 2025-12-16 22:20:56 +00:00
parent f2b4a2382b
commit 4a68052d2e
3 changed files with 134 additions and 46 deletions

View File

@ -172,10 +172,19 @@ export class AgentApiClient {
}
}
public async listSessions(agentId: string): Promise<ListAgentSessionsResponse> {
public async listSessions(agentId: string, options?: ListOptions): Promise<ListAgentSessionsResponse> {
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.')

View File

@ -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<CreateAgentSessionResponse | null> => {
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

View File

@ -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<SessionsProps> = ({ 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<DraggableVirtualListRef>(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<SessionsProps> = ({ agentId }) => {
}
return (
<StyledVirtualList
<DraggableVirtualList
ref={listRef}
className="sessions-tab"
list={sessions}
estimateSize={() => 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={
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
{t('agent.session.add.title')}
</AddButton>
<>
<AddButton onClick={createDefaultSession} disabled={creatingSession}>
{t('agent.session.add.title')}
</AddButton>
<div className="my-1"></div>
</>
}>
{(session) => (
<SessionItem
key={session.id}
session={session}
agentId={agentId}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
{(session, index) => (
<>
<SessionItem
key={session.id}
session={session}
agentId={agentId}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
{index === sessions.length - 1 && isLoadingMore && (
<div className="flex justify-center py-2">
<Spin size="small" />
</div>
)}
</>
)}
</StyledVirtualList>
</DraggableVirtualList>
)
}
const StyledVirtualList = styled(DynamicVirtualList)`
display: flex;
flex-direction: column;
padding: 12px 10px;
height: 100%;
` as typeof DynamicVirtualList
export default memo(Sessions)