mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
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:
parent
f2b4a2382b
commit
4a68052d2e
@ -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.')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user