diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 6cccdaa5b..81477ac6d 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -686,7 +686,7 @@ async def handle_new_chat( search_space = search_space_result.scalars().first() # TODO: Add new llm config arch then complete this - llm_config_id = -1 + llm_config_id = -4 # Return streaming response return StreamingResponse( @@ -698,6 +698,7 @@ async def handle_new_chat( llm_config_id=llm_config_id, messages=request.messages, attachments=request.attachments, + mentioned_document_ids=request.mentioned_document_ids, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index ffaf85554..78498cf04 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -160,3 +160,6 @@ class NewChatRequest(BaseModel): attachments: list[ChatAttachment] | None = ( None # Optional attachments with extracted content ) + mentioned_document_ids: list[int] | None = ( + None # Optional document IDs mentioned with @ in the chat + ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index de318a7d5..0f4ff26b8 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -9,6 +9,7 @@ import json from collections.abc import AsyncGenerator from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer @@ -16,6 +17,7 @@ from app.agents.new_chat.llm_config import ( create_chat_litellm_from_config, load_llm_config_from_yaml, ) +from app.db import Document from app.schemas.new_chat import ChatAttachment, ChatMessage from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -38,6 +40,27 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> str: return "\n".join(context_parts) +def format_mentioned_documents_as_context(documents: list[Document]) -> str: + """Format mentioned documents as context for the agent.""" + if not documents: + return "" + + context_parts = [""] + context_parts.append( + "The user has explicitly mentioned the following documents from their knowledge base. " + "These documents are directly relevant to the query and should be prioritized as primary sources." + ) + for i, doc in enumerate(documents, 1): + context_parts.append( + f"" + ) + context_parts.append(f"") + context_parts.append("") + context_parts.append("") + + return "\n".join(context_parts) + + async def stream_new_chat( user_query: str, search_space_id: int, @@ -46,6 +69,7 @@ async def stream_new_chat( llm_config_id: int = -1, messages: list[ChatMessage] | None = None, attachments: list[ChatAttachment] | None = None, + mentioned_document_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -61,6 +85,8 @@ async def stream_new_chat( session: The database session llm_config_id: The LLM configuration ID (default: -1 for first global config) messages: Optional chat history from frontend (list of ChatMessage) + attachments: Optional attachments with extracted content + mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat Yields: str: SSE formatted response strings @@ -105,13 +131,30 @@ async def stream_new_chat( # Build input with message history from frontend langchain_messages = [] - # Format the user query with attachment context if any - final_query = user_query - if attachments: - attachment_context = format_attachments_as_context(attachments) - final_query = ( - f"{attachment_context}\n\n{user_query}" + # Fetch mentioned documents if any + mentioned_documents: list[Document] = [] + if mentioned_document_ids: + result = await session.execute( + select(Document).filter( + Document.id.in_(mentioned_document_ids), + Document.search_space_id == search_space_id, + ) ) + mentioned_documents = list(result.scalars().all()) + + # Format the user query with context (attachments + mentioned documents) + final_query = user_query + context_parts = [] + + if attachments: + context_parts.append(format_attachments_as_context(attachments)) + + if mentioned_documents: + context_parts.append(format_mentioned_documents_as_context(mentioned_documents)) + + if context_parts: + context = "\n\n".join(context_parts) + final_query = f"{context}\n\n{user_query}" # if messages: # # Convert frontend messages to LangChain format diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9c7e3cb4a..339e1be3d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -6,9 +6,11 @@ import { type ThreadMessageLike, useExternalStoreRuntime, } from "@assistant-ui/react"; +import { useAtomValue, useSetAtom } from "jotai"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { mentionedDocumentIdsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { Thread } from "@/components/assistant-ui/thread"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; @@ -107,6 +109,10 @@ export default function NewChatPage() { >(new Map()); const abortControllerRef = useRef(null); + // Get mentioned document IDs from the composer + const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); + const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); + // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -372,6 +378,14 @@ export default function NewChatPage() { // Extract attachment content to send with the request const attachments = extractAttachmentContent(messageAttachments); + // Get mentioned document IDs for context + const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; + + // Clear mentioned documents after capturing them + if (mentionedDocumentIds.length > 0) { + setMentionedDocumentIds([]); + } + const response = await fetch(`${backendUrl}/api/v1/new_chat`, { method: "POST", headers: { @@ -384,6 +398,7 @@ export default function NewChatPage() { search_space_id: searchSpaceId, messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, + mentioned_document_ids: documentIds, }), signal: controller.signal, }); @@ -546,7 +561,7 @@ export default function NewChatPage() { // Note: We no longer clear thinking steps - they persist with the message } }, - [threadId, searchSpaceId, messages] + [threadId, searchSpaceId, messages, mentionedDocumentIds, setMentionedDocumentIds] ); // Convert message (pass through since already in correct format) diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts new file mode 100644 index 000000000..6fc0daf06 --- /dev/null +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -0,0 +1,10 @@ +"use client"; + +import { atom } from "jotai"; + +/** + * Atom to store the IDs of documents mentioned in the current chat composer. + * This is used to pass document context to the backend when sending a message. + */ +export const mentionedDocumentIdsAtom = atom([]); + diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index ba12e626e..29b2df22d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -7,7 +7,6 @@ import { MessagePrimitive, ThreadPrimitive, useAssistantState, - useMessage, } from "@assistant-ui/react"; import { ArrowDownIcon, @@ -27,18 +26,20 @@ import { Search, Sparkles, SquareIcon, + X, } from "lucide-react"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { type FC, useState, useRef, useCallback, useEffect } from "react"; -import { useAtomValue } from "jotai"; +import { type FC, useState, useRef, useCallback, useEffect, createContext, useContext, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useAtomValue, useSetAtom } from "jotai"; +import { mentionedDocumentIdsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { useRef, useState } from "react"; import { ComposerAddAttachment, ComposerAttachments, @@ -69,8 +70,6 @@ interface ThreadProps { } // Context to pass thinking steps to AssistantMessage -import { createContext, useContext } from "react"; - const ThinkingStepsContext = createContext>(new Map()); /** @@ -333,12 +332,15 @@ const getTimeBasedGreeting = (userEmail?: string): string => { const ThreadWelcome: FC = () => { const { data: user } = useAtomValue(currentUserAtom); + // Memoize greeting so it doesn't change on re-renders (only on user change) + const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]); + return (
{/* Greeting positioned above the composer - fixed position */}

- {getTimeBasedGreeting(user?.email)} + {greeting}

{/* Composer - top edge fixed, expands downward only */} @@ -351,122 +353,155 @@ const ThreadWelcome: FC = () => { const Composer: FC = () => { // ---- State for document mentions ---- - const [allSelectedDocuments, setAllSelectedDocuments] = useState([]); const [mentionedDocuments, setMentionedDocuments] = useState([]); const [showDocumentPopover, setShowDocumentPopover] = useState(false); - const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); const { search_space_id } = useParams(); + const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); - const handleInputOrKeyUp = ( - e: React.FormEvent | React.KeyboardEvent - ) => { + // Sync mentioned document IDs to atom for use in chat request + useEffect(() => { + setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + }, [mentionedDocuments, setMentionedDocumentIds]); + + const handleKeyUp = (e: React.KeyboardEvent) => { const textarea = e.currentTarget; const value = textarea.value; - setInputValue(value); - // Regex: finds all [title] occurrences - const mentionRegex = /\[([^\]]+)\]/g; - const titlesMentioned: string[] = []; - let match; - while ((match = mentionRegex.exec(value)) !== null) { - titlesMentioned.push(match[1]); + // Open document picker when user types '@' + if (e.key === "@" || (e.key === "2" && e.shiftKey)) { + setShowDocumentPopover(true); } - // Use allSelectedDocuments to filter down for current chips - setMentionedDocuments( - allSelectedDocuments.filter((doc) => titlesMentioned.includes(doc.title)) - ); - - const selectionStart = textarea.selectionStart; - // Only open if the last character before the caret is exactly '@' - if ( - selectionStart !== null && - value[selectionStart - 1] === "@" && - value.length === selectionStart - ) { - setShowDocumentPopover(true); - } else { + // Close popover if '@' is no longer in the input (user deleted it) + if (showDocumentPopover && !value.includes("@")) { setShowDocumentPopover(false); } }; - const handleDocumentsMention = (documents: Document[]) => { - // Add newly selected docs to allSelectedDocuments - setAllSelectedDocuments((prev) => { - const toAdd = documents.filter((doc) => !prev.find((p) => p.id === doc.id)); - return [...prev, ...toAdd]; - }); - let newValue = inputValue; - documents.forEach((doc) => { - const refString = `[${doc.title}]`; - if (!newValue.includes(refString)) { - if (newValue.trim() !== "" && !newValue.endsWith(" ")) { - newValue += " "; - } - newValue += refString; - } - }); - setInputValue(newValue); - // Run the chip update as well right after change - const mentionRegex = /\[([^\]]+)\]/g; - const titlesMentioned: string[] = []; - let match; - while ((match = mentionRegex.exec(newValue)) !== null) { - titlesMentioned.push(match[1]); + const handleKeyDown = (e: React.KeyboardEvent) => { + // Close popover on Escape + if (e.key === "Escape" && showDocumentPopover) { + e.preventDefault(); + setShowDocumentPopover(false); + return; } - setMentionedDocuments( - allSelectedDocuments.filter((doc) => titlesMentioned.includes(doc.title)) - ); + + // Remove last document chip when pressing backspace at the beginning of input + if (e.key === "Backspace" && mentionedDocuments.length > 0) { + const textarea = e.currentTarget; + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + + // Only remove chip if cursor is at position 0 and nothing is selected + if (selectionStart === 0 && selectionEnd === 0) { + e.preventDefault(); + // Remove the last document chip + setMentionedDocuments((prev) => prev.slice(0, -1)); + } + } + }; + + const handleDocumentsMention = (documents: Document[]) => { + // Update mentioned documents (merge with existing, avoid duplicates) + setMentionedDocuments((prev) => { + const existingIds = new Set(prev.map((d) => d.id)); + const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + return [...prev, ...newDocs]; + }); + + // Clean up the '@' trigger from input if present + if (inputRef.current) { + const input = inputRef.current; + const currentValue = input.value; + // Remove trailing @ if it exists + if (currentValue.endsWith("@")) { + // Use a native input event to properly update the controlled component + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value" + )?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, currentValue.slice(0, -1)); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + } + // Focus the input so user can continue typing + input.focus(); + } + }; + + const handleRemoveDocument = (docId: number) => { + setMentionedDocuments((prev) => prev.filter((doc) => doc.id !== docId)); }; return ( - {/* -------- Input field w/ refs and handlers -------- */} - + {/* -------- Input field with inline document chips -------- */} +
+ {/* Inline document chips */} + {mentionedDocuments.map((doc) => ( + + {doc.title} + + + ))} + {/* Text input */} + 0 ? "Ask about these documents..." : "Ask SurfSense (type @ to mention docs)"} + className="aui-composer-input flex-1 min-w-[120px] max-h-32 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0 py-1" + rows={1} + autoFocus + aria-label="Message input" + /> +
- {/* -------- Document mention popover (simple version) -------- */} - {showDocumentPopover && ( -
-
+ {/* -------- Document mention popover (rendered via portal) -------- */} + {showDocumentPopover && typeof document !== "undefined" && createPortal( + <> + {/* Backdrop */} +
- )} - {/* ---- Mention chips for selected/mentioned documents ---- */} - {mentionedDocuments.length > 0 && ( -
- {mentionedDocuments.map((doc) => ( - - {doc.title} - - ))} -
+ , + document.body )} @@ -564,7 +599,7 @@ const ConnectorIndicator: FC = () => {
{/* Document types from the search space */} - {activeDocumentTypes.map(([docType, count]) => ( + {activeDocumentTypes.map(([docType]) => (
{ const thinkingStepsMap = useContext(ThinkingStepsContext); // Get the current message ID to look up thinking steps - const messageId = useMessage((m) => m.id); + const messageId = useAssistantState(({ message }) => message?.id); const thinkingSteps = thinkingStepsMap.get(messageId) || []; // Check if thread is still running (for stopping the spinner when cancelled) diff --git a/surfsense_web/components/new-chat/DocumentsDataTable.tsx b/surfsense_web/components/new-chat/DocumentsDataTable.tsx index 290417e53..d97096317 100644 --- a/surfsense_web/components/new-chat/DocumentsDataTable.tsx +++ b/surfsense_web/components/new-chat/DocumentsDataTable.tsx @@ -1,49 +1,21 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { - type ColumnDef, - flexRender, - getCoreRowModel, - type SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { useAtomValue } from "jotai"; -import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { FileText, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import type { Document, DocumentTypeEnum } from "@/contracts/types/document.types"; +import type { Document } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { cn } from "@/lib/utils"; interface DocumentsDataTableProps { searchSpaceId: number; onSelectionChange: (documents: Document[]) => void; onDone: () => void; initialSelectedDocuments?: Document[]; - viewOnly?: boolean; } function useDebounced(value: T, delay = 300) { @@ -55,551 +27,190 @@ function useDebounced(value: T, delay = 300) { return debounced; } -const columns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - size: 40, - }, - { - accessorKey: "title", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const title = row.getValue("title") as string; - return ( -
- {title} -
- ); - }, - }, - { - accessorKey: "document_type", - header: "Type", - cell: ({ row }) => { - const type = row.getValue("document_type") as DocumentType; - return ( -
- {getConnectorIcon(String(type))} -
- ); - }, - size: 80, - meta: { - className: "hidden sm:table-cell", - }, - }, - { - accessorKey: "content", - header: "Preview", - cell: ({ row }) => { - const content = row.getValue("content") as string; - return ( -
- {content.substring(0, 30)}... - {content.substring(0, 100)}... -
- ); - }, - enableSorting: false, - meta: { - className: "hidden md:table-cell", - }, - }, - { - accessorKey: "created_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const date = new Date(row.getValue("created_at")); - return ( -
- - {date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - - {date.toLocaleDateString("en-US", { - month: "numeric", - day: "numeric", - })} - -
- ); - }, - size: 80, - }, -]; - export function DocumentsDataTable({ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], }: DocumentsDataTableProps) { - const router = useRouter(); - const [sorting, setSorting] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 300); - const [documentTypeFilter, setDocumentTypeFilter] = useState([]); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const listRef = useRef(null); + const itemRefs = useRef>(new Map()); const fetchQueryParams = useMemo( () => ({ search_space_id: searchSpaceId, - page: pageIndex, - page_size: pageSize, - ...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }), + page: 0, + page_size: 20, }), - [searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch] + [searchSpaceId] ); const searchQueryParams = useMemo(() => { return { search_space_id: searchSpaceId, - page: pageIndex, - page_size: pageSize, - ...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }), + page: 0, + page_size: 20, title: debouncedSearch, }; - }, [debouncedSearch, searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]); + }, [debouncedSearch, searchSpaceId]); // Use query for fetching documents const { data: documents, isLoading: isDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }), - staleTime: 3 * 60 * 1000, // 3 minutes + staleTime: 3 * 60 * 1000, enabled: !!searchSpaceId && !debouncedSearch.trim(), }); - // Seaching + // Searching const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(searchQueryParams), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), - staleTime: 3 * 60 * 1000, // 3 minutes + staleTime: 3 * 60 * 1000, enabled: !!searchSpaceId && !!debouncedSearch.trim(), }); - // Use query data when not searching, otherwise use hook data const actualDocuments = debouncedSearch.trim() ? searchedDocuments?.items || [] : documents?.items || []; - const actualTotal = debouncedSearch.trim() - ? searchedDocuments?.total || 0 - : documents?.total || 0; const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading; - // Memoize initial row selection to prevent infinite loops - const initialRowSelection = useMemo(() => { - if (!initialSelectedDocuments.length) return {}; - - const selection: Record = {}; - initialSelectedDocuments.forEach((selectedDoc) => { - selection[selectedDoc.id] = true; - }); - return selection; - }, [initialSelectedDocuments]); - - const [rowSelection, setRowSelection] = useState>( - () => initialRowSelection + // Track already selected document IDs + const selectedIds = useMemo( + () => new Set(initialSelectedDocuments.map((d) => d.id)), + [initialSelectedDocuments] ); - // Maintain a separate state for actually selected documents (across all pages) - const [selectedDocumentsMap, setSelectedDocumentsMap] = useState>(() => { - const map = new Map(); - initialSelectedDocuments.forEach((doc) => map.set(doc.id, doc)); - return map; - }); + // Filter out already selected documents for navigation + const selectableDocuments = useMemo( + () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), + [actualDocuments, selectedIds] + ); - // Track the last notified selection to avoid redundant parent calls - const lastNotifiedSelection = useRef(""); + const handleSelectDocument = useCallback((doc: Document) => { + onSelectionChange([...initialSelectedDocuments, doc]); + onDone(); + }, [initialSelectedDocuments, onSelectionChange, onDone]); - // Update row selection only when initialSelectedDocuments changes (not rowSelection itself) + // Scroll highlighted item into view useEffect(() => { - const initialKeys = Object.keys(initialRowSelection); - if (initialKeys.length === 0) return; - - const currentKeys = Object.keys(rowSelection); - // Quick length check before expensive comparison - if (currentKeys.length === initialKeys.length) { - // Check if all keys match (order doesn't matter for Sets) - const hasAllKeys = initialKeys.every((key) => rowSelection[key]); - if (hasAllKeys) return; + const item = itemRefs.current.get(highlightedIndex); + if (item) { + item.scrollIntoView({ block: "nearest", behavior: "smooth" }); } + }, [highlightedIndex]); - setRowSelection(initialRowSelection); - }, [initialRowSelection]); // Remove rowSelection from dependencies to prevent loop + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (selectableDocuments.length === 0) return; - // Update the selected documents map when row selection changes - useEffect(() => { - if (!actualDocuments || actualDocuments.length === 0) return; - - setSelectedDocumentsMap((prev) => { - const newMap = new Map(prev); - let hasChanges = false; - - // Process only current page documents - for (const doc of actualDocuments) { - const docId = doc.id; - const isSelected = rowSelection[docId.toString()]; - const wasInMap = newMap.has(docId); - - if (isSelected && !wasInMap) { - newMap.set(docId, doc); - hasChanges = true; - } else if (!isSelected && wasInMap) { - newMap.delete(docId); - hasChanges = true; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => + prev < selectableDocuments.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : selectableDocuments.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (selectableDocuments[highlightedIndex]) { + handleSelectDocument(selectableDocuments[highlightedIndex]); } - } - - // Return same reference if no changes to avoid unnecessary re-renders - return hasChanges ? newMap : prev; - }); - }, [rowSelection, documents]); - - // Memoize selected documents array - const selectedDocumentsArray = useMemo(() => { - return Array.from(selectedDocumentsMap.values()); - }, [selectedDocumentsMap]); - - // Notify parent of selection changes only when content actually changes - useEffect(() => { - // Create a stable string representation for comparison - const selectionKey = selectedDocumentsArray - .map((d) => d.id) - .sort() - .join(","); - - // Skip if selection hasn't actually changed - if (selectionKey === lastNotifiedSelection.current) return; - - lastNotifiedSelection.current = selectionKey; - onSelectionChange(selectedDocumentsArray); - }, [selectedDocumentsArray, onSelectionChange]); - - const table = useReactTable({ - data: actualDocuments || [], - columns, - getRowId: (row) => row.id.toString(), - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection, - manualPagination: true, - pageCount: Math.ceil(actualTotal / pageSize), - state: { sorting, rowSelection, pagination: { pageIndex, pageSize } }, - }); - - const handleClearAll = useCallback(() => { - setRowSelection({}); - setSelectedDocumentsMap(new Map()); - }, []); - - const handleSelectPage = useCallback(() => { - const currentPageRows = table.getRowModel().rows; - const newSelection = { ...rowSelection }; - currentPageRows.forEach((row) => { - newSelection[row.id] = true; - }); - setRowSelection(newSelection); - }, [table, rowSelection]); - - const handleToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => { - setDocumentTypeFilter((prev) => { - if (checked) { - return [...prev, type]; - } - return prev.filter((t) => t !== type); - }); - setPageIndex(0); // Reset to first page when filter changes - }, []); - - const selectedCount = selectedDocumentsMap.size; - - // Get available document types from type counts (memoized) - const availableTypes = useMemo(() => { - const types = typeCounts ? (Object.keys(typeCounts) as DocumentTypeEnum[]) : []; - return types.length > 0 ? types.sort() : []; - }, [typeCounts]); + break; + case "Escape": + e.preventDefault(); + onDone(); + break; + } + }, [selectableDocuments, highlightedIndex, handleSelectDocument, onDone]); return ( -
- {/* Header Controls */} -
- {/* Search and Filter Row */} -
-
- - { - setSearch(event.target.value); - setPageIndex(0); // Reset to first page on search - }} - className="pl-10 text-sm" - /> +
+ {/* Search */} +
+ + { + setSearch(e.target.value); + setHighlightedIndex(0); + }} + className="pl-8 h-8 text-sm border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + autoFocus + /> +
+ + {/* Document List */} + + {actualLoading ? ( +
+
- - - - - -
-
Filter by Type
-
- {availableTypes.map((type) => ( -
- handleToggleType(type, !!checked)} - /> - -
- ))} -
- {documentTypeFilter.length > 0 && ( - - )} -
-
-
-
- - {/* Action Controls Row */} -
-
- - {selectedCount} selected {actualLoading && "ยท Loading..."} - -
-
- - - -
+ {/* Title */} + + {doc.title} + + + ); + })}
- -
-
- - {/* Table Container */} -
-
- {actualLoading ? ( -
-
-
-

Loading documents...

-
-
- ) : ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-
- -
-
-

No documents found

-

- Get started by adding your first data source to build your knowledge - base. -

-
- -
-
-
- )} -
-
- )} -
-
- - {/* Footer Pagination */} -
-
- Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, actualTotal)}{" "} - of {actualTotal} documents -
-
- -
- Page - {pageIndex + 1} - of - {Math.ceil(actualTotal / pageSize)} -
- -
-
+ )} +
); }