diff --git a/package.json b/package.json index 5301a971..d07ecbf9 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.90.3", + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-process": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c791d5e..9fe22cfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.3 version: 5.90.3(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: ^2.8.0 version: 2.8.0 @@ -1468,6 +1471,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tauri-apps/api@2.8.0': resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} @@ -4275,6 +4287,14 @@ snapshots: '@tanstack/query-core': 5.90.3 react: 18.3.1 + '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.23': {} + '@tauri-apps/api@2.8.0': {} '@tauri-apps/cli-darwin-arm64@2.8.1': diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index 3f16de0c..0c4b219a 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -69,8 +70,7 @@ export function SessionManagerPage({ appId }: { appId: string }) { const { data, isLoading, refetch } = useSessionsQuery(); const sessions = data ?? []; const detailRef = useRef(null); - const messagesEndRef = useRef(null); - const messageRefs = useRef>(new Map()); + const scrollContainerRef = useRef(null); const [activeMessageIndex, setActiveMessageIndex] = useState( null, ); @@ -134,6 +134,20 @@ export function SessionManagerPage({ appId }: { appId: string }) { const deleteSessionMutation = useDeleteSessionMutation(); const isDeleting = deleteSessionMutation.isPending || isBatchDeleting; + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 120, + overscan: 5, + gap: 12, + }); + + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [selectedKey]); + useEffect(() => { const validKeys = new Set( sessions.map((session) => getSessionKey(session)), @@ -166,37 +180,36 @@ export function SessionManagerPage({ appId }: { appId: string }) { }, [messages]); const scrollToMessage = (index: number) => { - const el = messageRefs.current.get(index); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - setActiveMessageIndex(index); - setTocDialogOpen(false); // 关闭弹窗 - // 清除高亮状态 - setTimeout(() => setActiveMessageIndex(null), 2000); - } + virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); + setActiveMessageIndex(index); + setTocDialogOpen(false); + setTimeout(() => setActiveMessageIndex(null), 2000); }; - // 清理定时器 - useEffect(() => { - return () => { - // 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。 - // 如果要严格清理,需要用 useRef 存 timer id。 - // 但对于 2秒的高亮清除,通常不清理也没大问题。 - // 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理) - }; - }, []); + const handleCopy = useCallback( + async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + } catch (error) { + toast.error( + extractErrorMessage(error) || + t("common.error", { defaultValue: "Copy failed" }), + ); + } + }, + [t], + ); - const handleCopy = async (text: string, successMessage: string) => { - try { - await navigator.clipboard.writeText(text); - toast.success(successMessage); - } catch (error) { - toast.error( - extractErrorMessage(error) || - t("common.error", { defaultValue: "Copy failed" }), + const handleMessageCopy = useCallback( + (content: string) => { + void handleCopy( + content, + t("sessionManager.messageCopied", { defaultValue: "已复制消息内容" }), ); - } - }; + }, + [handleCopy, t], + ); const handleResume = async () => { if (!selectedSession?.resumeCommand) return; @@ -973,9 +986,9 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{/* 消息列表 */} - -
-
+
+
+
{t("sessionManager.conversationHistory", { @@ -986,7 +999,11 @@ export function SessionManagerPage({ appId }: { appId: string }) { {messages.length}
- +
+
{isLoadingMessages ? (
@@ -999,32 +1016,41 @@ export function SessionManagerPage({ appId }: { appId: string }) {

) : ( -
- {messages.map((message, index) => ( - { - if (el) messageRefs.current.set(index, el); - }} - onCopy={(content) => - handleCopy( - content, - t("sessionManager.messageCopied", { - defaultValue: "已复制消息内容", - }), - ) - } - /> - ))} -
+
+ {virtualizer + .getVirtualItems() + .map((virtualRow) => ( +
+ +
+ ))}
)}
- +
{/* 右侧目录 - 类似少数派 (大屏幕) */} void; onCopy: (content: string) => void; } -export function SessionMessageItem({ +export const SessionMessageItem = memo(function SessionMessageItem({ message, isActive, searchQuery, - setRef, onCopy, }: SessionMessageItemProps) { const { t } = useTranslation(); return (
); -} +});