"use client" import { useCallback, useImperativeHandle, useMemo, useRef, useState, type Ref, } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" 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 { Collapsible, CollapsibleTrigger, CollapsibleContent, } from "@/components/ui/collapsible" 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 } 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) 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" }) } }, 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 reviewConversations = useMemo( () => grouped.get("pending_review") ?? [], [grouped] ) const reviewConversationCount = reviewConversations.length const toggleGroup = useCallback((status: ConversationStatus) => { setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] })) }, []) 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")}
) : (
{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 = selectedConversation?.agentType === conversation.agent_type && selectedConversation?.id === conversation.id return ( ) })}
) })}
{t("newConversation")} {importing ? t("importing") : t("importLocalSessions")}
)} !completingReview && setCompleteReviewOpen(open) } > {t("completeAllReviewTitle")} {t("completeAllReviewDescription", { count: reviewConversationCount, })} {tCommon("cancel")} {completingReview ? t("completing") : tCommon("confirm")}
) }