mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-10 20:35:17 +02:00
refactor: update document mention picker and chat share button for improved performance and UX
- Replaced throttling with debouncing in DocumentMentionPicker to reduce request spam and enhance user experience. - Updated API service methods to support request cancellation using AbortSignal. - Simplified imports in ChatShareButton by removing unused components.
This commit is contained in:
parent
293de6876a
commit
720c13667e
3 changed files with 55 additions and 48 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<T>(value: T, delay = THROTTLE_MS) {
|
||||
const [throttled, setThrottled] = useState(value);
|
||||
const lastExecuted = useRef(Date.now());
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<T>(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<Map<number, HTMLButtonElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
) : actualLoading ? (
|
||||
|
|
@ -369,7 +371,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
</div>
|
||||
) : actualDocuments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
||||
<FileText className="h-5 w-5 text-muted-foreground/55 mb-1" />
|
||||
<FileText className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<p className="text-sm text-muted-foreground">No documents found</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue