Merge pull request #707 from AnishSarkar22/fix/mentions

feat: Revamped search in document mentions
This commit is contained in:
Rohan Verma 2026-01-18 22:15:28 -08:00 committed by GitHub
commit 26ef83fbaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 560 additions and 168 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/60 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

@ -200,7 +200,7 @@ const ThreadWelcome: FC = () => {
};
const Composer: FC = () => {
// ---- State for document mentions (using atoms to persist across remounts) ----
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
@ -212,16 +212,12 @@ const Composer: FC = () => {
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
// Check if thread is empty (new chat)
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
// Check if thread is currently running (streaming response)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Auto-focus editor when on new chat page
// Auto-focus editor on new chat page after mount
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
// Small delay to ensure the editor is fully mounted
const timeoutId = setTimeout(() => {
editorRef.current?.focus();
hasAutoFocusedRef.current = true;
@ -230,7 +226,7 @@ const Composer: FC = () => {
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
// Sync mentioned document IDs to atom for inclusion in chat request payload
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
@ -242,7 +238,7 @@ const Composer: FC = () => {
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer
// Sync editor text with assistant-ui composer runtime
const handleEditorChange = useCallback(
(text: string) => {
composerRuntime.setText(text);
@ -250,13 +246,13 @@ const Composer: FC = () => {
[composerRuntime]
);
// Handle @ mention trigger from inline editor
// Open document picker when @ mention is triggered
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
// Handle mention close
// Close document picker and reset query
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
@ -264,7 +260,7 @@ const Composer: FC = () => {
}
}, [showDocumentPopover]);
// Handle keyboard navigation when popover is open
// Keyboard navigation for document picker (arrow keys, Enter, Escape)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showDocumentPopover) {
@ -294,15 +290,13 @@ const Composer: FC = () => {
[showDocumentPopover]
);
// Handle submit from inline editor (Enter key)
// Submit message (blocked during streaming or when document picker is open)
const handleSubmit = useCallback(() => {
// Prevent sending while a response is still streaming
if (isThreadRunning) {
return;
}
if (!showDocumentPopover) {
composerRuntime.send();
// Clear the editor after sending
editorRef.current?.clear();
setMentionedDocuments([]);
setMentionedDocumentIds({
@ -318,6 +312,7 @@ const Composer: FC = () => {
setMentionedDocumentIds,
]);
// Remove document from mentions and sync IDs to atom
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
@ -336,6 +331,7 @@ const Composer: FC = () => {
[setMentionedDocuments, setMentionedDocumentIds]
);
// Add selected documents from picker, insert chips, and sync IDs to atom
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
@ -373,7 +369,7 @@ const Composer: FC = () => {
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* -------- Inline Mention Editor -------- */}
{/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
@ -388,45 +384,29 @@ const Composer: FC = () => {
/>
</div>
{/* -------- Document mention popover (rendered via portal) -------- */}
{/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<>
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 cursor-default"
style={{ zIndex: 9998 }}
onClick={() => setShowDocumentPopover(false)}
aria-label="Close document picker"
/>
{/* Popover positioned above input */}
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
style={{
zIndex: 9999,
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
>
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
</>,
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
containerStyle={{
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
/>,
document.body
)}
<ComposerAction />