import { ActionBarPrimitive, AssistantIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, ThreadPrimitive, useAssistantState, useComposerRuntime, } from "@assistant-ui/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AlertCircle, ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, Dot, DownloadIcon, FileWarning, Paperclip, RefreshCwIcon, SquareIcon, } from "lucide-react"; import { useParams } from "next/navigation"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom"; import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { globalNewLLMConfigsAtom, llmPreferencesAtom, newLLMConfigsAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { InlineMentionEditor, type InlineMentionEditorRef, } from "@/components/assistant-ui/inline-mention-editor"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ThinkingStepsContext, ThinkingStepsDisplay, } from "@/components/assistant-ui/thinking-steps"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; import { DocumentMentionPicker, type DocumentMentionPickerRef, } from "@/components/new-chat/document-mention-picker"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ const CYCLING_PLACEHOLDERS = [ "Ask SurfSense anything or @mention docs.", "Generate a podcast from my vacation ideas in Notion.", "Sum up last week's meeting notes from Drive in a bulleted list.", "Give me a brief overview of the most urgent tickets in Jira and Linear.", "Briefly, what are today's top ten important emails and calendar events?", "Check if this week's Slack messages reference any GitHub issues.", ]; const CHAT_UPLOAD_ACCEPT = ".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm"; const CHAT_MAX_FILES = 10; const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total type UploadState = "pending" | "processing" | "ready" | "failed"; interface UploadedMentionDoc { id: number; title: string; document_type: Document["document_type"]; state: UploadState; reason?: string | null; } interface ThreadProps { messageThinkingSteps?: Map; header?: React.ReactNode; } export const Thread: FC = ({ messageThinkingSteps = new Map(), header }) => { return ( ); }; const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => { const showGutter = useAtomValue(showCommentsGutterAtom); return ( {header &&
{header}
} thread.isEmpty}> !thread.isEmpty}>
); }; const ThreadScrollToBottom: FC = () => { return ( ); }; const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => { const hour = new Date().getHours(); // Extract first name: prefer display_name, fall back to email extraction let firstName: string | null = null; if (user?.display_name?.trim()) { // Use display_name if available and not empty // Extract first name from display_name (take first word) const nameParts = user.display_name.trim().split(/\s+/); firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase(); } else if (user?.email) { // Fall back to email extraction if display_name is not available firstName = user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() + user.email.split("@")[0].split(".")[0].slice(1); } // Array of greeting variations for each time period const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"]; const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"]; const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"]; const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"]; const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"]; // Select a random greeting based on time let greeting: string; if (hour < 5) { // Late night: midnight to 5 AM greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)]; } else if (hour < 12) { greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)]; } else if (hour < 18) { greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)]; } else if (hour < 22) { greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)]; } else { // Night: 10 PM to midnight greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)]; } // Add personalization with first name if available if (firstName) { return `${greeting}, ${firstName}!`; } return `${greeting}!`; }; const ThreadWelcome: FC = () => { const { data: user } = useAtomValue(currentUserAtom); // Memoize greeting so it doesn't change on re-renders (only on user change) const greeting = useMemo(() => getTimeBasedGreeting(user), [user]); return (
{/* Greeting positioned above the composer - fixed position */}

{greeting}

{/* Composer - top edge fixed, expands downward only */}
); }; const Composer: FC = () => { // Document mention state (atoms persist across component remounts) const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [uploadedMentionDocs, setUploadedMentionDocs] = useState< Record >({}); const [isUploadingDocs, setIsUploadingDocs] = useState(false); const editorRef = useRef(null); const editorContainerRef = useRef(null); const uploadInputRef = useRef(null); const isFileDialogOpenRef = useRef(false); const documentPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); // Cycling placeholder state - only cycles in new chats const [placeholderIndex, setPlaceholderIndex] = useState(0); // Cycle through placeholders every 4 seconds when thread is empty (new chat) useEffect(() => { // Only cycle when thread is empty (new chat) if (!isThreadEmpty) { // Reset to first placeholder when chat becomes active setPlaceholderIndex(0); return; } const intervalId = setInterval(() => { setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length); }, 6000); return () => clearInterval(intervalId); }, [isThreadEmpty]); // Compute current placeholder - only cycle in new chats const currentPlaceholder = isThreadEmpty ? CYCLING_PLACEHOLDERS[placeholderIndex] : CYCLING_PLACEHOLDERS[0]; // Live collaboration state const { data: currentUser } = useAtomValue(currentUserAtom); const { data: members } = useAtomValue(membersAtom); const threadId = useMemo(() => { if (Array.isArray(chat_id) && chat_id.length > 0) { return Number.parseInt(chat_id[0], 10) || null; } return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null; }, [chat_id]); const sessionState = useAtomValue(chatSessionStateAtom); const isAiResponding = sessionState?.isAiResponding ?? false; const respondingToUserId = sessionState?.respondingToUserId ?? null; const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; // Sync comments for the entire thread via Electric SQL (one subscription per thread) useCommentsElectric(threadId); // Batch-prefetch comments for all assistant messages so individual useComments // hooks never fire their own network requests (eliminates N+1 API calls). // Return a primitive string from the selector so useSyncExternalStore can // compare snapshots by value and avoid infinite re-render loops. const assistantIdsKey = useAssistantState(({ thread }) => thread.messages .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .map((m) => m.id!.replace("msg-", "")) .join(",") ); const assistantDbMessageIds = useMemo( () => (assistantIdsKey ? assistantIdsKey.split(",").map(Number) : []), [assistantIdsKey] ); useBatchCommentsPreload(assistantDbMessageIds); // Auto-focus editor on new chat page after mount useEffect(() => { if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { const timeoutId = setTimeout(() => { editorRef.current?.focus(); hasAutoFocusedRef.current = true; }, 100); return () => clearTimeout(timeoutId); } }, [isThreadEmpty]); // Sync mentioned document IDs to atom for inclusion in chat request payload 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]); // Sync editor text with assistant-ui composer runtime const handleEditorChange = useCallback( (text: string) => { composerRuntime.setText(text); }, [composerRuntime] ); // Open document picker when @ mention is triggered const handleMentionTrigger = useCallback((query: string) => { setShowDocumentPopover(true); setMentionQuery(query); }, []); // Close document picker and reset query const handleMentionClose = useCallback(() => { if (showDocumentPopover) { setShowDocumentPopover(false); setMentionQuery(""); } }, [showDocumentPopover]); // Keyboard navigation for document picker (arrow keys, Enter, Escape) 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] ); const uploadedMentionedDocs = useMemo( () => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]), [mentionedDocuments, uploadedMentionDocs] ); const blockingUploadedMentions = useMemo( () => uploadedMentionedDocs.filter((doc) => { const state = uploadedMentionDocs[doc.id]?.state; return state === "pending" || state === "processing" || state === "failed"; }), [uploadedMentionedDocs, uploadedMentionDocs] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { if ( isThreadRunning || isBlockedByOtherUser || isUploadingDocs || blockingUploadedMentions.length > 0 ) { return; } if (!showDocumentPopover) { composerRuntime.send(); editorRef.current?.clear(); setMentionedDocuments([]); setMentionedDocumentIds({ surfsense_doc_ids: [], document_ids: [], }); } }, [ showDocumentPopover, isThreadRunning, isBlockedByOtherUser, isUploadingDocs, blockingUploadedMentions.length, composerRuntime, setMentionedDocuments, setMentionedDocumentIds, ]); // Remove document from mentions and sync IDs to atom 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; }); setUploadedMentionDocs((prev) => { if (!(docId in prev)) return prev; const { [docId]: _removed, ...rest } = prev; return rest; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); // Add selected documents from picker, insert chips, and sync IDs to atom 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] ); const refreshUploadedDocStatuses = useCallback( async (documentIds: number[]) => { if (!search_space_id || documentIds.length === 0) return; const statusResponse = await documentsApiService.getDocumentsStatus({ queryParams: { search_space_id: Number(search_space_id), document_ids: documentIds, }, }); setUploadedMentionDocs((prev) => { const next = { ...prev }; for (const item of statusResponse.items) { next[item.id] = { id: item.id, title: item.title, document_type: item.document_type, state: item.status.state, reason: item.status.reason, }; } return next; }); handleDocumentsMention( statusResponse.items.map((item) => ({ id: item.id, title: item.title, document_type: item.document_type, })) ); }, [search_space_id, handleDocumentsMention] ); const handleUploadClick = useCallback(() => { if (isFileDialogOpenRef.current) return; isFileDialogOpenRef.current = true; uploadInputRef.current?.click(); // Reset after a delay to handle cancellation (which doesn't fire the change event). setTimeout(() => { isFileDialogOpenRef.current = false; }, 1000); }, []); const handleUploadInputChange = useCallback( async (event: React.ChangeEvent) => { isFileDialogOpenRef.current = false; const files = Array.from(event.target.files ?? []); event.target.value = ""; if (files.length === 0 || !search_space_id) return; if (files.length > CHAT_MAX_FILES) { toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`); return; } let totalSize = 0; for (const file of files) { if (file.size > CHAT_MAX_FILE_SIZE_BYTES) { toast.error( `File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.` ); return; } totalSize += file.size; } if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) { toast.error( `Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.` ); return; } setIsUploadingDocs(true); try { const uploadResponse = await documentsApiService.uploadDocument({ files, search_space_id: Number(search_space_id), }); const uploadedIds = uploadResponse.document_ids ?? []; const duplicateIds = uploadResponse.duplicate_document_ids ?? []; const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds])); if (idsToMention.length === 0) { toast.warning("No documents were created or matched from selected files."); return; } await refreshUploadedDocStatuses(idsToMention); if (uploadedIds.length > 0 && duplicateIds.length > 0) { toast.success( `Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.` ); } else if (uploadedIds.length > 0) { toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`); } else { toast.success( `Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.` ); } } catch (error) { const message = error instanceof Error ? error.message : "Upload failed"; toast.error(`Upload failed: ${message}`); } finally { setIsUploadingDocs(false); } }, [search_space_id, refreshUploadedDocStatuses] ); // Poll status for uploaded mentioned documents until all are ready or removed. useEffect(() => { const trackedIds = uploadedMentionedDocs.map((doc) => doc.id); const needsPolling = trackedIds.some((id) => { const state = uploadedMentionDocs[id]?.state; return state === "pending" || state === "processing"; }); if (!needsPolling) return; const interval = setInterval(() => { refreshUploadedDocStatuses(trackedIds).catch((error) => { console.error("[Composer] Failed to refresh uploaded mention statuses:", error); }); }, 2500); return () => clearInterval(interval); }, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]); // Push upload status directly onto mention chips (instead of separate status rows). useEffect(() => { for (const doc of uploadedMentionedDocs) { const state = uploadedMentionDocs[doc.id]?.state ?? "pending"; const statusLabel = state === "ready" ? null : state === "failed" ? "failed" : state === "processing" ? "indexing" : "queued"; editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state); } }, [uploadedMentionedDocs, uploadedMentionDocs]); // Prune upload status entries that are no longer mentioned in the composer. useEffect(() => { const activeIds = new Set(mentionedDocuments.map((doc) => doc.id)); setUploadedMentionDocs((prev) => { let changed = false; const next: Record = {}; for (const [key, value] of Object.entries(prev)) { const id = Number(key); if (activeIds.has(id)) { next[id] = value; } else { changed = true; } } return changed ? next : prev; }); }, [mentionedDocuments]); return (
{/* Inline editor with @mention support */}
{/* Document picker popover (portal to body for proper z-index stacking) */} {showDocumentPopover && typeof document !== "undefined" && createPortal( { 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 )} uploadedMentionDocs[doc.id]?.state === "failed" )} />
); }; interface ComposerActionProps { isBlockedByOtherUser?: boolean; onUploadClick: () => void; isUploadingDocs: boolean; blockingUploadedMentionsCount: number; hasFailedUploadedMentions: boolean; } const ComposerAction: FC = ({ isBlockedByOtherUser = false, onUploadClick, isUploadingDocs, blockingUploadedMentionsCount, hasFailedUploadedMentions, }) => { const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); // Check if composer text is empty (chips are represented in mentionedDocuments atom) const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; }); const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; // Check if a model is configured const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const { data: preferences } = useAtomValue(llmPreferencesAtom); const hasModelConfigured = useMemo(() => { if (!preferences) return false; const agentLlmId = preferences.agent_llm_id; if (agentLlmId === null || agentLlmId === undefined) return false; // Check if the configured model actually exists // Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs if (agentLlmId <= 0) { return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; } return userConfigs?.some((c) => c.id === agentLlmId) ?? false; }, [preferences, globalConfigs, userConfigs]); const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser || isUploadingDocs || blockingUploadedMentionsCount > 0; return (
Upload and mention files Max 10 files 50 MB each Total upload limit: 200 MB
) } side="bottom" variant="ghost" size="icon" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" aria-label="Upload files" onClick={onUploadClick} disabled={isUploadingDocs} > {isUploadingDocs ? ( ) : ( )}
{blockingUploadedMentionsCount > 0 && (
{hasFailedUploadedMentions ? : } {hasFailedUploadedMentions ? "Remove or retry failed uploads" : "Waiting for uploaded files to finish indexing"}
)} {/* Show warning when no model is configured */} {!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
Select a model
)} !thread.isRunning}> 0 ? "Waiting for uploaded files to finish indexing" : isUploadingDocs ? "Uploading documents..." : !hasModelConfigured ? "Please select a model from the header to start chatting" : isComposerEmpty ? "Enter a message to send" : "Send message" } side="bottom" type="submit" variant="default" size="icon" className={cn( "aui-composer-send size-8 rounded-full", isSendDisabled && "cursor-not-allowed opacity-50" )} aria-label="Send message" disabled={isSendDisabled} > thread.isRunning}> ); }; const MessageError: FC = () => { return ( ); }; /** * Custom component to render thinking steps from Context */ const ThinkingStepsPart: FC = () => { const thinkingStepsMap = useContext(ThinkingStepsContext); // Get the current message ID to look up thinking steps const messageId = useAssistantState(({ message }) => message?.id); const thinkingSteps = thinkingStepsMap.get(messageId) || []; // Check if this specific message is currently streaming // A message is streaming if: thread is running AND this is the last assistant message const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; if (thinkingSteps.length === 0) return null; return (
); }; const AssistantMessageInner: FC = () => { return ( <> {/* Render thinking steps from message content - this ensures proper scroll tracking */}
); }; const AssistantActionBar: FC = () => { return ( message.isCopied}> !message.isCopied}> ); }; const EditComposer: FC = () => { return (
); }; const BranchPicker: FC = ({ className, ...rest }) => { return ( / ); };