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, DownloadIcon, Loader2, 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 { 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 { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment"; 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 type { Document } from "@/contracts/types/document.types"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { cn } from "@/lib/utils"; 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 editorRef = useRef(null); const editorContainerRef = useRef(null); 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); // 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); // 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] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { if (isThreadRunning || isBlockedByOtherUser) { return; } if (!showDocumentPopover) { composerRuntime.send(); editorRef.current?.clear(); setMentionedDocuments([]); setMentionedDocumentIds({ surfsense_doc_ids: [], document_ids: [], }); } }, [ showDocumentPopover, isThreadRunning, isBlockedByOtherUser, 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; }); }, [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] ); 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 )}
); }; interface ComposerActionProps { isBlockedByOtherUser?: boolean; } const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => { // Check if any attachments are still being processed (running AND progress < 100) // When progress is 100, processing is done but waiting for send() const hasProcessingAttachments = useAssistantState(({ composer }) => composer.attachments?.some((att) => { const status = att.status; if (status?.type !== "running") return false; const progress = (status as { type: "running"; progress?: number }).progress; return progress === undefined || progress < 100; }) ); // Check if composer text is empty const isComposerEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.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 = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; return (
{/* Show processing indicator when attachments are being processed */} {hasProcessingAttachments && (
Processing...
)} {/* Show warning when no model is configured */} {!hasModelConfigured && !hasProcessingAttachments && (
Select a model
)} !thread.isRunning}> 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 ( / ); };