diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index 0acc1d30b..edb01d4cc 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -29,6 +29,7 @@ from app.schemas import (
DocumentTitleSearchResponse,
DocumentUpdate,
DocumentWithChunksRead,
+ FolderRead,
PaginatedResponse,
)
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
@@ -953,15 +954,13 @@ async def get_document_by_chunk_id(
) from e
-@router.get("/documents/watched-folders", response_model=list["FolderRead"])
+@router.get("/documents/watched-folders", response_model=list[FolderRead])
async def get_watched_folders(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Return root folders that are marked as watched (metadata->>'watched' = 'true')."""
- from app.schemas import FolderRead # noqa: F811
-
await check_permission(
session,
user,
diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx
index 06b0d38e7..78600be47 100644
--- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx
+++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx
@@ -125,29 +125,23 @@ const DocumentUploadPopupContent: FC<{
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
- className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
+ className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(460px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-3 sm:[&>button]:top-5 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
>
Upload Document
- {/* Scrollable container for mobile */}
- {/* Header - scrolls with content on mobile */}
-
- {/* Upload header */}
-
-
-
- Upload Documents
-
-
- Upload and sync your documents to your search space
-
-
+
+
+
+ Upload Documents
+
+
+ Upload and sync your documents to your search space
+
- {/* Content */}
-
+
{!isLoading && !hasDocumentSummaryLLM ? (
@@ -179,9 +173,6 @@ const DocumentUploadPopupContent: FC<{
)}
-
- {/* Bottom fade shadow - hidden on very small screens */}
-
);
diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx
index 691a6eb0d..7a3b3e0ca 100644
--- a/surfsense_web/components/documents/DocumentNode.tsx
+++ b/surfsense_web/components/documents/DocumentNode.tsx
@@ -195,12 +195,14 @@ export const DocumentNode = React.memo(function DocumentNode({
{doc.title}
-
- {getDocumentTypeIcon(
- doc.document_type as DocumentTypeEnum,
- "h-3.5 w-3.5 text-muted-foreground"
- )}
-
+ {getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground") && (
+
+ {getDocumentTypeIcon(
+ doc.document_type as DocumentTypeEnum,
+ "h-3.5 w-3.5 text-muted-foreground"
+ )}
+
+ )}
diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx
index 6780bd1e5..41c1d8f73 100644
--- a/surfsense_web/components/documents/FolderNode.tsx
+++ b/surfsense_web/components/documents/FolderNode.tsx
@@ -1,6 +1,7 @@
"use client";
import {
+ AlertCircle,
ChevronDown,
ChevronRight,
Eye,
@@ -30,6 +31,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { FolderSelectionState } from "./FolderTreeView";
@@ -55,6 +58,7 @@ interface FolderNodeProps {
isRenaming: boolean;
childCount: number;
selectionState: FolderSelectionState;
+ processingState: "idle" | "processing" | "failed";
onToggleSelect: (folderId: number, selectAll: boolean) => void;
onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void;
@@ -100,6 +104,7 @@ export const FolderNode = React.memo(function FolderNode({
isRenaming,
childCount,
selectionState,
+ processingState,
onToggleSelect,
onToggleExpand,
onRename,
@@ -281,14 +286,41 @@ export const FolderNode = React.memo(function FolderNode({
)}
- e.stopPropagation()}
- className="h-3.5 w-3.5 shrink-0"
- />
+ {processingState !== "idle" && selectionState === "none" ? (
+ <>
+
+
+
+ {processingState === "processing" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {processingState === "processing"
+ ? "Syncing folder contents"
+ : "Some files failed to process"}
+
+
+ e.stopPropagation()}
+ className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
+ />
+ >
+ ) : (
+ e.stopPropagation()}
+ className="h-3.5 w-3.5 shrink-0"
+ />
+ )}
diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx
index f34b9a0c2..01af73edc 100644
--- a/surfsense_web/components/documents/FolderTreeView.tsx
+++ b/surfsense_web/components/documents/FolderTreeView.tsx
@@ -166,6 +166,35 @@ export function FolderTreeView({
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
+ const folderProcessingStates = useMemo(() => {
+ const states: Record = {};
+
+ function compute(folderId: number): { hasProcessing: boolean; hasFailed: boolean } {
+ const directDocs = docsByFolder[folderId] ?? [];
+ let hasProcessing = directDocs.some(
+ (d) => d.status?.state === "pending" || d.status?.state === "processing"
+ );
+ let hasFailed = directDocs.some((d) => d.status?.state === "failed");
+
+ for (const child of foldersByParent[folderId] ?? []) {
+ const sub = compute(child.id);
+ hasProcessing = hasProcessing || sub.hasProcessing;
+ hasFailed = hasFailed || sub.hasFailed;
+ }
+
+ if (hasProcessing) states[folderId] = "processing";
+ else if (hasFailed) states[folderId] = "failed";
+ else states[folderId] = "idle";
+
+ return { hasProcessing, hasFailed };
+ }
+
+ for (const f of folders) {
+ if (states[f.id] === undefined) compute(f.id);
+ }
+ return states;
+ }, [folders, docsByFolder, foldersByParent]);
+
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? [])
@@ -199,6 +228,7 @@ export function FolderTreeView({
isRenaming={renamingFolderId === f.id}
childCount={folderChildCounts[f.id] ?? 0}
selectionState={folderSelectionStates[f.id] ?? "none"}
+ processingState={folderProcessingStates[f.id] ?? "idle"}
onToggleSelect={onToggleFolderSelect}
onToggleExpand={onToggleExpand}
onRename={onRenameFolder}
diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx
index d5ac2770a..7176afae5 100644
--- a/surfsense_web/components/sources/DocumentUploadTab.tsx
+++ b/surfsense_web/components/sources/DocumentUploadTab.tsx
@@ -1,24 +1,21 @@
"use client";
import { useAtom } from "jotai";
-import { CheckCircle2, ChevronDown, File as FileIcon, FileType, FolderOpen, Info, Upload, X } from "lucide-react";
+import { CheckCircle2, ChevronDown, File as FileIcon, FileType, FolderOpen, Plus, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
-import { SummaryConfig } from "@/components/assistant-ui/connector-popup/components/summary-config";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
-import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +24,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
-import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
@@ -36,7 +32,6 @@ import {
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
-import { GridPattern } from "./GridPattern";
interface SelectedFolder {
path: string;
@@ -128,13 +123,12 @@ interface FileWithId {
file: File;
}
-const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
-
-// Upload limits — files are sent in batches of 5 to avoid proxy timeouts
const MAX_FILES = 50;
const MAX_TOTAL_SIZE_MB = 200;
const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024;
+const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
+
export function DocumentUploadTab({
searchSpaceId,
onSuccess,
@@ -198,7 +192,7 @@ export function DocumentUploadTab({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptedFileTypes,
- maxSize: 50 * 1024 * 1024, // 50MB per file
+ maxSize: 50 * 1024 * 1024,
noClick: isElectron,
disabled: files.length >= MAX_FILES,
});
@@ -270,6 +264,8 @@ export function DocumentUploadTab({
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1);
+ const hasContent = files.length > 0 || selectedFolder !== null;
+
const handleAccordionChange = useCallback(
(value: string) => {
setAccordionValue(value);
@@ -307,7 +303,7 @@ export function DocumentUploadTab({
});
toast.success(`Watching folder: ${selectedFolder.name}`);
} else {
- toast.success(`Indexing folder: ${selectedFolder.name}`);
+ toast.success(`Syncing folder: ${selectedFolder.name}`);
}
setSelectedFolder(null);
@@ -355,139 +351,180 @@ export function DocumentUploadTab({
);
};
- return (
-
-
-
-
- {t("file_size_limit")}{" "}
- {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
-
-
+ const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => {
+ const { compact, fullWidth } = options ?? {};
+ if (isFileCountLimitReached) return null;
-
-
-
-
-
-
-
- {isFileCountLimitReached ? (
-
-
-
-
- {t("file_limit_reached")}
-
-
- {t("file_limit_reached_desc", { max: MAX_FILES })}
-
+ const sizeClass = compact ? "h-7" : "h-8";
+ const widthClass = fullWidth ? "w-full" : "";
+
+ if (isElectron) {
+ return (
+
+ e.stopPropagation()}>
+
+
+ e.stopPropagation()}>
+
+
+ Files
+
+
+
+ Folder
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+ {/* Hidden file input for mobile browse */}
+
+
+ {/* MOBILE DROP ZONE */}
+
+ {hasContent ? (
+ !selectedFolder && !isFileCountLimitReached && (
+ isElectron ? (
+
+ {renderBrowseButton({ compact: true, fullWidth: true })}
-
- ) : isDragActive ? (
-
- ) : (
-
-
-
-
{t("drag_drop")}
-
{t("or_browse")}
-
- {files.length > 0 && (
-
- {t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
-
- )}
-
- )}
- {!isFileCountLimitReached && (
-
- {isElectron ? (
-
- e.stopPropagation()}>
-
-
- e.stopPropagation()}>
-
-
- Files
-
-
-
- Folder
-
-
-
) : (
+ )
+ )
+ ) : (
+
{
+ if (!isElectron) fileInputRef.current?.click();
+ }}
+ >
+
+
+
+ {isElectron ? "Select files or folder" : "Tap to select files"}
+
+
+ {t("file_size_limit")}{" "}
+ {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
+
+
+ {isElectron && (
+
e.stopPropagation()}>
+ {renderBrowseButton({ fullWidth: true })}
+
)}
)}
-
-
-
+
- {selectedFolder && (
-
-
-
-
-
-
-
- {selectedFolder.name}
-
-
- {selectedFolder.path}
-
-
+ {/* DESKTOP DROP ZONE */}
+
+ {hasContent ? (
+
+
+
+ {isDragActive
+ ? t("drop_files")
+ : isFileCountLimitReached
+ ? t("file_limit_reached")
+ : t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
+
+ {renderBrowseButton({ compact: true })}
+
+ ) : isFileCountLimitReached ? (
+
+
+
{t("file_limit_reached")}
+
+ {t("file_limit_reached_desc", { max: MAX_FILES })}
+
+
+ ) : isDragActive ? (
+
+ ) : (
+
+
+
{t("drag_drop")}
+
+ {t("file_size_limit")}{" "}
+ {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
+
+
{renderBrowseButton()}
+
+ )}
+
+
+ {/* FOLDER SELECTED */}
+ {selectedFolder && (
+
+
+
+
+
{selectedFolder.name}
+
{selectedFolder.path}
-
-
-
-
)}
+ {/* FILES SELECTED */}
+ {files.length > 0 && (
+
+
+
+ {t("selected_files", { count: files.length })} · {formatFileSize(totalFileSize)}
+
+
+
+
+
+ {files.map((entry) => (
+
+
+ {entry.file.name}
+
+ {formatFileSize(entry.file.size)}
+
+
+
+ ))}
+
+
+ {isUploading && (
+
+
+ {t("uploading_files")}
+ {Math.round(uploadProgress)}%
+
+
+
+ )}
+
+
+
+
Enable AI Summary
+
+ Improves search quality but adds latency
+
+
+
+
+
+
+
+ )}
+
+ {/* SUPPORTED FORMATS */}
-
-
-
-
-
- {t("supported_file_types")}
-
-
- {t("file_types_desc")}
-
-
-
+
+
+
+ {t("supported_file_types")}
+
-
-
+
+
{supportedExtensions.map((ext) => (
-
+
{ext}
))}
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 2e609b060..ab71d58b5 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -126,6 +126,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return ;
case "DEEPEST":
return ;
+ case "LOCAL_FOLDER_FILE":
+ return null;
default:
return ;
}