feat: enhance document upload and folder synchronization UI with improved processing state indicators and responsive design adjustments

This commit is contained in:
Anish Sarkar 2026-04-03 04:14:09 +05:30
parent 530db10539
commit bd21c2842d
7 changed files with 359 additions and 283 deletions

View file

@ -29,6 +29,7 @@ from app.schemas import (
DocumentTitleSearchResponse, DocumentTitleSearchResponse,
DocumentUpdate, DocumentUpdate,
DocumentWithChunksRead, DocumentWithChunksRead,
FolderRead,
PaginatedResponse, PaginatedResponse,
) )
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
@ -953,15 +954,13 @@ async def get_document_by_chunk_id(
) from e ) 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( async def get_watched_folders(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
"""Return root folders that are marked as watched (metadata->>'watched' = 'true').""" """Return root folders that are marked as watched (metadata->>'watched' = 'true')."""
from app.schemas import FolderRead # noqa: F811
await check_permission( await check_permission(
session, session,
user, user,

View file

@ -125,29 +125,23 @@ const DocumentUploadPopupContent: FC<{
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(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"
> >
<DialogTitle className="sr-only">Upload Document</DialogTitle> <DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain"> <div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{/* Header - scrolls with content on mobile */} <div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-4 sm:pt-5 pb-10">
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0"> <div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
{/* Upload header */} <h2 className="text-base sm:text-lg font-semibold tracking-tight">
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6"> Upload Documents
<div className="flex-1 min-w-0 pr-8 sm:pr-0"> </h2>
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
Upload Documents
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1 line-clamp-1 sm:line-clamp-none">
Upload and sync your documents to your search space
</p>
</div>
</div> </div>
<p className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
Upload and sync your documents to your search space
</p>
</div> </div>
{/* Content */} <div className="px-4 sm:px-6 pb-4 sm:pb-6">
<div className="px-4 sm:px-12 pb-4 sm:pb-16">
{!isLoading && !hasDocumentSummaryLLM ? ( {!isLoading && !hasDocumentSummaryLLM ? (
<Alert variant="destructive" className="mb-4 bg-muted/50 rounded-xl border-destructive/30"> <Alert variant="destructive" className="mb-4 bg-muted/50 rounded-xl border-destructive/30">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
@ -179,9 +173,6 @@ const DocumentUploadPopupContent: FC<{
)} )}
</div> </div>
</div> </div>
{/* Bottom fade shadow - hidden on very small screens */}
<div className="hidden sm:block absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -195,12 +195,14 @@ export const DocumentNode = React.memo(function DocumentNode({
<span className="flex-1 min-w-0 truncate">{doc.title}</span> <span className="flex-1 min-w-0 truncate">{doc.title}</span>
<span className="shrink-0"> {getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground") && (
{getDocumentTypeIcon( <span className="shrink-0">
doc.document_type as DocumentTypeEnum, {getDocumentTypeIcon(
"h-3.5 w-3.5 text-muted-foreground" doc.document_type as DocumentTypeEnum,
)} "h-3.5 w-3.5 text-muted-foreground"
</span> )}
</span>
)}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
AlertCircle,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Eye, Eye,
@ -30,6 +31,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { cn } from "@/lib/utils";
import type { FolderSelectionState } from "./FolderTreeView"; import type { FolderSelectionState } from "./FolderTreeView";
@ -55,6 +58,7 @@ interface FolderNodeProps {
isRenaming: boolean; isRenaming: boolean;
childCount: number; childCount: number;
selectionState: FolderSelectionState; selectionState: FolderSelectionState;
processingState: "idle" | "processing" | "failed";
onToggleSelect: (folderId: number, selectAll: boolean) => void; onToggleSelect: (folderId: number, selectAll: boolean) => void;
onToggleExpand: (folderId: number) => void; onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void; onRename: (folder: FolderDisplay, newName: string) => void;
@ -100,6 +104,7 @@ export const FolderNode = React.memo(function FolderNode({
isRenaming, isRenaming,
childCount, childCount,
selectionState, selectionState,
processingState,
onToggleSelect, onToggleSelect,
onToggleExpand, onToggleExpand,
onRename, onRename,
@ -281,14 +286,41 @@ export const FolderNode = React.memo(function FolderNode({
)} )}
</span> </span>
<Checkbox {processingState !== "idle" && selectionState === "none" ? (
checked={ <>
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false <Tooltip>
} <TooltipTrigger asChild>
onCheckedChange={handleCheckChange} <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center group-hover:hidden">
onClick={(e) => e.stopPropagation()} {processingState === "processing" ? (
className="h-3.5 w-3.5 shrink-0" <Spinner size="xs" className="text-primary" />
/> ) : (
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{processingState === "processing"
? "Syncing folder contents"
: "Some files failed to process"}
</TooltipContent>
</Tooltip>
<Checkbox
checked={false}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
/>
</>
) : (
<Checkbox
checked={
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false
}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />

View file

@ -166,6 +166,35 @@ export function FolderTreeView({
return states; return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]); }, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
const folderProcessingStates = useMemo(() => {
const states: Record<number, "idle" | "processing" | "failed"> = {};
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[] { function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root"; const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? []) const childFolders = (foldersByParent[key] ?? [])
@ -199,6 +228,7 @@ export function FolderTreeView({
isRenaming={renamingFolderId === f.id} isRenaming={renamingFolderId === f.id}
childCount={folderChildCounts[f.id] ?? 0} childCount={folderChildCounts[f.id] ?? 0}
selectionState={folderSelectionStates[f.id] ?? "none"} selectionState={folderSelectionStates[f.id] ?? "none"}
processingState={folderProcessingStates[f.id] ?? "idle"}
onToggleSelect={onToggleFolderSelect} onToggleSelect={onToggleFolderSelect}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onRename={onRenameFolder} onRename={onRenameFolder}

View file

@ -1,24 +1,21 @@
"use client"; "use client";
import { useAtom } from "jotai"; 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 { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { toast } from "sonner"; import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { SummaryConfig } from "@/components/assistant-ui/connector-popup/components/summary-config";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -27,7 +24,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
@ -36,7 +32,6 @@ import {
trackDocumentUploadStarted, trackDocumentUploadStarted,
trackDocumentUploadSuccess, trackDocumentUploadSuccess,
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import { GridPattern } from "./GridPattern";
interface SelectedFolder { interface SelectedFolder {
path: string; path: string;
@ -128,13 +123,12 @@ interface FileWithId {
file: File; 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_FILES = 50;
const MAX_TOTAL_SIZE_MB = 200; const MAX_TOTAL_SIZE_MB = 200;
const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024; 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({ export function DocumentUploadTab({
searchSpaceId, searchSpaceId,
onSuccess, onSuccess,
@ -198,7 +192,7 @@ export function DocumentUploadTab({
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
accept: acceptedFileTypes, accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, // 50MB per file maxSize: 50 * 1024 * 1024,
noClick: isElectron, noClick: isElectron,
disabled: files.length >= MAX_FILES, disabled: files.length >= MAX_FILES,
}); });
@ -270,6 +264,8 @@ export function DocumentUploadTab({
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024) (MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1); ).toFixed(1);
const hasContent = files.length > 0 || selectedFolder !== null;
const handleAccordionChange = useCallback( const handleAccordionChange = useCallback(
(value: string) => { (value: string) => {
setAccordionValue(value); setAccordionValue(value);
@ -307,7 +303,7 @@ export function DocumentUploadTab({
}); });
toast.success(`Watching folder: ${selectedFolder.name}`); toast.success(`Watching folder: ${selectedFolder.name}`);
} else { } else {
toast.success(`Indexing folder: ${selectedFolder.name}`); toast.success(`Syncing folder: ${selectedFolder.name}`);
} }
setSelectedFolder(null); setSelectedFolder(null);
@ -355,139 +351,180 @@ export function DocumentUploadTab({
); );
}; };
return ( const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => {
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"> const { compact, fullWidth } = options ?? {};
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5"> if (isFileCountLimitReached) return null;
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}{" "}
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</AlertDescription>
</Alert>
<Card className={`relative overflow-hidden ${cardClass}`}> const sizeClass = compact ? "h-7" : "h-8";
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30"> const widthClass = fullWidth ? "w-full" : "";
<GridPattern />
</div> if (isElectron) {
<CardContent className="p-4 sm:p-10 relative z-10"> return (
<div <DropdownMenu>
{...getRootProps()} <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${ <Button variant="secondary" size="sm" className={`text-xs gap-1 ${sizeClass} ${widthClass}`}>
isFileCountLimitReached || isSizeLimitReached Browse
? "border-destructive/50 bg-destructive/5 cursor-not-allowed" <ChevronDown className="h-3 w-3 opacity-60" />
: "border-border hover:border-primary/50 cursor-pointer" </Button>
}`} </DropdownMenuTrigger>
> <DropdownMenuContent align="center" onClick={(e) => e.stopPropagation()}>
<input <DropdownMenuItem onClick={handleBrowseFiles}>
{...getInputProps()} <FileIcon className="h-4 w-4 mr-2" />
ref={fileInputRef} Files
className="hidden" </DropdownMenuItem>
onClick={handleFileInputClick} <DropdownMenuItem onClick={handleBrowseFolder}>
/> <FolderOpen className="h-4 w-4 mr-2" />
{isFileCountLimitReached ? ( Folder
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4"> </DropdownMenuItem>
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" /> </DropdownMenuContent>
<div> </DropdownMenu>
<p className="text-sm sm:text-lg font-medium text-destructive"> );
{t("file_limit_reached")} }
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1"> return (
{t("file_limit_reached_desc", { max: MAX_FILES })} <Button
</p> variant="secondary"
size="sm"
className={`text-xs ${sizeClass} ${widthClass}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
fileInputRef.current?.click();
}}
>
{t("browse_files")}
</Button>
);
};
return (
<div className="space-y-2 w-full mx-auto">
{/* Hidden file input for mobile browse */}
<input
{...getInputProps()}
ref={fileInputRef}
className="hidden"
onClick={handleFileInputClick}
/>
{/* MOBILE DROP ZONE */}
<div className="sm:hidden">
{hasContent ? (
!selectedFolder && !isFileCountLimitReached && (
isElectron ? (
<div className="w-full">
{renderBrowseButton({ compact: true, fullWidth: true })}
</div> </div>
</div>
) : isDragActive ? (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div>
)}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
{isElectron ? (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="secondary" size="sm" className="text-xs sm:text-sm gap-1">
{t("browse_files")}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleBrowseFiles}>
<FileIcon className="h-4 w-4 mr-2" />
Files
</DropdownMenuItem>
<DropdownMenuItem onClick={handleBrowseFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : ( ) : (
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
className="text-xs sm:text-sm" className="w-full text-xs h-8 gap-1.5 border-dashed border-muted-foreground/30"
onClick={(e) => { onClick={() => fileInputRef.current?.click()}
e.stopPropagation();
e.preventDefault();
fileInputRef.current?.click();
}}
> >
{t("browse_files")} <Plus className="h-3.5 w-3.5" />
Add more files
</Button> </Button>
)
)
) : (
<div
className="flex flex-col items-center gap-3 py-6 px-4 cursor-pointer"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
>
<Upload className="h-7 w-7 text-muted-foreground" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">
{isElectron ? "Select files or folder" : "Tap to select files"}
</p>
<p className="text-xs text-muted-foreground">
{t("file_size_limit")}{" "}
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</p>
</div>
{isElectron && (
<div className="w-full mt-1" onClick={(e) => e.stopPropagation()}>
{renderBrowseButton({ fullWidth: true })}
</div>
)} )}
</div> </div>
)} )}
</div> </div>
</CardContent>
</Card>
{selectedFolder && ( {/* DESKTOP DROP ZONE */}
<Card className={cardClass}> <div
<CardHeader className="p-4 sm:p-6"> {...getRootProps()}
<div className="flex items-center justify-between gap-2"> className={`hidden sm:block border-2 border-dashed rounded-lg transition-colors ${
<div className="flex items-center gap-3 min-w-0 flex-1"> isFileCountLimitReached || isSizeLimitReached
<FolderOpen className="h-5 w-5 text-primary flex-shrink-0" /> ? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
<div className="min-w-0 flex-1"> : "border-muted-foreground/30 hover:border-foreground/70 cursor-pointer"
<CardTitle className="text-base sm:text-lg truncate"> } ${hasContent ? "p-3" : "py-20 px-4"}`}
{selectedFolder.name} >
</CardTitle> {hasContent ? (
<CardDescription className="text-xs sm:text-sm truncate"> <div className="flex items-center gap-3">
{selectedFolder.path} <Upload className="h-4 w-4 text-muted-foreground shrink-0" />
</CardDescription> <span className="text-xs text-muted-foreground flex-1 truncate">
</div> {isDragActive
? t("drop_files")
: isFileCountLimitReached
? t("file_limit_reached")
: t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</span>
{renderBrowseButton({ compact: true })}
</div>
) : isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 text-center">
<Upload className="h-8 w-8 text-destructive/70" />
<p className="text-sm font-medium text-destructive">{t("file_limit_reached")}</p>
<p className="text-xs text-muted-foreground">
{t("file_limit_reached_desc", { max: MAX_FILES })}
</p>
</div>
) : isDragActive ? (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-primary" />
<p className="text-sm font-medium text-primary">{t("drop_files")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">{t("drag_drop")}</p>
<p className="text-xs text-muted-foreground text-center">
{t("file_size_limit")}{" "}
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</p>
<div className="mt-1">{renderBrowseButton()}</div>
</div>
)}
</div>
{/* FOLDER SELECTED */}
{selectedFolder && (
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-primary shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{selectedFolder.name}</p>
<p className="text-xs text-muted-foreground truncate">{selectedFolder.path}</p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0" className="h-7 w-7 shrink-0"
onClick={() => setSelectedFolder(null)} onClick={() => setSelectedFolder(null)}
disabled={folderSubmitting} disabled={folderSubmitting}
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0 space-y-4"> <div className={toggleRowClass}>
<div className="flex items-center justify-between rounded-lg border border-border p-3"> <Label htmlFor="watch-folder-toggle" className="flex flex-col gap-0.5 cursor-pointer">
<Label htmlFor="watch-folder-toggle" className="flex flex-col gap-1 cursor-pointer">
<span className="text-sm font-medium">Watch folder</span> <span className="text-sm font-medium">Watch folder</span>
<span className="text-xs text-muted-foreground font-normal"> <span className="text-xs text-muted-foreground font-normal">
Automatically sync changes when files are added, edited, or removed Auto-sync when files change
</span> </span>
</Label> </Label>
<Switch <Switch
@ -497,150 +534,133 @@ export function DocumentUploadTab({
/> />
</div> </div>
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} /> <div className={toggleRowClass}>
<div className="space-y-0.5">
<p className="font-medium text-sm">Enable AI Summary</p>
<p className="text-xs text-muted-foreground">
Improves search quality but adds latency
</p>
</div>
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div>
<Button <Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium" className="w-full relative"
onClick={handleFolderSubmit} onClick={handleFolderSubmit}
disabled={folderSubmitting} disabled={folderSubmitting}
> >
{folderSubmitting ? ( <span className={folderSubmitting ? "invisible" : ""}>
<span className="flex items-center gap-2"> {watchFolder ? "Sync & Watch for Changes" : "Sync Folder"}
</span>
{folderSubmitting && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" /> <Spinner size="sm" />
Processing...
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{watchFolder ? "Watch & Index Folder" : "Index Folder"}
</span> </span>
)} )}
</Button> </Button>
</CardContent> </div>
</Card>
)}
{files.length > 0 && (
<Card className={cardClass}>
<CardHeader className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base sm:text-2xl">
{t("selected_files", { count: files.length })}
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{t("total_size")}: {formatFileSize(totalFileSize)}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="text-xs sm:text-sm shrink-0"
onClick={() => setFiles([])}
disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0">
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
{files.map((entry) => (
<div
key={entry.id}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(entry.file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{entry.file.type || "Unknown type"}
</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
disabled={isUploading}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{isUploading && (
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
<Separator className="bg-border" />
<div className="space-y-2">
<div className="flex items-center justify-between text-xs sm:text-sm">
<span>{t("uploading_files")}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
</div>
)}
<div className="mt-3 sm:mt-6">
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
</div>
<div className="mt-3 sm:mt-6">
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
</div>
</CardContent>
</Card>
)} )}
{/* FILES SELECTED */}
{files.length > 0 && (
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{t("selected_files", { count: files.length })} &middot; {formatFileSize(totalFileSize)}
</p>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setFiles([])}
disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
<div className="max-h-[160px] sm:max-h-[200px] overflow-y-auto -mx-1">
{files.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-slate-400/5 dark:hover:bg-white/5 group"
>
<FileType className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate flex-1 min-w-0">{entry.file.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(entry.file.size)}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
disabled={isUploading}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{isUploading && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span>{t("uploading_files")}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-1.5" />
</div>
)}
<div className={toggleRowClass}>
<div className="space-y-0.5">
<p className="font-medium text-sm">Enable AI Summary</p>
<p className="text-xs text-muted-foreground">
Improves search quality but adds latency
</p>
</div>
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div>
<Button
className="w-full"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
</div>
)}
{/* SUPPORTED FORMATS */}
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
value={accordionValue} value={accordionValue}
onValueChange={handleAccordionChange} onValueChange={handleAccordionChange}
className={`w-full ${cardClass} border border-border rounded-lg mb-0`} className="w-full"
> >
<AccordionItem value="supported-file-types" className="border-0"> <AccordionItem value="supported-file-types" className="border border-border rounded-lg">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0"> <AccordionTrigger className="px-3 py-2.5 hover:no-underline !items-center [&>svg]:!translate-y-0">
<div className="flex items-center gap-2 flex-1"> <span className="text-xs sm:text-sm text-muted-foreground font-normal">
<div className="text-left min-w-0"> {t("supported_file_types")}
<div className="font-semibold text-sm sm:text-base"> </span>
{t("supported_file_types")}
</div>
<div className="text-xs sm:text-sm text-muted-foreground font-normal">
{t("file_types_desc")}
</div>
</div>
</div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6"> <AccordionContent className="px-3 pb-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1">
{supportedExtensions.map((ext) => ( {supportedExtensions.map((ext) => (
<Badge key={ext} variant="outline" className="text-xs"> <Badge key={ext} variant="outline" className="text-[10px] px-1.5 py-0">
{ext} {ext}
</Badge> </Badge>
))} ))}

View file

@ -126,6 +126,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Microscope {...iconProps} />; return <Microscope {...iconProps} />;
case "DEEPEST": case "DEEPEST":
return <Telescope {...iconProps} />; return <Telescope {...iconProps} />;
case "LOCAL_FOLDER_FILE":
return null;
default: default:
return <Search {...iconProps} />; return <Search {...iconProps} />;
} }