"use client"; import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { ChevronLeft, ChevronRight, Trash2, Unplug } from "lucide-react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; const SHOWCASE_CONNECTORS = [ { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" }, { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" }, { type: "NOTION_CONNECTOR", label: "Notion" }, { type: "YOUTUBE_CONNECTOR", label: "YouTube" }, { type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" }, { type: "SLACK_CONNECTOR", label: "Slack" }, { type: "LINEAR_CONNECTOR", label: "Linear" }, { type: "JIRA_CONNECTOR", label: "Jira" }, { type: "GITHUB_CONNECTOR", label: "GitHub" }, ] as const; interface DocumentsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; isDocked?: boolean; onDockedChange?: (docked: boolean) => void; /** When true, renders content without any wrapper — parent provides the container */ embedded?: boolean; /** Optional action element rendered in the header row (e.g. collapse button) */ headerAction?: React.ReactNode; } export function DocumentsSidebar({ open, onOpenChange, isDocked = false, onDockedChange, embedded = false, headerAction, }: DocumentsSidebarProps) { const t = useTranslations("documents"); const tSidebar = useTranslations("sidebar"); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); const searchSpaceId = Number(params.search_space_id); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: connectors } = useAtomValue(connectorsAtom); const connectorCount = connectors?.length ?? 0; const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]); // Folder state const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom); const expandedIds = useMemo( () => new Set(expandedFolderMap[searchSpaceId] ?? []), [expandedFolderMap, searchSpaceId] ); const toggleFolderExpand = useCallback( (folderId: number) => { setExpandedFolderMap((prev) => { const current = new Set(prev[searchSpaceId] ?? []); if (current.has(folderId)) current.delete(folderId); else current.add(folderId); return { ...prev, [searchSpaceId]: [...current] }; }); }, [searchSpaceId, setExpandedFolderMap] ); // Zero queries for tree data const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId })); const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom); const treeFolders: FolderDisplay[] = useMemo( () => (zeroFolders ?? []).map((f) => ({ id: f.id, name: f.name, position: f.position, parentId: f.parentId ?? null, searchSpaceId: f.searchSpaceId, })), [zeroFolders] ); const treeDocuments: DocumentNodeDoc[] = useMemo(() => { const zeroDocs = (zeroAllDocs ?? []) .filter((d) => d.title && d.title.trim() !== "") .map((d) => ({ id: d.id, title: d.title, document_type: d.documentType, folderId: (d as { folderId?: number | null }).folderId ?? null, status: d.status as { state: string; reason?: string | null } | undefined, })); const zeroIds = new Set(zeroDocs.map((d) => d.id)); const pendingAgentDocs = agentCreatedDocs .filter((d) => d.searchSpaceId === searchSpaceId && !zeroIds.has(d.id)) .map((d) => ({ id: d.id, title: d.title, document_type: d.documentType, folderId: d.folderId ?? null, status: { state: "ready" } as { state: string; reason?: string | null }, })); return [...pendingAgentDocs, ...zeroDocs]; }, [zeroAllDocs, agentCreatedDocs, searchSpaceId]); // Prune agent-created docs once Zero has caught up useEffect(() => { if (!zeroAllDocs?.length || !agentCreatedDocs.length) return; const zeroIds = new Set(zeroAllDocs.map((d) => d.id)); const remaining = agentCreatedDocs.filter((d) => !zeroIds.has(d.id)); if (remaining.length < agentCreatedDocs.length) { setAgentCreatedDocs(remaining); } }, [zeroAllDocs, agentCreatedDocs, setAgentCreatedDocs]); const foldersByParent = useMemo(() => { const map: Record = {}; for (const f of treeFolders) { const key = String(f.parentId ?? "root"); if (!map[key]) map[key] = []; map[key].push(f); } return map; }, [treeFolders]); // Folder actions const [folderPickerOpen, setFolderPickerOpen] = useState(false); const [folderPickerTarget, setFolderPickerTarget] = useState<{ type: "folder" | "document"; id: number; disabledIds?: Set; } | null>(null); // Create-folder dialog state const [createFolderOpen, setCreateFolderOpen] = useState(false); const [createFolderParentId, setCreateFolderParentId] = useState(null); const createFolderParentName = useMemo(() => { if (createFolderParentId === null) return null; return treeFolders.find((f) => f.id === createFolderParentId)?.name ?? null; }, [createFolderParentId, treeFolders]); const handleCreateFolder = useCallback((parentId: number | null) => { setCreateFolderParentId(parentId); setCreateFolderOpen(true); }, []); const handleCreateFolderConfirm = useCallback( async (name: string) => { try { await foldersApiService.createFolder({ name, parent_id: createFolderParentId, search_space_id: searchSpaceId, }); toast.success("Folder created"); if (createFolderParentId !== null) { setExpandedFolderMap((prev) => { const current = new Set(prev[searchSpaceId] ?? []); current.add(createFolderParentId); return { ...prev, [searchSpaceId]: [...current] }; }); } } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to create folder"); } }, [createFolderParentId, searchSpaceId, setExpandedFolderMap] ); const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => { try { await foldersApiService.updateFolder(folder.id, { name: newName }); toast.success("Folder renamed"); } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to rename folder"); } }, []); const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => { if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return; try { await foldersApiService.deleteFolder(folder.id); toast.success("Folder deleted"); } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to delete folder"); } }, []); const handleMoveFolder = useCallback( (folder: FolderDisplay) => { const subtreeIds = new Set(); function collectSubtree(id: number) { subtreeIds.add(id); for (const child of foldersByParent[String(id)] ?? []) { collectSubtree(child.id); } } collectSubtree(folder.id); setFolderPickerTarget({ type: "folder", id: folder.id, disabledIds: subtreeIds, }); setFolderPickerOpen(true); }, [foldersByParent] ); const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => { setFolderPickerTarget({ type: "document", id: doc.id }); setFolderPickerOpen(true); }, []); const handleExportDocument = useCallback( async (doc: DocumentNodeDoc, format: string) => { const safeTitle = doc.title .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "document"; const ext = EXPORT_FILE_EXTENSIONS[format] ?? format; try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`, { method: "GET" } ); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Export failed" })); throw new Error(errorData.detail || "Export failed"); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error(`Export ${format} failed:`, err); toast.error(err instanceof Error ? err.message : `Export failed`); } }, [searchSpaceId] ); const handleFolderPickerSelect = useCallback( async (targetFolderId: number | null) => { if (!folderPickerTarget) return; try { if (folderPickerTarget.type === "folder") { await foldersApiService.moveFolder(folderPickerTarget.id, { new_parent_id: targetFolderId, }); toast.success("Folder moved"); } else { await foldersApiService.moveDocument(folderPickerTarget.id, { folder_id: targetFolderId, }); toast.success("Document moved"); } } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to move item"); } setFolderPickerTarget(null); }, [folderPickerTarget] ); const handleDropIntoFolder = useCallback( async (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => { try { if (itemType === "folder") { await foldersApiService.moveFolder(itemId, { new_parent_id: targetFolderId, }); toast.success("Folder moved"); } else { await foldersApiService.moveDocument(itemId, { folder_id: targetFolderId, }); toast.success("Document moved"); } } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to move item"); } }, [] ); const handleReorderFolder = useCallback( async (folderId: number, beforePos: string | null, afterPos: string | null) => { try { await foldersApiService.reorderFolder(folderId, { before_position: beforePos, after_position: afterPos, }); } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to reorder folder"); } }, [] ); const handleToggleChatMention = useCallback( (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { if (isMentioned) { setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id)); } else { setSidebarDocs((prev) => { if (prev.some((d) => d.id === doc.id)) return prev; return [ ...prev, { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum }, ]; }); } }, [setSidebarDocs] ); const handleToggleFolderSelect = useCallback( (folderId: number, selectAll: boolean) => { function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] { const directDocs = (treeDocuments ?? []).filter( (d) => d.folderId === parentId && d.status?.state !== "pending" && d.status?.state !== "processing" ); const childFolders = foldersByParent[String(parentId)] ?? []; const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); return [...directDocs, ...descendantDocs]; } const subtreeDocs = collectSubtreeDocs(folderId); if (subtreeDocs.length === 0) return; if (selectAll) { setSidebarDocs((prev) => { const existingIds = new Set(prev.map((d) => d.id)); const newDocs = subtreeDocs .filter((d) => !existingIds.has(d.id)) .map((d) => ({ id: d.id, title: d.title, document_type: d.document_type as DocumentTypeEnum, })); return newDocs.length > 0 ? [...prev, ...newDocs] : prev; }); } else { const idsToRemove = new Set(subtreeDocs.map((d) => d.id)); setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id))); } }, [treeDocuments, foldersByParent, setSidebarDocs] ); const searchFilteredDocuments = useMemo(() => { const query = debouncedSearch.trim().toLowerCase(); if (!query) return treeDocuments; return treeDocuments.filter((d) => d.title.toLowerCase().includes(query)); }, [treeDocuments, debouncedSearch]); const typeCounts = useMemo(() => { const counts: Partial> = {}; for (const d of treeDocuments) { counts[d.document_type] = (counts[d.document_type] || 0) + 1; } return counts; }, [treeDocuments]); const deletableSelectedIds = useMemo(() => { const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d])); return sidebarDocs .filter((doc) => { const fullDoc = treeDocMap.get(doc.id); if (!fullDoc) return false; const state = fullDoc.status?.state ?? "ready"; return ( state !== "pending" && state !== "processing" && !NON_DELETABLE_DOCUMENT_TYPES.includes(doc.document_type) ); }) .map((doc) => doc.id); }, [sidebarDocs, treeDocuments]); const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); const handleBulkDeleteSelected = useCallback(async () => { if (deletableSelectedIds.length === 0) return; setIsBulkDeleting(true); try { const results = await Promise.allSettled( deletableSelectedIds.map(async (id) => { await deleteDocumentMutation({ id }); return id; }) ); const successIds = results .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") .map((r) => r.value); const failed = results.length - successIds.length; if (successIds.length > 0) { setSidebarDocs((prev) => { const idSet = new Set(successIds); return prev.filter((d) => !idSet.has(d.id)); }); toast.success(`Deleted ${successIds.length} document${successIds.length !== 1 ? "s" : ""}`); } if (failed > 0) { toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`); } } catch { toast.error("Failed to delete documents"); } setIsBulkDeleting(false); setBulkDeleteConfirmOpen(false); }, [deletableSelectedIds, deleteDocumentMutation, setSidebarDocs]); const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => { setActiveTypes((prev) => { if (checked) { return prev.includes(type) ? prev : [...prev, type]; } return prev.filter((t) => t !== type); }); }, []); const handleDeleteDocument = useCallback( async (id: number): Promise => { try { await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); return true; } catch (e) { console.error("Error deleting document:", e); return false; } }, [deleteDocumentMutation, t, setSidebarDocs] ); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { if (isMobile) { onOpenChange(false); } else { setRightPanelCollapsed(true); } } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange, isMobile, setRightPanelCollapsed]); const documentsContent = ( <>
{isMobile && ( )}

