diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 5b49212dc..d9e269794 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Lock, Share2, Users } from "lucide-react"; +import { Loader2, Lock, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 66988adcc..81c7b9a33 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { FileText } from "lucide-react"; import { forwardRef, @@ -32,36 +32,27 @@ interface DocumentMentionPickerProps { const PAGE_SIZE = 20; const MIN_SEARCH_LENGTH = 2; -const THROTTLE_MS = 200; +const DEBOUNCE_MS = 300; /** - * Throttle hook - fires immediately, then at most once per interval - * Better than debounce for typeahead: user sees results updating as they type + * Debounce hook - waits until user stops typing before firing + * Better than throttle for search: reduces request spam and prevents race conditions */ -function useThrottled(value: T, delay = THROTTLE_MS) { - const [throttled, setThrottled] = useState(value); - const lastExecuted = useRef(Date.now()); - const timeoutRef = useRef>(); +function useDebounced(value: T, delay = DEBOUNCE_MS) { + const [debounced, setDebounced] = useState(value); + const timeoutRef = useRef | undefined>(undefined); useEffect(() => { - const now = Date.now(); - const elapsed = now - lastExecuted.current; - - if (elapsed >= delay) { - // Enough time has passed, update immediately - lastExecuted.current = now; - setThrottled(value); - } else { - // Schedule update for remaining time - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - lastExecuted.current = Date.now(); - setThrottled(value); - }, delay - elapsed); + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } + // Set new timeout - only fires after user stops typing for `delay` ms + timeoutRef.current = setTimeout(() => { + setDebounced(value); + }, delay); + return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -69,7 +60,7 @@ function useThrottled(value: T, delay = THROTTLE_MS) { }; }, [value, delay]); - return throttled; + return debounced; } export const DocumentMentionPicker = forwardRef< @@ -81,9 +72,10 @@ export const DocumentMentionPicker = forwardRef< ) { const queryClient = useQueryClient(); - // Use external search with throttle (not debounce) for responsive feel + // Use external search with debounce - waits until user stops typing + // Reduces request spam and prevents race conditions with stale results const search = externalSearch; - const throttledSearch = useThrottled(search, THROTTLE_MS); + const debouncedSearch = useDebounced(search, DEBOUNCE_MS); const [highlightedIndex, setHighlightedIndex] = useState(0); const itemRefs = useRef>(new Map()); const scrollContainerRef = useRef(null); @@ -97,8 +89,8 @@ export const DocumentMentionPicker = forwardRef< const [isLoadingMore, setIsLoadingMore] = useState(false); // Check if search is long enough - const isSearchValid = throttledSearch.trim().length >= MIN_SEARCH_LENGTH; - const shouldSearch = throttledSearch.trim().length > 0; + const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH; + const shouldSearch = debouncedSearch.trim().length > 0; // Prefetch first page when picker mounts - results appear instantly useEffect(() => { @@ -129,13 +121,15 @@ export const DocumentMentionPicker = forwardRef< }, [searchSpaceId, queryClient]); // Reset pagination when search or search space changes + // Don't clear accumulatedDocuments - let new data replace it smoothly (prevents "No documents found" flash) // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes useEffect(() => { - setAccumulatedDocuments([]); + // Keep previous documents visible while new query is fetching (smooth UX) + // setAccumulatedDocuments([]); // Removed to prevent flash of "No documents found" setCurrentPage(0); setHasMore(false); setHighlightedIndex(0); - }, [throttledSearch, searchSpaceId]); + }, [debouncedSearch, searchSpaceId]); // Query params for lightweight title search const titleSearchParams = useMemo( @@ -143,9 +137,9 @@ export const DocumentMentionPicker = forwardRef< search_space_id: searchSpaceId, page: 0, page_size: PAGE_SIZE, - ...(isSearchValid ? { title: throttledSearch.trim() } : {}), + ...(isSearchValid ? { title: debouncedSearch.trim() } : {}), }), - [searchSpaceId, throttledSearch, isSearchValid] + [searchSpaceId, debouncedSearch, isSearchValid] ); const surfsenseDocsQueryParams = useMemo(() => { @@ -154,25 +148,33 @@ export const DocumentMentionPicker = forwardRef< page_size: PAGE_SIZE, }; if (isSearchValid) { - params.title = throttledSearch.trim(); + params.title = debouncedSearch.trim(); } return params; - }, [throttledSearch, isSearchValid]); + }, [debouncedSearch, isSearchValid]); // Use the new lightweight endpoint for document title search + // TanStack Query provides signal for automatic request cancellation + // keepPreviousData: shows old results while fetching new ones (no spinner flicker) const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({ queryKey: ["document-titles", titleSearchParams], - queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }), + queryFn: ({ signal }) => + documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal), staleTime: 60 * 1000, // 1 minute - shorter for fresher results enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid), + placeholderData: keepPreviousData, }); // Use query for fetching first page of SurfSense docs + // TanStack Query provides signal for automatic request cancellation + // keepPreviousData: shows old results while fetching new ones (no spinner flicker) const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({ - queryKey: ["surfsense-docs-mention", throttledSearch, isSearchValid], - queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }), + queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid], + queryFn: ({ signal }) => + documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal), staleTime: 3 * 60 * 1000, enabled: !shouldSearch || isSearchValid, + placeholderData: keepPreviousData, }); // Update accumulated documents when first page loads - combine both sources @@ -213,7 +215,7 @@ export const DocumentMentionPicker = forwardRef< search_space_id: searchSpaceId, page: nextPage, page_size: PAGE_SIZE, - ...(isSearchValid ? { title: throttledSearch.trim() } : {}), + ...(isSearchValid ? { title: debouncedSearch.trim() } : {}), }; const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({ queryParams }); @@ -226,7 +228,7 @@ export const DocumentMentionPicker = forwardRef< } finally { setIsLoadingMore(false); } - }, [currentPage, hasMore, isLoadingMore, throttledSearch, searchSpaceId, isSearchValid]); + }, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]); // Infinite scroll handler const handleScroll = useCallback( @@ -359,8 +361,8 @@ export const DocumentMentionPicker = forwardRef< {showSearchHint ? (

- Type {MIN_SEARCH_LENGTH - throttledSearch.trim().length} more character - {MIN_SEARCH_LENGTH - throttledSearch.trim().length > 1 ? "s" : ""} to search + Type {MIN_SEARCH_LENGTH - debouncedSearch.trim().length} more character + {MIN_SEARCH_LENGTH - debouncedSearch.trim().length > 1 ? "s" : ""} to search

) : actualLoading ? ( @@ -369,7 +371,7 @@ export const DocumentMentionPicker = forwardRef< ) : actualDocuments.length === 0 ? (
- +

No documents found

) : ( diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index daa67f6d5..03d86a253 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -166,8 +166,10 @@ class DocumentsApiService { /** * Search document titles (lightweight, optimized for mention picker) * Returns only id, title, document_type - no content or metadata + * @param request - The search request with query params + * @param signal - Optional AbortSignal for request cancellation */ - searchDocumentTitles = async (request: SearchDocumentTitlesRequest) => { + searchDocumentTitles = async (request: SearchDocumentTitlesRequest, signal?: AbortSignal) => { const parsedRequest = searchDocumentTitlesRequest.safeParse(request); if (!parsedRequest.success) { @@ -188,7 +190,8 @@ class DocumentsApiService { return baseApiService.get( `/api/v1/documents/search/titles?${queryParams}`, - searchDocumentTitlesResponse + searchDocumentTitlesResponse, + { signal } ); }; @@ -258,8 +261,10 @@ class DocumentsApiService { /** * List all Surfsense documentation documents + * @param request - The request with query params + * @param signal - Optional AbortSignal for request cancellation */ - getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => { + getSurfsenseDocs = async (request: GetSurfsenseDocsRequest, signal?: AbortSignal) => { const parsedRequest = getSurfsenseDocsRequest.safeParse(request); if (!parsedRequest.success) { @@ -282,7 +287,7 @@ class DocumentsApiService { const url = `/api/v1/surfsense-docs?${queryParams}`; - return baseApiService.get(url, getSurfsenseDocsResponse); + return baseApiService.get(url, getSurfsenseDocsResponse, { signal }); }; /**