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 651307488..9fea40394 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 {
@@ -20,7 +20,9 @@ import {
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';
@@ -36,6 +38,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,
@@ -95,6 +107,97 @@ const documentTypeIcons = {
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") => {
+ switch (type) {
+ case "ALL":
+ return ;
+ case "EXTENSION":
+ return ;
+ case "CRAWLED_URL":
+ return ;
+ case "FILE":
+ return ;
+ case "SLACK_CONNECTOR":
+ return ;
+ case "NOTION_CONNECTOR":
+ return ;
+ case "YOUTUBE_VIDEO":
+ return ;
+ case "GITHUB_CONNECTOR":
+ return ;
+ case "LINEAR_CONNECTOR":
+ return ;
+ case "DISCORD_CONNECTOR":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ 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 +430,70 @@ 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));
+ // Custom hook for debounced search
+ const useDebounce = (value: string, delay: number) => {
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedDocumentFilter(value);
+ setDocumentsPage(1); // Reset page when search changes
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedDocumentFilter;
+ };
+
+ // Use debounced search
+ useDebounce(documentFilter, 300);
+
+ // 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 +1039,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 +1233,7 @@ const ChatPage = () => {
- {/* Document Selection Dialog */}
+ {/* Enhanced Document Selection Dialog */}