"use client"; import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { ChevronLeft, ChevronRight, FolderClock, 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 { 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 { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import { DocumentsFilters } from "@/components/documents/DocumentsFilters"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { VersionHistoryDialog } from "@/components/documents/version-history"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { DEFAULT_EXCLUDE_PATTERNS, FolderWatchDialog, type SelectedFolder, } from "@/components/sources/FolderWatchDialog"; 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 { useElectronAPI } from "@/hooks/use-platform"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; 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 electronAPI = useElectronAPI(); 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 [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); const [folderWatchOpen, setFolderWatchOpen] = useState(false); const [watchInitialFolder, setWatchInitialFolder] = useState(null); const isElectron = typeof window !== "undefined" && !!window.electronAPI; // AI File Sort state const { data: searchSpaces, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); const activeSearchSpace = useMemo( () => searchSpaces?.find((s) => s.id === searchSpaceId), [searchSpaces, searchSpaceId] ); const aiSortEnabled = activeSearchSpace?.ai_file_sort_enabled ?? false; const [aiSortBusy, setAiSortBusy] = useState(false); const [aiSortConfirmOpen, setAiSortConfirmOpen] = useState(false); const handleToggleAiSort = useCallback(() => { if (aiSortEnabled) { // Disable: just update the setting, no confirmation needed setAiSortBusy(true); searchSpacesApiService .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: false } }) .then(() => { refetchSearchSpaces(); toast.success("AI file sorting disabled"); }) .catch(() => toast.error("Failed to disable AI file sorting")) .finally(() => setAiSortBusy(false)); } else { setAiSortConfirmOpen(true); } }, [aiSortEnabled, searchSpaceId, refetchSearchSpaces]); const handleConfirmEnableAiSort = useCallback(() => { setAiSortConfirmOpen(false); setAiSortBusy(true); searchSpacesApiService .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: true } }) .then(() => searchSpacesApiService.triggerAiSort(searchSpaceId)) .then(() => { refetchSearchSpaces(); toast.success("AI file sorting enabled — organizing your documents in the background"); }) .catch(() => toast.error("Failed to enable AI file sorting")) .finally(() => setAiSortBusy(false)); }, [searchSpaceId, refetchSearchSpaces]); const handleWatchLocalFolder = useCallback(async () => { const api = window.electronAPI; if (!api?.selectFolder) return; const folderPath = await api.selectFolder(); if (!folderPath) return; const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; setWatchInitialFolder({ path: folderPath, name: folderName }); setFolderWatchOpen(true); }, []); const refreshWatchedIds = useCallback(async () => { if (!electronAPI?.getWatchedFolders) return; const api = electronAPI; const folders = await api.getWatchedFolders(); if (folders.length === 0) { try { const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId); for (const bf of backendFolders) { const meta = bf.metadata as Record | null; if (!meta?.watched || !meta.folder_path) continue; await api.addWatchedFolder({ path: meta.folder_path as string, name: bf.name, rootFolderId: bf.id, searchSpaceId: bf.search_space_id, excludePatterns: (meta.exclude_patterns as string[]) ?? [], fileExtensions: (meta.file_extensions as string[] | null) ?? null, active: true, }); } const recovered = await api.getWatchedFolders(); const ids = new Set( recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number) ); setWatchedFolderIds(ids); return; } catch (err) { console.error("[DocumentsSidebar] Recovery from backend failed:", err); } } const ids = new Set( folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number) ); setWatchedFolderIds(ids); }, [searchSpaceId, electronAPI]); useEffect(() => { refreshWatchedIds(); }, [refreshWatchedIds]); 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, metadata: f.metadata as Record | null | undefined, })), [zeroFolders] ); const treeDocuments: DocumentNodeDoc[] = useMemo(() => { const zeroDocs = (zeroAllDocs ?? []) .filter((d) => { if (!d.title || d.title.trim() === "") return false; const state = (d.status as { state?: string } | undefined)?.state; if (state === "deleting") return false; return true; }) .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 handleRescanFolder = useCallback( async (folder: FolderDisplay) => { if (!electronAPI) return; const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (!matched) { toast.error("This folder is not being watched"); return; } try { toast.info(`Re-scanning folder: ${matched.name}`); await uploadFolderScan({ folderPath: matched.path, folderName: matched.name, searchSpaceId, excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()), enableSummary: false, rootFolderId: folder.id, }); toast.success(`Re-scan complete: ${matched.name}`); } catch (err) { toast.error((err as Error)?.message || "Failed to re-scan folder"); } }, [searchSpaceId, electronAPI] ); const handleStopWatching = useCallback( async (folder: FolderDisplay) => { if (!electronAPI) return; const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (!matched) { toast.error("This folder is not being watched"); return; } await electronAPI.removeWatchedFolder(matched.path); try { await foldersApiService.stopWatching(folder.id); } catch (err) { console.error("[DocumentsSidebar] Failed to clear watched metadata:", err); } toast.success(`Stopped watching: ${matched.name}`); refreshWatchedIds(); }, [electronAPI, refreshWatchedIds] ); 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 { if (electronAPI) { const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (matched) { await electronAPI.removeWatchedFolder(matched.path); } } await foldersApiService.deleteFolder(folder.id); toast.success("Folder deleted"); } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to delete folder"); } }, [electronAPI] ); 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 [, setIsExportingKB] = useState(false); const [exportWarningOpen, setExportWarningOpen] = useState(false); const [exportWarningContext, setExportWarningContext] = useState<{ folder: FolderDisplay; pendingCount: number; } | null>(null); const doExport = useCallback(async (url: string, downloadName: string) => { const response = await authenticatedFetch(url, { 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 blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = blobUrl; a.download = downloadName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); }, []); const handleExportWarningConfirm = useCallback(async () => { setExportWarningOpen(false); const ctx = exportWarningContext; if (!ctx?.folder) return; setIsExportingKB(true); try { const safeName = ctx.folder.name .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "folder"; await doExport( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`, `${safeName}.zip` ); toast.success(`Folder "${ctx.folder.name}" exported`); } catch (err) { console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { setIsExportingKB(false); } setExportWarningContext(null); }, [exportWarningContext, searchSpaceId, doExport]); const getPendingCountInSubtree = useCallback( (folderId: number): number => { const subtreeIds = new Set(); function collect(id: number) { subtreeIds.add(id); for (const child of foldersByParent[String(id)] ?? []) { collect(child.id); } } collect(folderId); return treeDocuments.filter( (d) => subtreeIds.has(d.folderId ?? -1) && (d.status?.state === "pending" || d.status?.state === "processing") ).length; }, [foldersByParent, treeDocuments] ); const handleExportFolder = useCallback( async (folder: FolderDisplay) => { const folderPendingCount = getPendingCountInSubtree(folder.id); if (folderPendingCount > 0) { setExportWarningContext({ folder, pendingCount: folderPendingCount, }); setExportWarningOpen(true); return; } setIsExportingKB(true); try { const safeName = folder.name .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "folder"; await doExport( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`, `${safeName}.zip` ); toast.success(`Folder "${folder.name}" exported`); } catch (err) { console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { setIsExportingKB(false); } }, [searchSpaceId, getPendingCountInSubtree, doExport] ); 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" && d.status?.state !== "failed" ); 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) { const displayType = d.document_type === "LOCAL_FOLDER_FILE" ? "FILE" : d.document_type; counts[displayType] = (counts[displayType] || 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 [versionDocId, setVersionDocId] = useState(null); 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 */}
{isElectron && ( )}
handleCreateFolder(null)} aiSortEnabled={aiSortEnabled} aiSortBusy={aiSortBusy} onToggleAiSort={handleToggleAiSort} />
{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} onVersionHistory={(doc) => setVersionDocId(doc.id)} activeTypes={activeTypes} onDropIntoFolder={handleDropIntoFolder} onReorderFolder={handleReorderFolder} watchedFolderIds={watchedFolderIds} onRescanFolder={handleRescanFolder} onStopWatchingFolder={handleStopWatching} onExportFolder={handleExportFolder} />
{versionDocId !== null && ( { if (!open) setVersionDocId(null); }} documentId={versionDocId} /> )} {isElectron && ( { setFolderWatchOpen(nextOpen); if (!nextOpen) setWatchInitialFolder(null); }} searchSpaceId={searchSpaceId} initialFolder={watchInitialFolder} onSuccess={refreshWatchedIds} /> )} !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 (!open) { setExportWarningOpen(false); setExportWarningContext(null); } }} > Some documents are still processing {exportWarningContext?.pendingCount} document {exportWarningContext?.pendingCount !== 1 ? "s are" : " is"} currently being processed and will be excluded from the export. Do you want to continue? Cancel Export anyway Enable AI File Sorting? All documents in this search space will be organized into folders by connector type, date, and AI-generated categories. New documents will also be sorted automatically. You can disable this at any time. Cancel Enable ); if (embedded) { return (
{documentsContent}
); } if (isDocked && open && !isMobile) { return ( ); } return ( {documentsContent} ); }