"use client" import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useQueryState } from "nuqs" import type { UIMessage } from "@ai-sdk/react" import { motion } from "motion/react" import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" import NovaOrb from "@/components/nova/nova-orb" import { Button } from "@ui/components/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@ui/components/sheet" import { ScrollArea } from "@ui/components/scroll-area" import { ArrowLeft, Check, ChevronDownIcon, HistoryIcon, Plus, SearchIcon, SquarePenIcon, Trash2, XIcon, } from "lucide-react" import { formatDistanceToNow } from "date-fns" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import ChatInput from "./input" import ChatModelSelector from "./model-selector" import { getNovaChatErrorCopy } from "@/lib/chat-stream-error" import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo" import { useProject } from "@/stores" import { useContainerTags } from "@/hooks/use-container-tags" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" import { modelNames, type ModelId } from "@/lib/models" import { SuperLoader } from "../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage } from "./message/agent-message" import { ChatGraphContextRail } from "./chat-graph-context-rail" import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" import { analytics } from "@/lib/analytics" import { generateId } from "@lib/generate-id" import { useViewMode } from "@/lib/view-mode-context" import { threadParam } from "@/lib/search-params" const DEFAULT_SUGGESTIONS = [ "Show me all content related to Supermemory.", "Summarize the key ideas from My Gita.", "Which memories connect design and AI?", "What are the main themes across my memories?", ] function ChatEmptyStatePlaceholder({ onSuggestionClick, suggestions = DEFAULT_SUGGESTIONS, }: { onSuggestionClick: (suggestion: string) => void suggestions?: string[] }) { return (

Ask me anything about your memories...

