perf: virtualize session message list for long conversations

Replace full DOM rendering with @tanstack/react-virtual to only render
visible messages (~25 DOM nodes instead of N). Wrap SessionMessageItem
in React.memo to prevent unnecessary re-renders on state changes.
This commit is contained in:
Jason
2026-04-14 16:12:48 +08:00
Unverified
parent 04508801ef
commit 7ae89e9106
4 changed files with 108 additions and 64 deletions
+1
View File
@@ -67,6 +67,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3", "@tanstack/react-query": "^5.90.3",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0",
+20
View File
@@ -89,6 +89,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.90.3 specifier: ^5.90.3
version: 5.90.3(react@18.3.1) 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': '@tauri-apps/api':
specifier: ^2.8.0 specifier: ^2.8.0
version: 2.8.0 version: 2.8.0
@@ -1468,6 +1471,15 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 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': '@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
@@ -4275,6 +4287,14 @@ snapshots:
'@tanstack/query-core': 5.90.3 '@tanstack/query-core': 5.90.3
react: 18.3.1 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/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.8.1': '@tauri-apps/cli-darwin-arm64@2.8.1':
+83 -57
View File
@@ -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 { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
@@ -69,8 +70,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
const { data, isLoading, refetch } = useSessionsQuery(); const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? []; const sessions = data ?? [];
const detailRef = useRef<HTMLDivElement | null>(null); const detailRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>( const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
null, null,
); );
@@ -134,6 +134,20 @@ export function SessionManagerPage({ appId }: { appId: string }) {
const deleteSessionMutation = useDeleteSessionMutation(); const deleteSessionMutation = useDeleteSessionMutation();
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting; 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(() => { useEffect(() => {
const validKeys = new Set( const validKeys = new Set(
sessions.map((session) => getSessionKey(session)), sessions.map((session) => getSessionKey(session)),
@@ -166,37 +180,36 @@ export function SessionManagerPage({ appId }: { appId: string }) {
}, [messages]); }, [messages]);
const scrollToMessage = (index: number) => { const scrollToMessage = (index: number) => {
const el = messageRefs.current.get(index); virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" });
if (el) { setActiveMessageIndex(index);
el.scrollIntoView({ behavior: "smooth", block: "center" }); setTocDialogOpen(false);
setActiveMessageIndex(index); setTimeout(() => setActiveMessageIndex(null), 2000);
setTocDialogOpen(false); // 关闭弹窗
// 清除高亮状态
setTimeout(() => setActiveMessageIndex(null), 2000);
}
}; };
// 清理定时器 const handleCopy = useCallback(
useEffect(() => { async (text: string, successMessage: string) => {
return () => { try {
// 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。 await navigator.clipboard.writeText(text);
// 如果要严格清理,需要用 useRef 存 timer id。 toast.success(successMessage);
// 但对于 2秒的高亮清除,通常不清理也没大问题。 } catch (error) {
// 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理) toast.error(
}; extractErrorMessage(error) ||
}, []); t("common.error", { defaultValue: "Copy failed" }),
);
}
},
[t],
);
const handleCopy = async (text: string, successMessage: string) => { const handleMessageCopy = useCallback(
try { (content: string) => {
await navigator.clipboard.writeText(text); void handleCopy(
toast.success(successMessage); content,
} catch (error) { t("sessionManager.messageCopied", { defaultValue: "已复制消息内容" }),
toast.error(
extractErrorMessage(error) ||
t("common.error", { defaultValue: "Copy failed" }),
); );
} },
}; [handleCopy, t],
);
const handleResume = async () => { const handleResume = async () => {
if (!selectedSession?.resumeCommand) return; if (!selectedSession?.resumeCommand) return;
@@ -973,9 +986,9 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<CardContent className="flex-1 min-h-0 p-0"> <CardContent className="flex-1 min-h-0 p-0">
<div className="flex h-full min-w-0"> <div className="flex h-full min-w-0">
{/* 消息列表 */} {/* 消息列表 */}
<ScrollArea className="flex-1 min-w-0"> <div className="flex-1 min-w-0 flex flex-col">
<div className="p-4 min-w-0"> <div className="px-4 pt-4 pb-2 min-w-0">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2">
<MessageSquare className="size-4 text-muted-foreground" /> <MessageSquare className="size-4 text-muted-foreground" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t("sessionManager.conversationHistory", { {t("sessionManager.conversationHistory", {
@@ -986,7 +999,11 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{messages.length} {messages.length}
</Badge> </Badge>
</div> </div>
</div>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-4 pb-4 min-w-0"
>
{isLoadingMessages ? ( {isLoadingMessages ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<RefreshCw className="size-5 animate-spin text-muted-foreground" /> <RefreshCw className="size-5 animate-spin text-muted-foreground" />
@@ -999,32 +1016,41 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div
{messages.map((message, index) => ( style={{
<SessionMessageItem height: virtualizer.getTotalSize(),
key={`${message.role}-${index}`} position: "relative",
message={message} }}
index={index} >
isActive={activeMessageIndex === index} {virtualizer
searchQuery={search} .getVirtualItems()
setRef={(el) => { .map((virtualRow) => (
if (el) messageRefs.current.set(index, el); <div
}} key={virtualRow.key}
onCopy={(content) => data-index={virtualRow.index}
handleCopy( ref={virtualizer.measureElement}
content, style={{
t("sessionManager.messageCopied", { position: "absolute",
defaultValue: "已复制消息内容", top: 0,
}), left: 0,
) width: "100%",
} transform: `translateY(${virtualRow.start}px)`,
/> }}
))} >
<div ref={messagesEndRef} /> <SessionMessageItem
message={messages[virtualRow.index]}
isActive={
activeMessageIndex === virtualRow.index
}
searchQuery={search}
onCopy={handleMessageCopy}
/>
</div>
))}
</div> </div>
)} )}
</div> </div>
</ScrollArea> </div>
{/* 右侧目录 - 类似少数派 (大屏幕) */} {/* 右侧目录 - 类似少数派 (大屏幕) */}
<SessionTocSidebar <SessionTocSidebar
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -18,27 +19,23 @@ import {
interface SessionMessageItemProps { interface SessionMessageItemProps {
message: SessionMessage; message: SessionMessage;
index: number;
isActive: boolean; isActive: boolean;
searchQuery?: string; searchQuery?: string;
setRef: (el: HTMLDivElement | null) => void;
onCopy: (content: string) => void; onCopy: (content: string) => void;
} }
export function SessionMessageItem({ export const SessionMessageItem = memo(function SessionMessageItem({
message, message,
isActive, isActive,
searchQuery, searchQuery,
setRef,
onCopy, onCopy,
}: SessionMessageItemProps) { }: SessionMessageItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
ref={setRef}
className={cn( className={cn(
"rounded-lg border px-3 py-2.5 relative group transition-all min-w-0", "rounded-lg border px-3 py-2.5 relative group transition-shadow min-w-0",
message.role.toLowerCase() === "user" message.role.toLowerCase() === "user"
? "bg-primary/5 border-primary/20 ml-8" ? "bg-primary/5 border-primary/20 ml-8"
: message.role.toLowerCase() === "assistant" : message.role.toLowerCase() === "assistant"
@@ -81,4 +78,4 @@ export function SessionMessageItem({
</div> </div>
</div> </div>
); );
} });