"use client" import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref, } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { Reorder } from "motion/react" import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react" import { ChevronDown, ChevronRight, Download, FolderOpen, GitBranch, ListChecks, Loader2, Palette, Plus, Rocket, XCircle, } from "lucide-react" import { useActiveFolder } from "@/contexts/active-folder-context" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useTaskContext } from "@/contexts/task-context" import { useZoomLevel } from "@/hooks/use-appearance" import { importLocalConversations, openProjectBootWindow, updateConversationTitle, updateConversationStatus, updateFolderColor, deleteConversation, } from "@/lib/api" import { isDesktop, openFileDialog } from "@/lib/platform" import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import { loadFolderExpanded, saveFolderExpanded, type SidebarSortMode, } from "@/lib/sidebar-view-mode-storage" import { SidebarConversationCard } from "./sidebar-conversation-card" import { ConversationManageDialog } from "./conversation-manage-dialog" import { CloneDialog } from "@/components/layout/clone-dialog" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { ScrollArea } from "@/components/ui/scroll-area" import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, } 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 } function compareByCreatedAtDesc( left: DbConversationSummary, right: DbConversationSummary ): number { const createdDiff = parseTimestamp(right.created_at) - parseTimestamp(left.created_at) if (createdDiff !== 0) return createdDiff const updatedDiff = parseTimestamp(right.updated_at) - parseTimestamp(left.updated_at) if (updatedDiff !== 0) return updatedDiff return right.id - left.id } // Sentinel stored in the DB that resolves to the current sidebar foreground // color — the swatch then always reads as the folder name does, across themes. const FOREGROUND_SWATCH = "foreground" // Kept in sync with Rust-side `FOLDER_COLOR_PALETTE` in // `src-tauri/src/db/service/folder_service.rs`. Nine well-separated hues // spanning the color wheel (skipping the blue band that reads as muddy), // plus a theme-aware neutral that tracks the sidebar text color. const FOLDER_SWATCH_PALETTE = [ "#ef4444", // red "#f97316", // orange "#eab308", // yellow "#84cc16", // lime "#22c55e", // green "#06b6d4", // cyan "#8b5cf6", // violet "#d946ef", // fuchsia "#ec4899", // pink FOREGROUND_SWATCH, ] as const function resolveSwatchColor(swatch: string): string { return swatch === FOREGROUND_SWATCH ? "var(--sidebar-foreground)" : swatch } function formatRelative(iso: string): string { const ts = parseTimestamp(iso) if (!ts) return "" const diff = Math.max(0, Date.now() - ts) const m = Math.floor(diff / 60000) if (m < 1) return "now" if (m < 60) return `${m}m` const h = Math.floor(m / 60) if (h < 24) return `${h}h` const d = Math.floor(h / 24) if (d < 30) return `${d}d` const mo = Math.floor(d / 30) if (mo < 12) return `${mo}mo` const y = Math.floor(mo / 12) return `${y}y` } const FolderHeader = memo(function FolderHeader({ folderId, folderName, count, expanded, importing, color, onToggle, onRemoveFromWorkspace, onNewConversation, onImport, onManageConversations, onChangeColor, isDragging, t, }: { folderId: number folderName: string count: number expanded: boolean importing: boolean color: string onToggle: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void onNewConversation: (folderId: number) => void onImport: (folderId: number) => void onManageConversations: (folderId: number) => void onChangeColor: (folderId: number, color: string) => void isDragging?: boolean t: ReturnType }) { return (
onNewConversation(folderId)}> {t("newConversation")} onImport(folderId)} > {importing ? t("importing") : t("importLocalSessions")} onManageConversations(folderId)}> {t("folderHeaderMenu.manageConversations")} {t("folderHeaderMenu.changeColor")}
{FOLDER_SWATCH_PALETTE.map((swatch) => { const active = swatch.toLowerCase() === color.toLowerCase() return (
onRemoveFromWorkspace(folderId)} > {t("folderHeaderMenu.removeFromWorkspace")}
) }) interface FolderGroupItemProps { folderId: number folderName: string conversations: DbConversationSummary[] totalConversationCount: number expanded: boolean importing: boolean reordering: boolean dragging: boolean sortMode: SidebarSortMode selectedConversation: { id: number; agentType: string } | null openTabConversationKeys: Set color: string onToggle: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void onNewConversationForFolder: (folderId: number) => void onImport: (folderId: number) => void onManageConversations: (folderId: number) => void onChangeColor: (folderId: number, color: string) => void onSelect: (id: number, agentType: string) => void onDoubleClick: (id: number, agentType: string) => void onRename: (id: number, newTitle: string) => Promise onDelete: (id: number, agentType: string) => Promise onStatusChange: (id: number, status: ConversationStatus) => Promise onNewConversation: () => void onDragStart: (folderId: number) => void onDragEnd: () => void stackIndex: number t: ReturnType } const DRAGGING_Z_INDEX = 10_000 function FolderGroupItem({ folderId, folderName, conversations, totalConversationCount, expanded, importing, reordering, dragging, sortMode, selectedConversation, openTabConversationKeys, color, onToggle, onRemoveFromWorkspace, onNewConversationForFolder, onImport, onManageConversations, onChangeColor, onSelect, onDoubleClick, onRename, onDelete, onStatusChange, onNewConversation, onDragStart, onDragEnd, stackIndex, t, }: FolderGroupItemProps) { const justDraggedRef = useRef(false) const handleToggle = useCallback( (id: number) => { if (justDraggedRef.current) { justDraggedRef.current = false return } onToggle(id) }, [onToggle] ) const handleDragStart = useCallback(() => { justDraggedRef.current = true onDragStart(folderId) }, [folderId, onDragStart]) // Wrap Reorder.Item in a plain div that owns the zIndex. Framer's Reorder.Item // internally overrides `style.zIndex` (forces 1 while dragging, "unset" at rest), // so any zIndex set directly on the Item is discarded. `isolation: isolate` // forces a real stacking context on each wrapper so earlier folders' sticky // headers always paint above later folders' conversation rows when scrolled. return (
{expanded && (conversations.length === 0 ? (
{totalConversationCount === 0 ? t("emptyFolderHint") : t("noMatchingConversations")}
) : ( conversations.map((conv) => ( )) ))}
) } export interface SidebarConversationListHandle { scrollToActive: () => void expandAll: () => void collapseAll: () => void } export interface SidebarConversationListProps { showCompleted?: boolean sortMode?: SidebarSortMode } export function SidebarConversationList({ ref, showCompleted = true, sortMode = "created", }: SidebarConversationListProps & { ref?: Ref }) { const t = useTranslations("Folder.sidebar") const tCommon = useTranslations("Folder.common") const tFolderDropdown = useTranslations("Folder.folderNameDropdown") useZoomLevel() const { folders, allFolders, conversations, conversationsLoading: loading, conversationsError: error, refreshConversations, updateConversationLocal, removeFolderFromWorkspace, reorderFolders, openFolder, refreshFolder, } = useAppWorkspace() const refreshing = loading const { activeFolder } = useActiveFolder() const { openTab, closeConversationTab, closeTabsByFolder, openNewConversationTab, activeTabId, tabs, } = useTabContext() const { addTask, updateTask } = useTaskContext() const folderIndex = useMemo(() => { const map = new Map() for (const f of allFolders) map.set(f.id, { name: f.name, path: f.path, color: f.color }) return map }, [allFolders]) const selectedConversation = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) if (!activeTab || activeTab.conversationId == null) return null return { id: activeTab.conversationId, agentType: activeTab.agentType, } }, [tabs, activeTabId]) const openTabConversationKeys = useMemo(() => { const set = new Set() for (const tab of tabs) { if (tab.conversationId != null) { set.add(`${tab.agentType}:${tab.conversationId}`) } } return set }, [tabs]) const [importing, setImporting] = useState(false) const [folderExpanded, setFolderExpanded] = useState>( {} ) const [removeConfirm, setRemoveConfirm] = useState<{ folderId: number folderName: string } | null>(null) const [manageState, setManageState] = useState<{ folderId: number folderName: string } | null>(null) const [cloneOpen, setCloneOpen] = useState(false) const [browserOpen, setBrowserOpen] = useState(false) const [dragging, setDragging] = useState(null) const [reordering, setReordering] = useState(false) const [dragOrder, setDragOrder] = useState(null) const pendingOrderRef = useRef(null) useEffect(() => { // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. setFolderExpanded(loadFolderExpanded()) }, []) const handleChangeFolderColor = useCallback( async (folderId: number, color: string) => { try { await updateFolderColor(folderId, color) await refreshFolder(folderId) } catch (err) { const msg = err instanceof Error ? err.message : String(err) toast.error(t("toasts.changeFolderColorFailed", { message: msg })) } }, [refreshFolder, t] ) const scrollRootRef = useRef(null) const scrollToActiveRef = useRef<() => void>(() => {}) const pendingScrollRef = useRef(false) const filteredConversations = useMemo(() => { if (showCompleted) return conversations return conversations.filter( (c) => c.status !== "completed" && c.status !== "cancelled" ) }, [conversations, showCompleted]) const byFolder = useMemo(() => { const map = new Map() for (const conv of filteredConversations) { const list = map.get(conv.folder_id) if (list) list.push(conv) else map.set(conv.folder_id, [conv]) } const comparator = sortMode === "updated" ? compareByUpdatedAtDesc : compareByCreatedAtDesc for (const list of map.values()) list.sort(comparator) return map }, [filteredConversations, sortMode]) const folderTotalCounts = useMemo(() => { const map = new Map() for (const conv of conversations) { map.set(conv.folder_id, (map.get(conv.folder_id) ?? 0) + 1) } return map }, [conversations]) const orderedFolderIds = useMemo(() => { const folderIdSet = new Set(folders.map((f) => f.id)) // During drag we honour the optimistic order so sibling folders shift live // as the user hovers over slots. We still filter/append against the source // of truth so newly-added or -removed folders don't disappear mid-drag. if (dragOrder) { const seen = new Set() const ids: number[] = [] for (const id of dragOrder) { if (folderIdSet.has(id) && !seen.has(id)) { seen.add(id) ids.push(id) } } for (const f of folders) { if (!seen.has(f.id)) { seen.add(f.id) ids.push(f.id) } } return ids } const seen = new Set() const ids: number[] = [] for (const f of folders) { if (!seen.has(f.id)) { seen.add(f.id) ids.push(f.id) } } return ids }, [folders, dragOrder]) useImperativeHandle(ref, () => ({ scrollToActive() { scrollToActiveRef.current() }, expandAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } for (const id of orderedFolderIds) next[id] = true saveFolderExpanded(next) return next }) }, collapseAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } for (const id of orderedFolderIds) next[id] = false saveFolderExpanded(next) return next }) }, })) 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 if (!(folderExpanded[conv.folder_id] ?? true)) { setFolderExpanded((prev) => { const next = { ...prev, [conv.folder_id]: true } saveFolderExpanded(next) return next }) pendingScrollRef.current = true return } const root = scrollRootRef.current?.getElement() if (!root) return const selector = `[data-conv-key="${targetAgent}:${targetId}"]` const el = root.querySelector(selector) if (el instanceof HTMLElement) { el.scrollIntoView({ block: "center", behavior: "smooth" }) } } if (pendingScrollRef.current) { pendingScrollRef.current = false scrollToActiveRef.current() } }, [selectedConversation, conversations, folderExpanded]) const toggleFolder = useCallback((folderId: number) => { setFolderExpanded((prev) => { const next = { ...prev, [folderId]: !(prev[folderId] ?? true) } saveFolderExpanded(next) return next }) }, []) const handleRemoveFolder = useCallback( (folderId: number) => { const name = folderIndex.get(folderId)?.name ?? String(folderId) setRemoveConfirm({ folderId, folderName: name }) }, [folderIndex] ) const handleManageConversations = useCallback( (folderId: number) => { const name = folderIndex.get(folderId)?.name ?? String(folderId) setManageState({ folderId, folderName: name }) }, [folderIndex] ) const handleRemoveFolderConfirm = useCallback(async () => { if (!removeConfirm) return const { folderId, folderName } = removeConfirm try { closeTabsByFolder(folderId) await removeFolderFromWorkspace(folderId) toast.success(t("toasts.folderRemoved", { name: folderName })) } catch (e) { const msg = e instanceof Error ? e.message : String(e) toast.error(t("toasts.removeFolderFailed", { message: msg })) } finally { setRemoveConfirm(null) } }, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t]) const handleSelect = useCallback( (id: number, agentType: string) => { const conv = conversations.find( (c) => c.id === id && c.agent_type === agentType ) if (!conv) return openTab( conv.folder_id, id, agentType as Parameters[2], false ) }, [openTab, conversations] ) const handleDoubleClick = useCallback( (id: number, agentType: string) => { const conv = conversations.find( (c) => c.id === id && c.agent_type === agentType ) if (!conv) return openTab( conv.folder_id, id, agentType as Parameters[2], true ) }, [openTab, conversations] ) const handleRename = useCallback( async (id: number, newTitle: string) => { await updateConversationTitle(id, newTitle) refreshConversations() }, [refreshConversations] ) const handleDelete = useCallback( async (id: number, agentType: string) => { const conv = conversations.find( (c) => c.id === id && c.agent_type === agentType ) await deleteConversation(id) if (conv) { closeConversationTab( conv.folder_id, id, agentType as Parameters[2] ) } refreshConversations() }, [closeConversationTab, refreshConversations, conversations] ) const handleStatusChange = useCallback( async (id: number, status: ConversationStatus) => { updateConversationLocal(id, { status }) await updateConversationStatus(id, status) }, [updateConversationLocal] ) const handleNewConversation = useCallback(() => { if (!activeFolder) return openNewConversationTab(activeFolder.id, activeFolder.path) }, [activeFolder, openNewConversationTab]) const handleNewConversationForFolder = useCallback( (folderId: number) => { const folder = folderIndex.get(folderId) if (!folder) return openNewConversationTab(folderId, folder.path) }, [folderIndex, openNewConversationTab] ) const handleImportForFolder = useCallback( async (folderId: number) => { 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, addTask, updateTask, refreshConversations, t] ) const persistReorder = useCallback( async (order: number[]) => { if (order.length === 0) return setReordering(true) try { await reorderFolders(order) } catch (e) { const msg = e instanceof Error ? e.message : String(e) toast.error(t("toasts.reorderFoldersFailed", { message: msg })) } finally { setReordering(false) } }, [reorderFolders, t] ) const handleReorder = useCallback((nextIds: number[]) => { pendingOrderRef.current = nextIds setDragOrder(nextIds) }, []) const handleDragStart = useCallback((folderId: number) => { setDragging(folderId) }, []) const handleDragEnd = useCallback(async () => { setDragging(null) const order = pendingOrderRef.current pendingOrderRef.current = null if (!order) { setDragOrder(null) return } try { await persistReorder(order) } finally { // Clear the optimistic override once the workspace context's folders // have absorbed the new order (or on failure, the rollback in the // context restores the original order). setDragOrder(null) } }, [persistReorder]) const handleOpenFolderAction = useCallback(async () => { if (isDesktop()) { try { const result = await openFileDialog({ directory: true, multiple: false, }) if (!result) return const selected = Array.isArray(result) ? result[0] : result await openFolder(selected) } catch (err) { console.error("[SidebarConversationList] failed to open folder:", err) } } else { setBrowserOpen(true) } }, [openFolder]) const handleBrowserSelect = useCallback( (path: string) => { openFolder(path).catch((err) => { console.error("[SidebarConversationList] failed to open folder:", err) }) }, [openFolder] ) const handleProjectBoot = useCallback(() => { openProjectBootWindow().catch((err) => { console.error( "[SidebarConversationList] failed to open project boot:", err ) }) }, []) const showEmptyWorkspaceActions = folders.length === 0 && conversations.length === 0 return (
{(loading || refreshing) && (
)} {loading && !refreshing ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : error ? (

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

) : showEmptyWorkspaceActions ? (
) : (
{orderedFolderIds.map((folderId, index) => { const folderName = folderIndex.get(folderId)?.name ?? String(folderId) const convs = byFolder.get(folderId) ?? [] const expanded = folderExpanded[folderId] ?? true const convsWithKey = convs.map((conv) => ({ ...conv, })) // Earlier folders get a higher stacking index so their // sticky headers paint above later folders' conversation // cards when scrolled. Framer's `layout` prop sets // `will-change: transform`, which would otherwise trap // each sticky inside its own Reorder.Item. const stackIndex = orderedFolderIds.length - index return ( ) })}
{t("newConversation")} {tFolderDropdown("openFolder")} setCloneOpen(true)}> {tFolderDropdown("cloneRepository")} {tFolderDropdown("projectBoot")}
)} !open && setRemoveConfirm(null)} > {t("removeFolderConfirmTitle")} {t("removeFolderConfirmDescription", { name: removeConfirm?.folderName ?? "", })} {tCommon("cancel")} {tCommon("confirm")} {manageState && ( !o && setManageState(null)} folderId={manageState.folderId} folderName={manageState.folderName} /> )}
) }