{suggestions.map((suggestion) => ( ))}
) } export function ChatLaunchFab({ onOpen, isMobile, }: { onOpen: () => void isMobile: boolean }) { return ( Chat with Nova ) } export function ChatSidebar({ isChatOpen, setIsChatOpen, queuedMessage, onConsumeQueuedMessage, queuedMessageSource = "highlight", initialSelectedModel = null, emptyStateSuggestions, layout = "sidebar", }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void queuedMessage?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" initialSelectedModel?: ModelId | null emptyStateSuggestions?: string[] layout?: "sidebar" | "page" }) { const isMobile = useIsMobile() const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState( initialSelectedModel ?? "claude-sonnet-4.6", ) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel const [copiedMessageId, setCopiedMessageId] = useState(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) const [messageFeedback, setMessageFeedback] = useState< Record >({}) const [expandedMemories, setExpandedMemories] = useState(null) const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [heightOffset, setHeightOffset] = useState(95) const [isHistoryOpen, setIsHistoryOpen] = useState(false) const [threads, setThreads] = useState< Array<{ id: string; title: string; createdAt: string; updatedAt: string }> >([]) const [isLoadingThreads, setIsLoadingThreads] = useState(false) const [confirmingDeleteId, setConfirmingDeleteId] = useState( null, ) const messagesContainerRef = useRef(null) const sentQueuedMessageRef = useRef(null) const { selectedProject } = useProject() const { allProjects } = useContainerTags() const selectedProjectRef = useRef(selectedProject) selectedProjectRef.current = selectedProject const chatSpaceLabel = useMemo( () => getChatSpaceDisplayLabel({ selectedProject, allProjects, }), [selectedProject, allProjects], ) const { viewMode } = useViewMode() const { user: _user } = useAuth() const [threadId, setThreadId] = useQueryState("thread", threadParam) const [fallbackChatId, setFallbackChatId] = useState(() => generateId()) const currentChatId = threadId ?? fallbackChatId const chatIdRef = useRef(currentChatId) chatIdRef.current = currentChatId const _setCurrentChatId = useCallback( (id: string) => setThreadId(id), [setThreadId], ) const chatApiBase = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const chatTransport = useMemo( () => new DefaultChatTransport({ api: `${chatApiBase}/chat`, credentials: "include", prepareSendMessagesRequest: ({ messages }) => ({ body: { messages, metadata: { chatId: chatIdRef.current, projectId: selectedProjectRef.current, model: selectedModelRef.current, }, }, }), }), [chatApiBase], ) const [pendingThreadLoad, setPendingThreadLoad] = useState<{ id: string messages: UIMessage[] } | null>(null) // Adjust chat height based on scroll position (desktop only, grid mode only) useEffect(() => { if (isMobile) return if (viewMode === "graph") return if (layout === "page") return const handleWindowScroll = () => { const scrollThreshold = 80 const scrollY = window.scrollY const progress = Math.min(scrollY / scrollThreshold, 1) const newOffset = 95 - progress * (95 - 15) setHeightOffset(newOffset) } window.addEventListener("scroll", handleWindowScroll, { passive: true }) handleWindowScroll() return () => window.removeEventListener("scroll", handleWindowScroll) }, [isMobile, viewMode, layout]) const { messages, sendMessage, status, setMessages, stop, error, clearError, } = useChat({ id: currentChatId ?? undefined, transport: chatTransport, }) const chatStreamError = useMemo( () => (error ? getNovaChatErrorCopy(error, selectedModel) : null), [error, selectedModel], ) const handleModelChange = useCallback( (modelId: ModelId) => { setSelectedModel(modelId) clearError() }, [clearError], ) useEffect(() => { if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) { setMessages(pendingThreadLoad.messages) setPendingThreadLoad(null) } }, [currentChatId, pendingThreadLoad, setMessages]) const checkIfScrolledToBottom = useCallback(() => { if (!messagesContainerRef.current) return const container = messagesContainerRef.current const { scrollTop, scrollHeight, clientHeight } = container const distanceFromBottom = scrollHeight - scrollTop - clientHeight const isAtBottom = distanceFromBottom <= 20 setIsScrolledToBottom(isAtBottom) }, []) const scrollToBottom = useCallback(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight setIsScrolledToBottom(true) } }, []) const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) sendMessage({ text: input }) setInput("") scrollToBottom() } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() } } const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) }, []) const handleLikeMessage = useCallback( (messageId: string) => { const wasLiked = messageFeedback[messageId] === "like" setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "like" ? null : "like", })) if (!wasLiked) { analytics.chatMessageLiked({ message_id: messageId }) } }, [messageFeedback], ) const handleDislikeMessage = useCallback( (messageId: string) => { const wasDisliked = messageFeedback[messageId] === "dislike" setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "dislike" ? null : "dislike", })) if (!wasDisliked) { analytics.chatMessageDisliked({ message_id: messageId }) } }, [messageFeedback], ) const handleToggleMemories = useCallback((messageId: string) => { setExpandedMemories((prev) => { const isExpanding = prev !== messageId if (isExpanding) { analytics.chatMemoryExpanded({ message_id: messageId }) } else { analytics.chatMemoryCollapsed({ message_id: messageId }) } return prev === messageId ? null : messageId }) }, []) const handleNewChat = useCallback(() => { analytics.newChatCreated() const newChatId = generateId() chatIdRef.current = newChatId setMessages([]) setThreadId(null) setFallbackChatId(newChatId) setInput("") }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { setIsLoadingThreads(true) try { const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`, { credentials: "include" }, ) if (response.ok) { const data = await response.json() setThreads(data.threads || []) } } catch (error) { console.error("Failed to fetch threads:", error) } finally { setIsLoadingThreads(false) } }, [selectedProject]) const loadThread = useCallback( async (id: string) => { try { const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`, { credentials: "include" }, ) if (response.ok) { const data = await response.json() const uiMessages = data.messages.map( (m: { id: string role: string parts: Array<{ type: string }> createdAt: string }) => ({ id: m.id, role: m.role, // Strip tool parts — persisted format doesn't round-trip through // convertToModelMessages correctly and causes tool_use/tool_result // mismatch errors. Text history is sufficient for context. parts: (m.parts || []).filter( (p) => p.type === "text" || p.type === "reasoning", ), createdAt: new Date(m.createdAt), }), ) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) analytics.chatThreadLoaded({ thread_id: id }) setIsHistoryOpen(false) setConfirmingDeleteId(null) } } catch (error) { console.error("Failed to load thread:", error) } }, [setThreadId], ) // Auto-restore thread from URL on mount (e.g. reload or direct link) const didAutoLoadRef = useRef(false) useEffect(() => { if (didAutoLoadRef.current) return if (!threadId) return didAutoLoadRef.current = true loadThread(threadId) }, [threadId, loadThread]) const deleteThread = useCallback( async (threadId: string) => { try { const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, { method: "DELETE", credentials: "include" }, ) if (response.ok) { analytics.chatThreadDeleted({ thread_id: threadId }) setThreads((prev) => prev.filter((t) => t.id !== threadId)) if (currentChatId === threadId) { handleNewChat() } } } catch (error) { console.error("Failed to delete thread:", error) } finally { setConfirmingDeleteId(null) } }, [currentChatId, handleNewChat], ) const formatRelativeTime = (isoString: string): string => { return formatDistanceToNow(new Date(isoString), { addSuffix: true }) } useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | null const isInEditableContext = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.isContentEditable || activeElement?.closest('[contenteditable="true"]') if ( e.key.toLowerCase() === "t" && !e.metaKey && !e.ctrlKey && !e.altKey && isChatOpen && !isInEditableContext ) { e.preventDefault() handleNewChat() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [isChatOpen, handleNewChat]) // Send queued message when chat opens useEffect(() => { if ( isChatOpen && queuedMessage && status !== "submitted" && status !== "streaming" && sentQueuedMessageRef.current !== queuedMessage ) { if (initialSelectedModel && selectedModel !== initialSelectedModel) { setSelectedModel(initialSelectedModel) return } sentQueuedMessageRef.current = queuedMessage if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: queuedMessageSource }) sendMessage({ text: queuedMessage }) onConsumeQueuedMessage?.() } }, [ isChatOpen, queuedMessage, queuedMessageSource, initialSelectedModel, selectedModel, status, sendMessage, onConsumeQueuedMessage, fallbackChatId, setThreadId, threadId, ]) // Reset the sent message ref when queued message is consumed useEffect(() => { if (!queuedMessage) { sentQueuedMessageRef.current = null } }, [queuedMessage]) // Scroll to bottom when a new user message is added useEffect(() => { const lastMessage = messages[messages.length - 1] if (lastMessage?.role === "user" && messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight setIsScrolledToBottom(true) } // Always check scroll position when messages change checkIfScrolledToBottom() }, [messages, checkIfScrolledToBottom]) // Add scroll event listener to track scroll position useEffect(() => { const container = messagesContainerRef.current if (!container) return const handleScroll = () => { requestAnimationFrame(() => { checkIfScrolledToBottom() }) } container.addEventListener("scroll", handleScroll, { passive: true }) // Initial check with a small delay to ensure DOM is ready setTimeout(() => { checkIfScrolledToBottom() }, 100) // Also observe resize to detect content height changes const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { checkIfScrolledToBottom() }) }) resizeObserver.observe(container) return () => { container.removeEventListener("scroll", handleScroll) resizeObserver.disconnect() } }, [checkIfScrolledToBottom]) if (!isChatOpen) { return null } const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput const chatHistorySheet = ( { setIsHistoryOpen(open) if (open) { fetchThreads() analytics.chatHistoryViewed?.() } else { setConfirmingDeleteId(null) } }} > button]:text-[#FAFAFA]", dmSansClassName(), )} > Chat History Space: {chatSpaceLabel}
{isLoadingThreads ? (
) : threads.length === 0 ? (
No conversations yet
) : (
{threads.map((thread) => { const isActive = thread.id === currentChatId return (
) : ( )} ) })}
)}
) const chatToolbarActions = (
) const pageDesktopToolbarRow = isPageDesktop ? (
{chatToolbarActions}
) : null const shell = ( <> {showHeaderRow ? (
{layout === "page" && isMobile && ( )} {!isStackedInput && ( <>
{chatSpaceLabel}
)}
{chatToolbarActions}
) : null}
{isInputExpanded && (
)} {messages.length === 0 && ( { analytics.chatSuggestedQuestionClicked() analytics.chatMessageSent({ source: "suggested" }) sendMessage({ text: suggestion }) }} suggestions={emptyStateSuggestions} /> )}
0 ? cn( "flex flex-col space-y-3 min-h-full justify-end", isPageDesktop ? "pt-2" : "pt-14", ) : "" } > {messages.map((message, index) => ( // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
message.role === "assistant" && setHoveredMessageId(message.id) } onMouseLeave={() => message.role === "assistant" && setHoveredMessageId(null) } > {message.role === "user" ? ( ) : ( )}
))} {(status === "submitted" || status === "streaming") && (
)}
{!isScrolledToBottom && messages.length > 0 && (
)} {chatStreamError && (

{chatStreamError.title}

{chatStreamError.body}

{chatStreamError.otherModels.length > 0 && (
{chatStreamError.otherModels.map((id) => { const m = modelNames[id] return ( ) })}
)}
)}
setInput(e.target.value)} onSend={handleSend} onStop={stop} onKeyDown={handleKeyDown} isResponding={status === "submitted" || status === "streaming"} activeStatus={ status === "submitted" ? "Thinking..." : status === "streaming" ? "Structuring response..." : "Waiting for input..." } onExpandedChange={setIsInputExpanded} chainOfThoughtComponent={ messages.length > 0 ? : null } stackedToolbar={ isStackedInput ? ( <>
{chatSpaceLabel}
) : undefined } />
) return ( {chatHistorySheet} {isPageDesktop ? (
{pageDesktopToolbarRow}
{shell}
) : ( shell )}
) } export { HomeChatComposer } from "./home-chat-composer"