"use client"; import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { ChevronLeft, ChevronRight, FileText, FolderClock, FolderPlus, Laptop, ListFilter, Lock, MoreHorizontal, Paperclip, Server, Trash2, Upload, X, } from "lucide-react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom"; import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; 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 { folderWatchDialogOpenAtom, folderWatchInitialFolderAtom, } from "@/atoms/folder-sync/folder-sync.atoms"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import { DocumentsFilters } from "@/components/documents/DocumentsFilters"; import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon"; 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 { useRuntimeConfig } from "@/components/providers/runtime-config"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { DEFAULT_EXCLUDE_PATTERNS, FolderWatchDialog, } from "@/components/sources/FolderWatchDialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode"; import { useLoginGate } from "@/contexts/login-gate"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-fetch"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { buildBackendUrl } from "@/lib/env-config"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { getWorkspaceIdNumber } from "@/lib/route-params"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; const DesktopLocalTabContent = dynamic( () => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent), { ssr: false } ); const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"]; const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [ { id: -1001, title: "MEMORY.md", document_type: "USER_MEMORY", folderId: null, status: { state: "ready" }, }, { id: -1002, title: "TEAM_MEMORY.md", document_type: "TEAM_MEMORY", folderId: null, status: { state: "ready" }, }, ]; function isMemoryDocument(doc: { document_type: string }) { return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY"; } function downloadTextFile(content: string, fileName: string, type = "text/markdown;charset=utf-8") { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function EmbeddedDocumentsMenu({ typeCounts, activeTypes, onToggleType, onCreateFolder, }: { typeCounts: Partial>; activeTypes: DocumentTypeEnum[]; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onCreateFolder: () => void; }) { const documentTypes = useMemo( () => Object.keys(typeCounts).sort() as DocumentTypeEnum[], [typeCounts] ); return ( New folder Filter by type {documentTypes.length > 0 ? ( documentTypes.map((type) => ( onToggleType(type, checked === true)} onSelect={(event) => event.preventDefault()} > {getDocumentTypeIcon(type, "h-4 w-4")} {getDocumentTypeLabel(type)} {typeCounts[type] ?? 0} )) ) : ( No document types )} ); } const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const MAX_LOCAL_FILESYSTEM_ROOTS = 10; function CloudDocumentsSkeleton() { const rows = [ { id: "row-1", widthClass: "w-44" }, { id: "row-2", widthClass: "w-32" }, { id: "row-3", widthClass: "w-32" }, { id: "row-4", widthClass: "w-44" }, { id: "row-5", widthClass: "w-32" }, { id: "row-6", widthClass: "w-32" }, { id: "row-7", widthClass: "w-44" }, { id: "row-8", widthClass: "w-32" }, ]; return (
{rows.map((row) => (
))}
); } type FilesystemSettings = { mode: "cloud" | "desktop_local_folder"; localRootPaths: string[]; updatedAt: string; }; interface WatchedFolderEntry { path: string; name: string; excludePatterns: string[]; fileExtensions: string[] | null; rootFolderId: number | null; searchSpaceId: number; active: boolean; } 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(props: DocumentsSidebarProps) { const isAnonymous = useIsAnonymous(); const { isDesktop } = usePlatform(); if (isAnonymous) { return ; } return isDesktop ? ( ) : ( ); } function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) { return ; } function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) { return ; } function AuthenticatedDocumentsSidebarBase({ open, onOpenChange, isDocked = false, onDockedChange, embedded = false, headerAction, desktopFeaturesEnabled, }: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) { const t = useTranslations("documents"); const tSidebar = useTranslations("sidebar"); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); const platformElectronAPI = useElectronAPI(); const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const { etlService } = useRuntimeConfig(); const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: agentFlags } = useAtomValue(agentFlagsAtom); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); const [filesystemSettings, setFilesystemSettings] = useState(null); const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false); const [pendingLocalPath, setPendingLocalPath] = useState(null); const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true; const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI; useEffect(() => { if (!electronAPI?.getAgentFilesystemSettings) return; let mounted = true; electronAPI .getAgentFilesystemSettings(searchSpaceId) .then((settings: FilesystemSettings) => { if (!mounted) return; setFilesystemSettings(settings); }) .catch(() => { if (!mounted) return; setFilesystemSettings({ mode: "cloud", localRootPaths: [], updatedAt: new Date().toISOString(), }); }); return () => { mounted = false; }; }, [electronAPI, searchSpaceId]); const hasLocalFilesystemTrust = useCallback(() => { try { return window.localStorage.getItem(LOCAL_FILESYSTEM_TRUST_KEY) === "true"; } catch { return false; } }, []); const localRootPaths = filesystemSettings?.localRootPaths ?? []; const canAddMoreLocalRoots = localRootPaths.length < MAX_LOCAL_FILESYSTEM_ROOTS; const applyLocalRootPath = useCallback( async (path: string) => { if (!electronAPI?.setAgentFilesystemSettings) return; const nextLocalRootPaths = [path, ...localRootPaths] .filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index) .slice(0, MAX_LOCAL_FILESYSTEM_ROOTS); if (nextLocalRootPaths.length === localRootPaths.length) return; const updated = await electronAPI.setAgentFilesystemSettings( { mode: "desktop_local_folder", localRootPaths: nextLocalRootPaths, }, searchSpaceId ); setFilesystemSettings(updated); }, [electronAPI, localRootPaths, searchSpaceId] ); const runPickLocalRoot = useCallback(async () => { if (!electronAPI?.pickAgentFilesystemRoot) return; const picked = await electronAPI.pickAgentFilesystemRoot(); if (!picked) return; await applyLocalRootPath(picked); }, [applyLocalRootPath, electronAPI]); const handlePickFilesystemRoot = useCallback(async () => { if (!canAddMoreLocalRoots) return; if (hasLocalFilesystemTrust()) { await runPickLocalRoot(); return; } if (!electronAPI?.pickAgentFilesystemRoot) return; const picked = await electronAPI.pickAgentFilesystemRoot(); if (!picked) return; setPendingLocalPath(picked); setLocalTrustDialogOpen(true); }, [canAddMoreLocalRoots, electronAPI, hasLocalFilesystemTrust, runPickLocalRoot]); const handleRemoveFilesystemRoot = useCallback( async (rootPathToRemove: string) => { if (!electronAPI?.setAgentFilesystemSettings) return; const updated = await electronAPI.setAgentFilesystemSettings( { mode: "desktop_local_folder", localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), }, searchSpaceId ); setFilesystemSettings(updated); }, [electronAPI, localRootPaths, searchSpaceId] ); const handleClearFilesystemRoots = useCallback(async () => { if (!electronAPI?.setAgentFilesystemSettings) return; const updated = await electronAPI.setAgentFilesystemSettings( { mode: "desktop_local_folder", localRootPaths: [], }, searchSpaceId ); setFilesystemSettings(updated); }, [electronAPI, searchSpaceId]); const handleFilesystemTabChange = useCallback( async (tab: "cloud" | "local") => { if (!electronAPI?.setAgentFilesystemSettings) return; const updated = await electronAPI.setAgentFilesystemSettings( { mode: tab === "cloud" ? "cloud" : "desktop_local_folder", }, searchSpaceId ); setFilesystemSettings(updated); }, [electronAPI, searchSpaceId] ); 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); }, [setWatchInitialFolder, setFolderWatchOpen]); const refreshWatchedIds = useCallback(async () => { if (!electronAPI?.getWatchedFolders) return; const api = electronAPI; const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[]; 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()) as WatchedFolderEntry[]; const ids = new Set( recovered .filter((f: WatchedFolderEntry) => f.rootFolderId != null) .map((f: WatchedFolderEntry) => f.rootFolderId as number) ); setWatchedFolderIds(ids); return; } catch (err) { console.error("[DocumentsSidebar] Recovery from backend failed:", err); } } const ids = new Set( folders .filter((f: WatchedFolderEntry) => f.rootFolderId != null) .map((f: WatchedFolderEntry) => f.rootFolderId as number) ); setWatchedFolderIds(ids); }, [searchSpaceId, electronAPI]); useEffect(() => { refreshWatchedIds(); }, [refreshWatchedIds]); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom); const mentionedDocKeys = useMemo( () => new Set(sidebarDocs.map((d) => getMentionDocKey(d))), [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, zeroFoldersResult] = useQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroAllDocs, zeroAllDocsResult] = 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()) as WatchedFolderEntry[]; const matched = watchedFolders.find( (wf: WatchedFolderEntry) => 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(undefined, etlService)), 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, etlService] ); const handleStopWatching = useCallback( async (folder: FolderDisplay) => { if (!electronAPI) return; const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[]; const matched = watchedFolders.find( (wf: WatchedFolderEntry) => 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()) as WatchedFolderEntry[]; const matched = watchedFolders.find( (wf: WatchedFolderEntry) => 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 isExportingKBRef = useRef(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; isExportingKBRef.current = true; try { const safeName = ctx.folder.name .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "folder"; await doExport( buildBackendUrl(`/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 { isExportingKBRef.current = 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; } isExportingKBRef.current = true; try { const safeName = folder.name .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "folder"; await doExport( buildBackendUrl(`/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 { isExportingKBRef.current = false; } }, [searchSpaceId, getPendingCountInSubtree, doExport] ); const handleExportDocument = useCallback( async (doc: DocumentNodeDoc, format: string) => { if (isMemoryDocument(doc)) { try { const endpoint = doc.document_type === "USER_MEMORY" ? buildBackendUrl("/api/v1/users/me/memory") : buildBackendUrl(`/api/v1/workspaces/${searchSpaceId}/memory`); const response = await authenticatedFetch(endpoint, { method: "GET" }); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Export failed" })); throw new Error(errorData.detail || "Export failed"); } const data = (await response.json()) as { memory_md?: string }; downloadTextFile( data.memory_md ?? "", doc.title.endsWith(".md") ? doc.title : `${doc.title}.md` ); return; } catch (err) { console.error("Memory export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); return; } } 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( buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export`, { 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 (isMemoryDocument(doc)) return; const key = getMentionDocKey({ ...doc, kind: "doc" }); if (isMentioned) { setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key)); } else { setSidebarDocs((prev) => { if (prev.some((d) => getMentionDocKey(d) === key)) return prev; return [ ...prev, { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum, kind: "doc", }, ]; }); } }, [setSidebarDocs] ); const handleToggleFolderSelect = useCallback( (folderId: number, selectAll: boolean) => { // One folder click = one folder-mention chip. The agent // resolves the chip to its virtual path // (``/documents/MyFolder/``) and walks it itself with // ``ls`` / ``find_documents``. We deliberately don't // fan out to per-doc chips anymore — the previous // behaviour created N chips for one click and dropped // nested folders entirely once selected, which the // agent had no way to recover. const folder = treeFolders.find((f) => f.id === folderId); if (!folder) return; const chip = makeFolderMention({ id: folder.id, name: folder.name }); const chipKey = getMentionDocKey(chip); if (selectAll) { setSidebarDocs((prev) => { const exists = prev.some((d) => getMentionDocKey(d) === chipKey); return exists ? prev : [...prev, chip]; }); } else { setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== chipKey)); } }, [treeFolders, setSidebarDocs] ); const treeDocumentsWithMemory = useMemo( () => [...MEMORY_DOCUMENTS, ...treeDocuments], [treeDocuments] ); const searchFilteredDocuments = useMemo(() => { const query = debouncedSearch.trim().toLowerCase(); if (!query) return treeDocumentsWithMemory; return treeDocumentsWithMemory.filter((d) => d.title.toLowerCase().includes(query)); }, [treeDocumentsWithMemory, debouncedSearch]); const openMemoryDocument = useCallback( (doc: DocumentNodeDoc) => { if (doc.document_type === "USER_MEMORY") { openEditorPanel({ kind: "memory", memoryScope: "user", searchSpaceId, title: doc.title, }); return true; } if (doc.document_type === "TEAM_MEMORY") { openEditorPanel({ kind: "memory", memoryScope: "team", searchSpaceId, title: doc.title, }); return true; } return false; }, [openEditorPanel, searchSpaceId] ); const handleResetMemoryDocument = useCallback( async (doc: DocumentNodeDoc) => { if (!isMemoryDocument(doc)) return; if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) { return; } const endpoint = doc.document_type === "USER_MEMORY" ? buildBackendUrl("/api/v1/users/me/memory/reset") : buildBackendUrl(`/api/v1/workspaces/${searchSpaceId}/memory/reset`); try { const response = await authenticatedFetch(endpoint, { method: "POST" }); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Reset failed" })); throw new Error(errorData.detail || "Reset failed"); } toast.success(`${doc.title} reset`); openMemoryDocument(doc); } catch (error) { toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`); } }, [openMemoryDocument, searchSpaceId] ); 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) => { if (doc.kind !== "doc") return false; 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.kind !== "doc" || 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 && isMobile) { onOpenChange(false); } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange, isMobile]); const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings && localFilesystemEnabled; const currentFilesystemTab = localFilesystemEnabled && filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud"; const showCloudSkeleton = currentFilesystemTab === "cloud" && (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); const renderDocumentTree = ({ documents = searchFilteredDocuments, activeTypesForTree = activeTypes, searchQuery = debouncedSearch.trim() || undefined, }: { documents?: DocumentNodeDoc[]; activeTypesForTree?: DocumentTypeEnum[]; searchQuery?: string; } = {}) => (
{deletableSelectedIds.length > 0 && (
)} {showCloudSkeleton ? ( ) : ( { if (openMemoryDocument(doc)) return; openEditorPanel({ documentId: doc.id, searchSpaceId, title: doc.title, }); }} onEditDocument={(doc) => { if (openMemoryDocument(doc)) return; openEditorPanel({ documentId: doc.id, searchSpaceId, title: doc.title, }); }} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onMoveDocument={handleMoveDocument} onResetDocument={handleResetMemoryDocument} onExportDocument={handleExportDocument} onVersionHistory={(doc) => setVersionDocId(doc.id)} activeTypes={activeTypesForTree} onDropIntoFolder={handleDropIntoFolder} onReorderFolder={handleReorderFolder} watchedFolderIds={watchedFolderIds} onRescanFolder={handleRescanFolder} onStopWatchingFolder={handleStopWatching} onExportFolder={handleExportFolder} /> )}
); const cloudContent = ( <> {isElectron && ( )}
handleCreateFolder(null)} />
{renderDocumentTree()}
); const localContent = ( { openEditorPanel({ kind: "local_file", localFilePath, title: localFilePath.split("/").pop() || localFilePath, searchSpaceId, }); }} electronAvailable={!!electronAPI} /> ); const documentsContent = ( <>
{isMobile && ( )}

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

{showFilesystemTabs && ( { void handleFilesystemTabChange(value === "local" ? "local" : "cloud"); }} > Cloud Local )}
{!isMobile && onDockedChange && ( {isDocked ? "Collapse panel" : "Expand panel"} )} {headerAction}
{showFilesystemTabs ? ( { void handleFilesystemTabChange(value === "local" ? "local" : "cloud"); }} className="flex min-h-0 flex-1 flex-col" > {cloudContent} {currentFilesystemTab === "local" ? localContent : null} ) : ( cloudContent )} {versionDocId !== null && ( { if (!open) setVersionDocId(null); }} documentId={versionDocId} /> )} {isElectron && ( { setFolderWatchOpen(nextOpen); if (!nextOpen) setWatchInitialFolder(null); }} searchSpaceId={searchSpaceId} initialFolder={watchInitialFolder} onSuccess={refreshWatchedIds} /> )} { setLocalTrustDialogOpen(nextOpen); if (!nextOpen) setPendingLocalPath(null); }} > Trust this workspace? Local mode can read and edit files inside the folders you select. Continue only if you trust this workspace and its contents. {pendingLocalPath && ( Folder path: {pendingLocalPath} )} Cancel { try { window.localStorage.setItem(LOCAL_FILESYSTEM_TRUST_KEY, "true"); } catch {} setLocalTrustDialogOpen(false); const path = pendingLocalPath; setPendingLocalPath(null); if (path) { await applyLocalRootPath(path); } else { await runPickLocalRoot(); } }} > I trust this workspace !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 ); if (embedded) { return (
handleCreateFolder(null)} />
{renderDocumentTree()}
); } if (isDocked && open && !isMobile) { return ( ); } return ( {documentsContent} ); } // --------------------------------------------------------------------------- // Anonymous Documents Sidebar // --------------------------------------------------------------------------- const ANON_ALLOWED_EXTENSIONS = new Set([ ".md", ".markdown", ".txt", ".text", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".xml", ".css", ".scss", ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".kt", ".go", ".rs", ".rb", ".php", ".c", ".h", ".cpp", ".hpp", ".cs", ".swift", ".sh", ".sql", ".log", ".rst", ".tex", ".vue", ".svelte", ".astro", ".tf", ".proto", ".csv", ".tsv", ".html", ".htm", ".xhtml", ]); const ANON_ACCEPT = Array.from(ANON_ALLOWED_EXTENSIONS).join(","); function AnonymousDocumentsSidebar({ open, onOpenChange, isDocked = false, onDockedChange, embedded = false, headerAction, }: DocumentsSidebarProps) { const t = useTranslations("documents"); const tSidebar = useTranslations("sidebar"); const isMobile = !useMediaQuery("(min-width: 640px)"); const anonMode = useAnonymousMode(); const { gate } = useLoginGate(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [search, setSearch] = useState(""); const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom); const mentionedDocKeys = useMemo( () => new Set(sidebarDocs.map((d) => getMentionDocKey(d))), [sidebarDocs] ); const handleToggleChatMention = useCallback( (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { const key = getMentionDocKey({ ...doc, kind: "doc" }); if (isMentioned) { setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key)); } else { setSidebarDocs((prev) => { if (prev.some((d) => getMentionDocKey(d) === key)) return prev; return [ ...prev, { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum, kind: "doc", }, ]; }); } }, [setSidebarDocs] ); const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null; const hasDoc = uploadedDoc !== null; const handleAnonUploadClick = useCallback(() => { if (hasDoc) { gate("upload more documents"); return; } fileInputRef.current?.click(); }, [hasDoc, gate]); const handleFileChange = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ""; const ext = `.${file.name.split(".").pop()?.toLowerCase()}`; if (!ANON_ALLOWED_EXTENSIONS.has(ext)) { gate("upload PDFs, Word documents, images, and more"); return; } setIsUploading(true); try { const result = await anonymousChatApiService.uploadDocument(file); if (!result.ok) { if (result.reason === "quota_exceeded") gate("upload more documents"); return; } const data = result.data; if (anonMode.isAnonymous) { anonMode.setUploadedDoc({ filename: data.filename, sizeBytes: data.size_bytes, }); } toast.success(`Uploaded "${data.filename}"`); } catch (err) { console.error("Upload failed:", err); toast.error(err instanceof Error ? err.message : "Upload failed"); } finally { setIsUploading(false); } }, [gate, anonMode] ); const handleRemoveDoc = useCallback(() => { if (anonMode.isAnonymous) { anonMode.setUploadedDoc(null); } }, [anonMode]); const treeDocuments: DocumentNodeDoc[] = useMemo(() => { if (!anonMode.isAnonymous || !anonMode.uploadedDoc) return []; return [ { id: -1, title: anonMode.uploadedDoc.filename, document_type: "FILE", folderId: null, status: { state: "ready" } as { state: string; reason?: string | null }, }, ]; }, [anonMode]); const searchFilteredDocs = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return treeDocuments; return treeDocuments.filter((d) => d.title.toLowerCase().includes(q)); }, [treeDocuments, search]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open && isMobile) { onOpenChange(false); } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange, isMobile]); const documentsContent = ( <> {/* Header */}

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

