diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx index 6513074..a9ebd9c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'; import { useChat } from '@ai-sdk/react'; import { useParams } from 'next/navigation'; import { @@ -16,13 +16,11 @@ import { SendHorizontal, FileText, Grid3x3, - File, - Globe, - Webhook, FolderOpen, - Upload + Upload, + ChevronDown, + Filter } from 'lucide-react'; -import { IconBrandDiscord, IconBrandGithub, IconBrandNotion, IconBrandSlack, IconBrandYoutube, IconLayoutKanban } from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -36,6 +34,16 @@ import { DialogTrigger, DialogFooter } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { ConnectorButton as ConnectorButtonComponent, getConnectorIcon, @@ -72,28 +80,75 @@ interface ConnectorSource { type DocumentType = "EXTENSION" | "CRAWLED_URL" | "SLACK_CONNECTOR" | "NOTION_CONNECTOR" | "FILE" | "YOUTUBE_VIDEO" | "GITHUB_CONNECTOR" | "LINEAR_CONNECTOR" | "DISCORD_CONNECTOR"; -interface Document { - id: number; - title: string; - document_type: DocumentType; - document_metadata: any; - content: string; - created_at: string; - search_space_id: number; -} -// Document type icons mapping -const documentTypeIcons = { - EXTENSION: Webhook, - CRAWLED_URL: Globe, - SLACK_CONNECTOR: IconBrandSlack, - NOTION_CONNECTOR: IconBrandNotion, - FILE: File, - YOUTUBE_VIDEO: IconBrandYoutube, - GITHUB_CONNECTOR: IconBrandGithub, - LINEAR_CONNECTOR: IconLayoutKanban, - DISCORD_CONNECTOR: IconBrandDiscord, -} as const; +/** + * Skeleton loader for document items + */ +const DocumentSkeleton = () => ( +
+ +
+ + + +
+ +
+); + +/** + * Enhanced document type filter dropdown + */ +const DocumentTypeFilter = ({ + value, + onChange, + counts +}: { + value: DocumentType | "ALL"; + onChange: (value: DocumentType | "ALL") => void; + counts: Record; +}) => { + const getTypeLabel = (type: DocumentType | "ALL") => { + if (type === "ALL") return "All Types"; + return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); + }; + + const getTypeIcon = (type: DocumentType | "ALL") => { + if (type === "ALL") return ; + return getConnectorIcon(type); + }; + + return ( + + + + + + Document Types + + {Object.entries(counts).map(([type, count]) => ( + onChange(type as DocumentType | "ALL")} + className="flex items-center justify-between" + > +
+ {getTypeIcon(type as DocumentType | "ALL")} + {getTypeLabel(type as DocumentType | "ALL")} +
+ + {count} + +
+ ))} +
+
+ ); +}; /** * Button that displays selected connectors and opens connector selection dialog @@ -327,8 +382,63 @@ const ChatPage = () => { // Document selection state const [selectedDocuments, setSelectedDocuments] = useState([]); const [documentFilter, setDocumentFilter] = useState(""); + const [debouncedDocumentFilter, setDebouncedDocumentFilter] = useState(""); + const [documentTypeFilter, setDocumentTypeFilter] = useState("ALL"); + const [documentsPage, setDocumentsPage] = useState(1); + const [documentsPerPage] = useState(10); const { documents, loading: isLoadingDocuments, error: documentsError } = useDocuments(Number(search_space_id)); + // Debounced search effect (proper implementation) + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedDocumentFilter(documentFilter); + setDocumentsPage(1); // Reset page when search changes + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [documentFilter]); + + // Memoized filtered and paginated documents + const filteredDocuments = useMemo(() => { + if (!documents) return []; + + return documents.filter(doc => { + const matchesSearch = doc.title.toLowerCase().includes(debouncedDocumentFilter.toLowerCase()) || + doc.content.toLowerCase().includes(debouncedDocumentFilter.toLowerCase()); + const matchesType = documentTypeFilter === "ALL" || doc.document_type === documentTypeFilter; + return matchesSearch && matchesType; + }); + }, [documents, debouncedDocumentFilter, documentTypeFilter]); + + const paginatedDocuments = useMemo(() => { + const startIndex = (documentsPage - 1) * documentsPerPage; + return filteredDocuments.slice(startIndex, startIndex + documentsPerPage); + }, [filteredDocuments, documentsPage, documentsPerPage]); + + const totalPages = Math.ceil(filteredDocuments.length / documentsPerPage); + + // Document type counts for filter dropdown + const documentTypeCounts = useMemo(() => { + if (!documents) return {}; + + const counts: Record = { ALL: documents.length }; + documents.forEach(doc => { + counts[doc.document_type] = (counts[doc.document_type] || 0) + 1; + }); + return counts; + }, [documents]); + + // Callback to handle document selection + const handleDocumentToggle = useCallback((documentId: number) => { + setSelectedDocuments(prev => + prev.includes(documentId) + ? prev.filter(id => id !== documentId) + : [...prev, documentId] + ); + }, []); + // Function to scroll terminal to bottom const scrollTerminalToBottom = () => { if (terminalMessagesRef.current) { @@ -874,7 +984,7 @@ const ChatPage = () => { // Use these message-specific sources for the Tabs component return ( 0 ? messageConnectorSources[0].type : "CRAWLED_URL"} + defaultValue={messageConnectorSources.length > 0 ? messageConnectorSources[0].type : undefined} className="w-full" >
@@ -1068,7 +1178,7 @@ const ChatPage = () => {
- {/* Document Selection Dialog */} + {/* Enhanced Document Selection Dialog */} { documentsCount={documents?.length || 0} /> - - + + - Select Documents +
+ + Select Documents + + {selectedDocuments.length} selected + +
- Choose documents to include in your research context + Choose documents to include in your research context. Use filters and search to find specific documents.
- {/* Document Search */} -
- - setDocumentFilter(e.target.value)} - /> - {documentFilter && ( - - )} -
- - {/* Document List */} -
- {isLoadingDocuments ? ( -
- + {/* Enhanced Search and Filter Controls */} +
+
+ {/* Search Input */} +
+ + setDocumentFilter(e.target.value)} + /> + {documentFilter && ( + + )}
- ) : documentsError ? ( -
-

Error loading documents

-
- ) : ( - (() => { - const filteredDocuments = documents?.filter(doc => - doc.title.toLowerCase().includes(documentFilter.toLowerCase()) - ) || []; - if (filteredDocuments.length === 0) { - return ( -
- -

{documentFilter ? `No documents found matching "${documentFilter}"` : 'No documents available'}

-
- ); - } - - return filteredDocuments.map((document) => { - const Icon = documentTypeIcons[document.document_type]; - const isSelected = selectedDocuments.includes(document.id); - - return ( -
{ - setSelectedDocuments(prev => - isSelected - ? prev.filter(id => id !== document.id) - : [...prev, document.id] - ); - }} - > -
- -
-
-

{document.title}

-

- {document.document_type.replace(/_/g, ' ').toLowerCase()} - {' • '} - {new Date(document.created_at).toLocaleDateString()} -

-

- {document.content.substring(0, 150)}... -

-
- {isSelected && ( -
- -
- )} -
- ); - }); - })() - )} -
- - -
- {selectedDocuments.length} document{selectedDocuments.length !== 1 ? 's' : ''} selected + {/* Document Type Filter */} + { + setDocumentTypeFilter(newType); + setDocumentsPage(1); // Reset to page 1 when filter changes + }} + counts={documentTypeCounts} + />
-
+ + {/* Results Summary */} +
+ + {isLoadingDocuments ? ( + "Loading documents..." + ) : ( + `Showing ${paginatedDocuments.length} of ${filteredDocuments.length} documents` + )} + + {filteredDocuments.length > 0 && ( + + Page {documentsPage} of {totalPages} + + )} +
+
+ + {/* Document List with Proper Scrolling */} +
+
+ {isLoadingDocuments ? ( + // Enhanced skeleton loading + Array.from({ length: 6 }, (_, i) => ( + + )) + ) : documentsError ? ( +
+
+ +
+

Error loading documents

+

Please try refreshing the page

+
+ ) : filteredDocuments.length === 0 ? ( +
+
+ +
+

No documents found

+

+ {documentFilter || documentTypeFilter !== "ALL" + ? "Try adjusting your search or filters" + : "Upload documents to get started"} +

+ {(!documentFilter && documentTypeFilter === "ALL") && ( + + )} +
+ ) : ( + // Enhanced document list + paginatedDocuments.map((document) => { + const isSelected = selectedDocuments.includes(document.id); + const typeLabel = document.document_type.replace(/_/g, ' ').toLowerCase(); + + return ( +
handleDocumentToggle(document.id)} + > +
+
+ {getConnectorIcon(document.document_type)} +
+
+
+
+

+ {document.title} +

+ {isSelected && ( +
+
+ +
+
+ )} +
+
+ + {typeLabel} + + + {new Date(document.created_at).toLocaleDateString()} + +
+

+ {document.content.substring(0, 200)}... +

+
+
+ ); + }) + )} +
+
+ + {/* Enhanced Pagination Controls */} + {totalPages > 1 && ( +
+
+ +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const page = documentsPage <= 3 ? i + 1 : documentsPage - 2 + i; + if (page > totalPages) return null; + return ( + + ); + })} + {totalPages > 5 && documentsPage < totalPages - 2 && ( + <> + ... + + + )} +
+ +
+
+ )} + + {/* Enhanced Footer */} + +
+ + {selectedDocuments.length} of {filteredDocuments.length} document{selectedDocuments.length !== 1 ? 's' : ''} selected + +
+
+
diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/page.tsx index cde5981..5e18082 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/researcher/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/page.tsx @@ -31,7 +31,7 @@ const ResearcherPage = () => { body: JSON.stringify({ type: "QNA", title: "Untitled Chat", // Empty title initially - initial_connectors: ["CRAWLED_URL"], // Default connector + initial_connectors: [], // No default connectors messages: [], search_space_id: Number(search_space_id) })