mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 05:09:09 +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
|
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.')
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user