{isMobile && ( )} {!isMobile && onDockedChange && ( {isDocked ? "Collapse panel" : "Expand panel"} )} {headerAction}
{/* Filters & upload */}
{}} activeTypes={[]} onCreateFolder={() => gate("create folders")} />
{}} mentionedDocKeys={mentionedDocKeys} onToggleChatMention={handleToggleChatMention} onToggleFolderSelect={() => {}} onRenameFolder={() => gate("rename folders")} onDeleteFolder={() => gate("delete folders")} onMoveFolder={() => gate("organize folders")} onCreateFolder={() => gate("create folders")} searchQuery={search.trim() || undefined} onPreviewDocument={() => gate("preview documents")} onEditDocument={() => gate("edit documents")} onDeleteDocument={async () => { handleRemoveDoc(); setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1)); return true; }} onMoveDocument={() => gate("organize documents")} onExportDocument={() => gate("export documents")} onVersionHistory={() => gate("view version history")} activeTypes={[]} onDropIntoFolder={async () => gate("organize documents")} onReorderFolder={async () => gate("organize folders")} watchedFolderIds={new Set()} onRescanFolder={() => gate("watch local folders")} onStopWatchingFolder={() => gate("watch local folders")} onExportFolder={() => gate("export folders")} /> {!hasDoc && (

Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+ connectors.

)}
{/* CTA footer */}
Create an account to unlock:
  • PDF, Word, images, audio uploads
  • Unlimited documents
); if (embedded) { return (
{}} onCreateFolder={() => gate("create folders")} />
{}} mentionedDocKeys={mentionedDocKeys} onToggleChatMention={handleToggleChatMention} onToggleFolderSelect={() => {}} onRenameFolder={() => gate("rename folders")} onDeleteFolder={() => gate("delete folders")} onMoveFolder={() => gate("organize folders")} onCreateFolder={() => gate("create folders")} onPreviewDocument={() => gate("preview documents")} onEditDocument={() => gate("edit documents")} onDeleteDocument={async () => { handleRemoveDoc(); setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1)); return true; }} onMoveDocument={() => gate("organize documents")} onExportDocument={() => gate("export documents")} onVersionHistory={() => gate("view version history")} activeTypes={[]} onDropIntoFolder={async () => gate("organize documents")} onReorderFolder={async () => gate("organize folders")} watchedFolderIds={new Set()} onRescanFolder={() => gate("watch local folders")} onStopWatchingFolder={() => gate("watch local folders")} onExportFolder={() => gate("export folders")} />
); } if (isDocked && open && !isMobile) { return ( ); } if (isMobile) { return ( {t("title") || "Documents"}
{documentsContent}
); } return ( {documentsContent} ); }