import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react"; import { useAtom, useSetAtom } from "jotai"; import { useParams } from "next/navigation"; import type { FC } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment"; import { ComposerAction } from "@/components/assistant-ui/composer-action"; import { InlineMentionEditor, type InlineMentionEditorRef, } from "@/components/assistant-ui/inline-mention-editor"; import { DocumentMentionPicker, type DocumentMentionPickerRef, } from "@/components/new-chat/document-mention-picker"; import type { Document } from "@/contracts/types/document.types"; export const Composer: FC = () => { // ---- State for document mentions (using atoms to persist across remounts) ---- const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); const { search_space_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); 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 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; }, 100); return () => clearTimeout(timeoutId); } }, [isThreadEmpty]); // Sync mentioned document IDs to atom for use in chat request useEffect(() => { setMentionedDocumentIds({ surfsense_doc_ids: mentionedDocuments .filter((doc) => doc.document_type === "SURFSENSE_DOCS") .map((doc) => doc.id), document_ids: mentionedDocuments .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") .map((doc) => doc.id), }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer const handleEditorChange = useCallback( (text: string) => { composerRuntime.setText(text); }, [composerRuntime] ); // Handle @ mention trigger from inline editor const handleMentionTrigger = useCallback((query: string) => { setShowDocumentPopover(true); setMentionQuery(query); }, []); // Handle mention close const handleMentionClose = useCallback(() => { if (showDocumentPopover) { setShowDocumentPopover(false); setMentionQuery(""); } }, [showDocumentPopover]); // Handle keyboard navigation when popover is open const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (showDocumentPopover) { if (e.key === "ArrowDown") { e.preventDefault(); documentPickerRef.current?.moveDown(); return; } if (e.key === "ArrowUp") { e.preventDefault(); documentPickerRef.current?.moveUp(); return; } if (e.key === "Enter") { e.preventDefault(); documentPickerRef.current?.selectHighlighted(); return; } if (e.key === "Escape") { e.preventDefault(); setShowDocumentPopover(false); setMentionQuery(""); return; } } }, [showDocumentPopover] ); // Handle submit from inline editor (Enter key) 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({ surfsense_doc_ids: [], document_ids: [], }); } }, [ showDocumentPopover, isThreadRunning, composerRuntime, setMentionedDocuments, setMentionedDocumentIds, ]); const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => { const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); setMentionedDocumentIds({ surfsense_doc_ids: updated .filter((doc) => doc.document_type === "SURFSENSE_DOCS") .map((doc) => doc.id), document_ids: updated .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") .map((doc) => doc.id), }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); const handleDocumentsMention = useCallback( (documents: Pick[]) => { const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const newDocs = documents.filter( (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } setMentionedDocuments((prev) => { const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); const uniqueNewDocs = documents.filter( (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) ); const updated = [...prev, ...uniqueNewDocs]; setMentionedDocumentIds({ surfsense_doc_ids: updated .filter((doc) => doc.document_type === "SURFSENSE_DOCS") .map((doc) => doc.id), document_ids: updated .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") .map((doc) => doc.id), }); return updated; }); setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] ); return ( {/* -------- Inline Mention Editor -------- */}
{/* -------- Document mention popover (rendered via portal) -------- */} {showDocumentPopover && typeof document !== "undefined" && createPortal( <> {/* Backdrop */}