"use client" import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref, } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useVirtualizer } from "@tanstack/react-virtual" import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useTaskContext } from "@/contexts/task-context" import { importLocalConversations, updateConversationTitle, updateConversationStatus, deleteConversation, } from "@/lib/tauri" import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types" import { SidebarConversationCard } from "./sidebar-conversation-card" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, } from "@/components/ui/context-menu" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { cn } from "@/lib/utils" function parseTimestamp(value: string): number { const timestamp = Date.parse(value) return Number.isNaN(timestamp) ? 0 : timestamp } function compareByUpdatedAtDesc( left: DbConversationSummary, right: DbConversationSummary ): number { const updatedDiff = parseTimestamp(right.updated_at) - parseTimestamp(left.updated_at) if (updatedDiff !== 0) return updatedDiff const createdDiff = parseTimestamp(right.created_at) - parseTimestamp(left.created_at) if (createdDiff !== 0) return createdDiff 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 }) { 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 collapseAll: () => void } export function SidebarConversationList({ ref, }: { ref?: Ref }) { const t = useTranslations("Folder.sidebar") const tStatus = useTranslations("Folder.statusLabels") const tCommon = useTranslations("Folder.common") const { folder, conversations, loading, refreshing, error, selectedConversation, folderId, refreshConversations, } = useFolderContext() const { openTab, closeConversationTab, openNewConversationTab } = useTabContext() const { addTask, updateTask } = useTaskContext() const [importing, setImporting] = useState(false) const [completeReviewOpen, setCompleteReviewOpen] = useState(false) const [completingReview, setCompletingReview] = useState(false) const [groupExpanded, setGroupExpanded] = useState< Record >({ in_progress: true, pending_review: true, completed: false, cancelled: false, }) const scrollContainerRef = useRef(null) const scrollToActiveRef = useRef<() => void>(() => {}) const pendingScrollRef = useRef(false) const virtualizerRef = useRef>>(null) useImperativeHandle(ref, () => ({ scrollToActive() { scrollToActiveRef.current() }, expandAll() { setGroupExpanded({ in_progress: true, pending_review: true, completed: true, cancelled: true, }) }, collapseAll() { setGroupExpanded({ in_progress: false, pending_review: false, completed: false, cancelled: false, }) }, })) const grouped = useMemo(() => { const map = new Map() for (const conv of conversations) { const status = conv.status as ConversationStatus const list = map.get(status) if (list) { list.push(conv) } else { map.set(status, [conv]) } } for (const list of map.values()) { list.sort(compareByUpdatedAtDesc) } 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) }, [openTab] ) const handleDoubleClick = useCallback( (id: number, agentType: string) => { openTab(id, agentType as Parameters[1], true) }, [openTab] ) const handleRename = useCallback( async (id: number, newTitle: string) => { await updateConversationTitle(id, newTitle) refreshConversations() }, [refreshConversations] ) const handleDelete = useCallback( async (id: number, agentType: string) => { await deleteConversation(id) closeConversationTab(id, agentType as Parameters[1]) refreshConversations() }, [closeConversationTab, refreshConversations] ) const handleStatusChange = useCallback( async (id: number, status: ConversationStatus) => { await updateConversationStatus(id, status) refreshConversations() }, [refreshConversations] ) const handleNewConversation = useCallback(() => { if (!folder) return openNewConversationTab("codex", folder.path) }, [folder, openNewConversationTab]) const handleImport = useCallback(async () => { if (importing) return setImporting(true) const taskId = `import-${folderId}-${Date.now()}` addTask(taskId, t("importLocalSessions")) updateTask(taskId, { status: "running" }) try { const result = await importLocalConversations(folderId) updateTask(taskId, { status: "completed" }) refreshConversations() if (result.imported > 0) { toast.success( t("toasts.importedSessions", { imported: result.imported, skipped: result.skipped, }) ) } else { toast.info(t("toasts.noNewSessionsFound", { skipped: result.skipped })) } } catch (e) { const msg = e instanceof Error ? e.message : String(e) updateTask(taskId, { status: "failed", error: msg }) toast.error(t("toasts.importFailed", { message: msg })) } finally { setImporting(false) } }, [importing, folderId, addTask, updateTask, refreshConversations, t]) const handleCompleteAllReview = useCallback(async () => { if (completingReview || reviewConversationCount === 0) return setCompletingReview(true) try { await Promise.all( reviewConversations.map((conversation) => updateConversationStatus(conversation.id, "completed") ) ) refreshConversations() toast.success( t("toasts.reviewCompleted", { count: reviewConversationCount }) ) setCompleteReviewOpen(false) } catch (e) { const msg = e instanceof Error ? e.message : String(e) toast.error(t("toasts.completeReviewFailed", { message: msg })) } finally { setCompletingReview(false) } }, [ completingReview, reviewConversationCount, reviewConversations, refreshConversations, t, ]) return (
{(loading || refreshing) && (
)} {loading && !refreshing ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : error ? (

{t("error", { message: error })}

) : conversations.length === 0 ? (

{t("noConversationsFound")}

{t("newConversation")} {importing ? t("importing") : t("importLocalSessions")}
) : (
{virtualizer.getVirtualItems().map((virtualRow) => { const item = flatItems[virtualRow.index] return (
{item.type === "header" ? ( item.status === "pending_review" ? ( ) : ( ) ) : ( )}
) })}
{t("newConversation")} {importing ? t("importing") : t("importLocalSessions")}
)} !completingReview && setCompleteReviewOpen(open) } > {t("completeAllReviewTitle")} {t("completeAllReviewDescription", { count: reviewConversationCount, })} {tCommon("cancel")} {completingReview ? t("completing") : tCommon("confirm")}
) }