feat: enhance document mention editor and picker for better user experience

- Added document type icons in InlineMentionEditor for improved visual context.
- Updated DocumentMentionPicker to include client-side filtering, reducing false positives in search results.
- Enhanced loading state management by incorporating fetching indicators for better UX during data retrieval.
This commit is contained in:
Anish Sarkar 2026-01-17 22:25:40 +05:30
parent 720c13667e
commit 0b5568d7ab
2 changed files with 35 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import {
} from "react";
import ReactDOMServer from "react-dom/server";
import type { Document } from "@/contracts/types/document.types";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
export interface MentionedDocument {
@ -166,12 +167,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false";
chip.className =
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
chip.style.userSelect = "none";
chip.style.verticalAlign = "baseline";
// Add document type icon
const iconSpan = document.createElement("span");
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
iconSpan.innerHTML = ReactDOMServer.renderToString(
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
);
const titleSpan = document.createElement("span");
titleSpan.className = "max-w-[80px] truncate";
titleSpan.className = "max-w-[120px] truncate";
titleSpan.textContent = doc.title;
titleSpan.title = doc.title;
@ -197,6 +205,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
focusAtEnd();
};
chip.appendChild(iconSpan);
chip.appendChild(titleSpan);
chip.appendChild(removeBtn);

View file

@ -156,7 +156,7 @@ export const DocumentMentionPicker = forwardRef<
// 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({
const { data: titleSearchResults, isLoading: isTitleSearchLoading, isFetching: isTitleSearchFetching } = useQuery({
queryKey: ["document-titles", titleSearchParams],
queryFn: ({ signal }) =>
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
@ -168,7 +168,7 @@ export const DocumentMentionPicker = forwardRef<
// 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({
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading, isFetching: isSurfsenseDocsFetching } = useQuery({
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
@ -177,6 +177,16 @@ export const DocumentMentionPicker = forwardRef<
placeholderData: keepPreviousData,
});
// Client-side filter to verify search term is actually in the title (handles backend fuzzy false positives)
const filterBySearchTerm = useCallback(
(docs: Pick<Document, "id" | "title" | "document_type">[]) => {
if (!isSearchValid) return docs; // No filtering when not searching
const searchLower = debouncedSearch.trim().toLowerCase();
return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower));
},
[debouncedSearch, isSearchValid]
);
// Update accumulated documents when first page loads - combine both sources
useEffect(() => {
if (currentPage === 0) {
@ -199,9 +209,10 @@ export const DocumentMentionPicker = forwardRef<
setHasMore(titleSearchResults.has_more);
}
setAccumulatedDocuments(combinedDocs);
// Apply client-side filter to remove fuzzy false positives
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}
}, [titleSearchResults, surfsenseDocs, currentPage]);
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
// Function to load next page using lightweight endpoint
const loadNextPage = useCallback(async () => {
@ -246,10 +257,14 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments;
const actualLoading = (isTitleSearchLoading || isSurfsenseDocsLoading) && currentPage === 0;
const isFetchingResults = isTitleSearchFetching || isSurfsenseDocsFetching;
// Show hint when search is too short
const showSearchHint = shouldSearch && !isSearchValid;
// Hide popup entirely when user is searching and no documents match (only after fetch completes)
const hasNoSearchResults = isSearchValid && !actualLoading && !isFetchingResults && actualDocuments.length === 0;
// Split documents into SurfSense docs and user docs for grouped rendering
const surfsenseDocsList = useMemo(
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
@ -345,6 +360,11 @@ export const DocumentMentionPicker = forwardRef<
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
);
// Don't show popup when user is searching and no documents match
if (hasNoSearchResults) {
return null;
}
return (
<div
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"