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 const url = this.getSessionPaths(agentId).base
try { 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) const result = ListAgentSessionsResponseSchema.safeParse(response.data)
if (!result.success) { if (!result.success) {
throw new Error('Not a valid Sessions array.') throw new Error('Not a valid Sessions array.')

View File

@ -1,29 +1,68 @@
import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from "@renderer/types";
import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { formatErrorMessageWithPrefix } from "@renderer/utils/error";
import { useCallback } from 'react' import { useCallback, useMemo } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from "react-i18next";
import useSWR from 'swr' import useSWRInfinite from "swr/infinite";
import { useAgentClient } from './useAgentClient' import { useAgentClient } from "./useAgentClient";
const PAGE_SIZE = 20
export const useSessions = (agentId: string | null) => { export const useSessions = (agentId: string | null) => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = agentId ? client.getSessionPaths(agentId).base : null
const fetcher = async () => { const getKey = (pageIndex: number, previousPageData: any) => {
if (!agentId) throw new Error('No active agent.') if (!agentId) return null
const data = await client.listSessions(agentId) if (previousPageData && previousPageData.data.length === 0) return null
return data.data 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( const createSession = useCallback(
async (form: CreateSessionForm): Promise<CreateAgentSessionResponse | null> => { async (form: CreateSessionForm): Promise<CreateAgentSessionResponse | null> => {
if (!agentId) return null if (!agentId) return null
try { try {
const result = await client.createSession(agentId, form) 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 return result
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) 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 if (!agentId) return null
try { try {
const result = await client.getSession(agentId, id) 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 return result
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed'))) 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 if (!agentId) return false
try { try {
await client.deleteSession(agentId, id) 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 return true
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed'))) window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
@ -64,9 +118,14 @@ export const useSessions = (agentId: string | null) => {
) )
return { return {
sessions: data ?? [], sessions,
total,
hasMore,
error, error,
isLoading, isLoading,
isLoadingMore,
isValidating,
loadMore,
createSession, createSession,
getSession, getSession,
deleteSession 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 { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions' import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
@ -12,9 +12,8 @@ import {
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Alert, Spin } from 'antd' import { Alert, Spin } from 'antd'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react' import { memo, useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddButton from './AddButton' import AddButton from './AddButton'
import SessionItem from './SessionItem' import SessionItem from './SessionItem'
@ -25,11 +24,29 @@ interface SessionsProps {
const Sessions: React.FC<SessionsProps> = ({ agentId }) => { const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation() 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 { chat } = useRuntime()
const { activeSessionIdMap } = chat const { activeSessionIdMap } = chat
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId) 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( const setActiveSessionId = useCallback(
(agentId: string, sessionId: string | null) => { (agentId: string, sessionId: string | null) => {
@ -96,36 +113,39 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
} }
return ( return (
<StyledVirtualList <DraggableVirtualList
ref={listRef}
className="sessions-tab" className="sessions-tab"
list={sessions} list={sessions}
estimateSize={() => 9 * 4} disabled
// FIXME: This component only supports CSSProperties style={{ height: '100%', padding: '9px 0 10px 10px' }}
scrollerStyle={{ overflowX: 'hidden' }} itemContainerStyle={{ paddingBottom: '8px' }}
autoHideScrollbar
header={ header={
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]"> <>
{t('agent.session.add.title')} <AddButton onClick={createDefaultSession} disabled={creatingSession}>
</AddButton> {t('agent.session.add.title')}
</AddButton>
<div className="my-1"></div>
</>
}> }>
{(session) => ( {(session, index) => (
<SessionItem <>
key={session.id} <SessionItem
session={session} key={session.id}
agentId={agentId} session={session}
onDelete={() => handleDeleteSession(session.id)} agentId={agentId}
onPress={() => setActiveSessionId(agentId, session.id)} 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) export default memo(Sessions)