优化会话侧边栏性能
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useState, useCallback } from "react"
|
import { memo, useState, useCallback, useMemo } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||||
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
|
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
|
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
|
||||||
import { STATUS_COLORS } from "@/lib/types"
|
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { AgentIcon } from "@/components/agent-icon"
|
import { AgentIcon } from "@/components/agent-icon"
|
||||||
import {
|
import {
|
||||||
@@ -39,12 +39,6 @@ import {
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
const ALL_STATUSES: ConversationStatus[] = [
|
|
||||||
"in_progress",
|
|
||||||
"pending_review",
|
|
||||||
"completed",
|
|
||||||
"cancelled",
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SidebarConversationCardProps {
|
interface SidebarConversationCardProps {
|
||||||
conversation: DbConversationSummary
|
conversation: DbConversationSummary
|
||||||
@@ -80,10 +74,14 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [renameValue, setRenameValue] = useState("")
|
const [renameValue, setRenameValue] = useState("")
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(new Date(conversation.updated_at), {
|
const timeAgo = useMemo(
|
||||||
addSuffix: true,
|
() =>
|
||||||
locale: dateFnsLocale,
|
formatDistanceToNow(new Date(conversation.updated_at), {
|
||||||
})
|
addSuffix: true,
|
||||||
|
locale: dateFnsLocale,
|
||||||
|
}),
|
||||||
|
[conversation.updated_at, dateFnsLocale]
|
||||||
|
)
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(conversation.id, conversation.agent_type)
|
onSelect(conversation.id, conversation.agent_type)
|
||||||
@@ -166,7 +164,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
{t("status")}
|
{t("status")}
|
||||||
</ContextMenuSubTrigger>
|
</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent>
|
||||||
{ALL_STATUSES.filter((s) => s !== conversation.status).map(
|
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
|
||||||
(s) => (
|
(s) => (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
key={s}
|
key={s}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||||
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
@@ -25,11 +28,6 @@ import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
|||||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
CollapsibleContent,
|
|
||||||
} from "@/components/ui/collapsible"
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
@@ -69,6 +67,108 @@ function compareByUpdatedAtDesc(
|
|||||||
return right.id - left.id
|
return right.id - left.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlatItem =
|
||||||
|
| { type: "header"; status: ConversationStatus; count: number }
|
||||||
|
| { type: "conversation"; conversation: DbConversationSummary }
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 32
|
||||||
|
const CARD_HEIGHT = 58
|
||||||
|
|
||||||
|
const GroupHeader = memo(function GroupHeader({
|
||||||
|
status,
|
||||||
|
count,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
tStatus,
|
||||||
|
}: {
|
||||||
|
status: ConversationStatus
|
||||||
|
count: number
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: (status: ConversationStatus) => void
|
||||||
|
tStatus: ReturnType<typeof useTranslations>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(status)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0 transition-transform",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full shrink-0",
|
||||||
|
STATUS_COLORS[status]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{tStatus(status)}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground/60 tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const PendingReviewHeader = memo(function PendingReviewHeader({
|
||||||
|
count,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
reviewConversationCount,
|
||||||
|
completingReview,
|
||||||
|
onCompleteReview,
|
||||||
|
tStatus,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
count: number
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: (status: ConversationStatus) => void
|
||||||
|
reviewConversationCount: number
|
||||||
|
completingReview: boolean
|
||||||
|
onCompleteReview: () => void
|
||||||
|
tStatus: ReturnType<typeof useTranslations>
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle("pending_review")}
|
||||||
|
className="flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0 transition-transform",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full shrink-0",
|
||||||
|
STATUS_COLORS.pending_review
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{tStatus("pending_review")}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground/60 tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
disabled={reviewConversationCount === 0 || completingReview}
|
||||||
|
onSelect={onCompleteReview}
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4" />
|
||||||
|
{t("completeAllSessions")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export interface SidebarConversationListHandle {
|
export interface SidebarConversationListHandle {
|
||||||
scrollToActive: () => void
|
scrollToActive: () => void
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
@@ -112,30 +212,14 @@ export function SidebarConversationList({
|
|||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const scrollToActiveRef = useRef<() => void>(() => {})
|
||||||
|
const pendingScrollRef = useRef(false)
|
||||||
|
const virtualizerRef =
|
||||||
|
useRef<ReturnType<typeof useVirtualizer<HTMLDivElement, Element>>>(null)
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToActive() {
|
scrollToActive() {
|
||||||
if (!selectedConversation) return
|
scrollToActiveRef.current()
|
||||||
const conv = conversations.find(
|
|
||||||
(c) =>
|
|
||||||
c.id === selectedConversation.id &&
|
|
||||||
c.agent_type === selectedConversation.agentType
|
|
||||||
)
|
|
||||||
if (!conv) return
|
|
||||||
const status = conv.status as ConversationStatus
|
|
||||||
if (!groupExpanded[status]) {
|
|
||||||
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = scrollContainerRef.current?.querySelector(
|
|
||||||
`[data-conversation-id="${selectedConversation.id}"]`
|
|
||||||
)
|
|
||||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const el = scrollContainerRef.current?.querySelector(
|
|
||||||
`[data-conversation-id="${selectedConversation.id}"]`
|
|
||||||
)
|
|
||||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
expandAll() {
|
expandAll() {
|
||||||
setGroupExpanded({
|
setGroupExpanded({
|
||||||
@@ -172,16 +256,87 @@ export function SidebarConversationList({
|
|||||||
return map
|
return map
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
|
|
||||||
|
const flatItems = useMemo<FlatItem[]>(() => {
|
||||||
|
const items: FlatItem[] = []
|
||||||
|
for (const status of STATUS_ORDER) {
|
||||||
|
const list = grouped.get(status)
|
||||||
|
if (!list || list.length === 0) continue
|
||||||
|
items.push({ type: "header", status, count: list.length })
|
||||||
|
if (groupExpanded[status]) {
|
||||||
|
for (const conv of list) {
|
||||||
|
items.push({ type: "conversation", conversation: conv })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [grouped, groupExpanded])
|
||||||
|
|
||||||
const reviewConversations = useMemo(
|
const reviewConversations = useMemo(
|
||||||
() => grouped.get("pending_review") ?? [],
|
() => grouped.get("pending_review") ?? [],
|
||||||
[grouped]
|
[grouped]
|
||||||
)
|
)
|
||||||
const reviewConversationCount = reviewConversations.length
|
const reviewConversationCount = reviewConversations.length
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: flatItems.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: (index) =>
|
||||||
|
flatItems[index].type === "header" ? HEADER_HEIGHT : CARD_HEIGHT,
|
||||||
|
getItemKey: (index) => {
|
||||||
|
const item = flatItems[index]
|
||||||
|
return item.type === "header"
|
||||||
|
? `header-${item.status}`
|
||||||
|
: `conv-${item.conversation.id}`
|
||||||
|
},
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
virtualizerRef.current = virtualizer
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToActiveRef.current = () => {
|
||||||
|
if (!selectedConversation) return
|
||||||
|
const targetId = selectedConversation.id
|
||||||
|
const targetAgent = selectedConversation.agentType
|
||||||
|
const conv = conversations.find(
|
||||||
|
(c) => c.id === targetId && c.agent_type === targetAgent
|
||||||
|
)
|
||||||
|
if (!conv) return
|
||||||
|
const status = conv.status as ConversationStatus
|
||||||
|
if (!groupExpanded[status]) {
|
||||||
|
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
|
||||||
|
pendingScrollRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = flatItems.findIndex(
|
||||||
|
(item) =>
|
||||||
|
item.type === "conversation" &&
|
||||||
|
item.conversation.id === targetId &&
|
||||||
|
item.conversation.agent_type === targetAgent
|
||||||
|
)
|
||||||
|
if (index >= 0) {
|
||||||
|
virtualizerRef.current?.scrollToIndex(index, {
|
||||||
|
align: "center",
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingScrollRef.current) {
|
||||||
|
pendingScrollRef.current = false
|
||||||
|
scrollToActiveRef.current()
|
||||||
|
}
|
||||||
|
}, [selectedConversation, flatItems, conversations, groupExpanded])
|
||||||
|
|
||||||
const toggleGroup = useCallback((status: ConversationStatus) => {
|
const toggleGroup = useCallback((status: ConversationStatus) => {
|
||||||
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenCompleteReview = useCallback(
|
||||||
|
() => setCompleteReviewOpen(true),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: number, agentType: string) => {
|
(id: number, agentType: string) => {
|
||||||
openTab(id, agentType as Parameters<typeof openTab>[1], false)
|
openTab(id, agentType as Parameters<typeof openTab>[1], false)
|
||||||
@@ -349,84 +504,71 @@ export function SidebarConversationList({
|
|||||||
"[&::-webkit-scrollbar-thumb]:bg-border"
|
"[&::-webkit-scrollbar-thumb]:bg-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{STATUS_ORDER.map((status) => {
|
<div
|
||||||
const items = grouped.get(status)
|
style={{
|
||||||
if (!items || items.length === 0) return null
|
height: virtualizer.getTotalSize(),
|
||||||
const isOpen = groupExpanded[status]
|
position: "relative",
|
||||||
const groupHeader = (
|
width: "100%",
|
||||||
<CollapsibleTrigger className="sticky top-0 z-10 bg-sidebar flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
}}
|
||||||
<ChevronRight
|
>
|
||||||
className={cn(
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
"h-3.5 w-3.5 shrink-0 transition-transform",
|
const item = flatItems[virtualRow.index]
|
||||||
isOpen && "rotate-90"
|
return (
|
||||||
)}
|
<div
|
||||||
/>
|
key={virtualRow.key}
|
||||||
<span
|
data-index={virtualRow.index}
|
||||||
className={cn(
|
ref={virtualizer.measureElement}
|
||||||
"w-2 h-2 rounded-full shrink-0",
|
style={{
|
||||||
STATUS_COLORS[status]
|
position: "absolute",
|
||||||
)}
|
top: 0,
|
||||||
/>
|
left: 0,
|
||||||
<span>{tStatus(status)}</span>
|
width: "100%",
|
||||||
<span className="ml-auto text-muted-foreground/60 tabular-nums">
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
{items.length}
|
}}
|
||||||
</span>
|
>
|
||||||
</CollapsibleTrigger>
|
{item.type === "header" ? (
|
||||||
)
|
item.status === "pending_review" ? (
|
||||||
return (
|
<PendingReviewHeader
|
||||||
<Collapsible
|
count={item.count}
|
||||||
key={status}
|
isOpen={groupExpanded[item.status]}
|
||||||
open={isOpen}
|
onToggle={toggleGroup}
|
||||||
onOpenChange={() => toggleGroup(status)}
|
reviewConversationCount={reviewConversationCount}
|
||||||
>
|
completingReview={completingReview}
|
||||||
{status === "pending_review" ? (
|
onCompleteReview={handleOpenCompleteReview}
|
||||||
<ContextMenu>
|
tStatus={tStatus}
|
||||||
<ContextMenuTrigger asChild>
|
t={t}
|
||||||
{groupHeader}
|
/>
|
||||||
</ContextMenuTrigger>
|
) : (
|
||||||
<ContextMenuContent>
|
<GroupHeader
|
||||||
<ContextMenuItem
|
status={item.status}
|
||||||
disabled={
|
count={item.count}
|
||||||
reviewConversationCount === 0 || completingReview
|
isOpen={groupExpanded[item.status]}
|
||||||
}
|
onToggle={toggleGroup}
|
||||||
onSelect={() => setCompleteReviewOpen(true)}
|
tStatus={tStatus}
|
||||||
>
|
/>
|
||||||
<CheckCheck className="h-4 w-4" />
|
)
|
||||||
{t("completeAllSessions")}
|
) : (
|
||||||
</ContextMenuItem>
|
<SidebarConversationCard
|
||||||
</ContextMenuContent>
|
conversation={item.conversation}
|
||||||
</ContextMenu>
|
isSelected={
|
||||||
) : (
|
|
||||||
groupHeader
|
|
||||||
)}
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="space-y-0.5 pb-1">
|
|
||||||
{items.map((conversation) => {
|
|
||||||
const isSelected =
|
|
||||||
selectedConversation?.agentType ===
|
selectedConversation?.agentType ===
|
||||||
conversation.agent_type &&
|
item.conversation.agent_type &&
|
||||||
selectedConversation?.id === conversation.id
|
selectedConversation?.id === item.conversation.id
|
||||||
return (
|
}
|
||||||
<SidebarConversationCard
|
onSelect={handleSelect}
|
||||||
key={conversation.id}
|
onDoubleClick={handleDoubleClick}
|
||||||
conversation={conversation}
|
onRename={handleRename}
|
||||||
isSelected={isSelected}
|
onDelete={handleDelete}
|
||||||
onSelect={handleSelect}
|
onStatusChange={handleStatusChange}
|
||||||
onDoubleClick={handleDoubleClick}
|
onNewConversation={handleNewConversation}
|
||||||
onRename={handleRename}
|
onImport={handleImport}
|
||||||
onDelete={handleDelete}
|
importing={importing}
|
||||||
onStatusChange={handleStatusChange}
|
/>
|
||||||
onNewConversation={handleNewConversation}
|
)}
|
||||||
onImport={handleImport}
|
</div>
|
||||||
importing={importing}
|
)
|
||||||
/>
|
})}
|
||||||
)
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user