{t("title") || "Documents"}

{!isMobile && onDockedChange && ( {isDocked ? "Collapse panel" : "Expand panel"} )} {headerAction}
{/* Connected tools strip */}
handleCreateFolder(null)} />
{deletableSelectedIds.length > 0 && (
)} { openEditorPanel({ documentId: doc.id, searchSpaceId, title: doc.title, }); }} onEditDocument={(doc) => { openEditorPanel({ documentId: doc.id, searchSpaceId, title: doc.title, }); }} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onMoveDocument={handleMoveDocument} onExportDocument={handleExportDocument} activeTypes={activeTypes} onDropIntoFolder={handleDropIntoFolder} onReorderFolder={handleReorderFolder} />
!open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} > Delete {deletableSelectedIds.length} document {deletableSelectedIds.length !== 1 ? "s" : ""}? This action cannot be undone.{" "} {deletableSelectedIds.length === 1 ? "This document" : `These ${deletableSelectedIds.length} documents`}{" "} will be permanently deleted from your search space. Cancel { e.preventDefault(); handleBulkDeleteSelected(); }} disabled={isBulkDeleting} className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete {isBulkDeleting && } ); if (embedded) { return (
{documentsContent}
); } if (isDocked && open && !isMobile) { return ( ); } return ( {documentsContent} ); }