mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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",
|
||||||
|
|||||||
Generated
+20
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user