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, PlusIcon, 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 { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { mentionedDocumentsAtom, sidebarSelectedDocumentsAtom, } 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 { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-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 { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; 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.", ]; interface ThreadProps { messageThinkingSteps?: Map; } export const Thread: FC = ({ messageThinkingSteps = new Map() }) => { return ( ); }; const ThreadContent: FC = () => { const showGutter = useAtomValue(showCommentsGutterAtom); return ( 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 setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom); 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 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 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([]); setSidebarDocs([]); } }, [ showDocumentPopover, isThreadRunning, isBlockedByOtherUser, composerRuntime, setMentionedDocuments, setSidebarDocs, ]); const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)) ); }, [setMentionedDocuments] ); 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}`) ); return [...prev, ...uniqueNewDocs]; }); setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments] ); 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 }) => { const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom); const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; }); const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; 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; 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; return (
{!hasModelConfigured && (
Select a model
)}
{sidebarDocs.length > 0 && ( )} !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 ( / ); };