From 8c60c8fdba7ac726dddeff380b14f155d8f5ee1d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 22 Mar 2026 12:32:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AF=9D=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sidebar-conversation-card.tsx | 24 +- .../sidebar-conversation-list.tsx | 350 ++++++++++++------ 2 files changed, 257 insertions(+), 117 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index e903bc7..ce2b200 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -1,12 +1,12 @@ "use client" -import { memo, useState, useCallback } from "react" +import { memo, useState, useCallback, useMemo } from "react" import { formatDistanceToNow } from "date-fns" import { enUS, zhCN, zhTW } from "date-fns/locale" import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react" import { useLocale, useTranslations } from "next-intl" 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 { AgentIcon } from "@/components/agent-icon" import { @@ -39,12 +39,6 @@ import { import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -const ALL_STATUSES: ConversationStatus[] = [ - "in_progress", - "pending_review", - "completed", - "cancelled", -] interface SidebarConversationCardProps { conversation: DbConversationSummary @@ -80,10 +74,14 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ const [deleteOpen, setDeleteOpen] = useState(false) const [renameValue, setRenameValue] = useState("") - const timeAgo = formatDistanceToNow(new Date(conversation.updated_at), { - addSuffix: true, - locale: dateFnsLocale, - }) + const timeAgo = useMemo( + () => + formatDistanceToNow(new Date(conversation.updated_at), { + addSuffix: true, + locale: dateFnsLocale, + }), + [conversation.updated_at, dateFnsLocale] + ) const handleClick = useCallback(() => { onSelect(conversation.id, conversation.agent_type) @@ -166,7 +164,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ {t("status")} - {ALL_STATUSES.filter((s) => s !== conversation.status).map( + {STATUS_ORDER.filter((s) => s !== conversation.status).map( (s) => ( void + tStatus: ReturnType +}) { + return ( + + ) +}) + +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 + t: ReturnType +}) { + return ( + + + + + + + + {t("completeAllSessions")} + + + + ) +}) + export interface SidebarConversationListHandle { scrollToActive: () => void expandAll: () => void @@ -112,30 +212,14 @@ export function SidebarConversationList({ const scrollContainerRef = useRef(null) + const scrollToActiveRef = useRef<() => void>(() => {}) + const pendingScrollRef = useRef(false) + const virtualizerRef = + useRef>>(null) + useImperativeHandle(ref, () => ({ scrollToActive() { - if (!selectedConversation) return - 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" }) - } + scrollToActiveRef.current() }, expandAll() { setGroupExpanded({ @@ -172,16 +256,87 @@ export function SidebarConversationList({ return map }, [conversations]) + const flatItems = useMemo(() => { + 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( () => grouped.get("pending_review") ?? [], [grouped] ) 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) => { setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] })) }, []) + const handleOpenCompleteReview = useCallback( + () => setCompleteReviewOpen(true), + [] + ) + const handleSelect = useCallback( (id: number, agentType: string) => { openTab(id, agentType as Parameters[1], false) @@ -349,84 +504,71 @@ export function SidebarConversationList({ "[&::-webkit-scrollbar-thumb]:bg-border" )} > - {STATUS_ORDER.map((status) => { - const items = grouped.get(status) - if (!items || items.length === 0) return null - const isOpen = groupExpanded[status] - const groupHeader = ( - - - - {tStatus(status)} - - {items.length} - - - ) - return ( - toggleGroup(status)} - > - {status === "pending_review" ? ( - - - {groupHeader} - - - setCompleteReviewOpen(true)} - > - - {t("completeAllSessions")} - - - - ) : ( - groupHeader - )} - -
- {items.map((conversation) => { - const isSelected = +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const item = flatItems[virtualRow.index] + return ( +
+ {item.type === "header" ? ( + item.status === "pending_review" ? ( + + ) : ( + + ) + ) : ( + - ) - })} -
- - - ) - })} + item.conversation.agent_type && + selectedConversation?.id === item.conversation.id + } + onSelect={handleSelect} + onDoubleClick={handleDoubleClick} + onRename={handleRename} + onDelete={handleDelete} + onStatusChange={handleStatusChange} + onNewConversation={handleNewConversation} + onImport={handleImport} + importing={importing} + /> + )} +
+ ) + })} +