"use client" import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useQuery } from "@tanstack/react-query" import { $fetch } from "@lib/api" 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, 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 { 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 { SpaceSelector } from "@/components/space-selector" 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" import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space" import { ChatEmptyStatePlaceholder } from "./chat-empty-state" export function ChatLaunchFab({ onOpen, isMobile, }: { onOpen: () => void isMobile: boolean }) { return ( Chat with Nova ) } export function ChatSidebar({ isChatOpen, setIsChatOpen, queuedMessage, queuedHighlightContent, onConsumeQueuedMessage, queuedMessageSource = "highlight", initialSelectedModel = null, initialChatProject = null, emptyStateSuggestions, layout = "sidebar", }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void queuedMessage?: string | null queuedHighlightContent?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" initialSelectedModel?: ModelId | null initialChatProject?: string | 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 isScrolledToBottomRef = useRef(true) const userJustSentRef = useRef(false) const sentQueuedMessageRef = useRef(null) const pendingHighlightReplyRef = useRef(null) const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) const targetHighlightChatIdRef = useRef(null) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ initialChatProject ?? selectedProject, ]) const chatProject = chatSpaceProjects[0] ?? selectedProject const { allProjects } = useContainerTags() const selectedProjectRef = useRef(chatProject) selectedProjectRef.current = chatProject const chatSpaceLabel = useMemo( () => chatProject === AUTO_CHAT_SPACE_ID ? "Auto" : getChatSpaceDisplayLabel({ selectedProject: chatProject, allProjects, }), [chatProject, allProjects], ) const isAutoChatSpace = chatProject === AUTO_CHAT_SPACE_ID const { data: chatSpaceMemoryCount } = useQuery({ queryKey: ["chat-empty-space-count", chatProject], queryFn: async (): Promise => { const response = await $fetch("@post/documents/documents", { body: { page: 1, limit: 1, sort: "createdAt", order: "desc", containerTags: [chatProject], }, disableValidation: true, }) if (response.error) return 0 const data = response.data as { pagination?: { totalItems?: number } } | null return data?.pagination?.totalItems ?? 0 }, staleTime: 30 * 1000, enabled: !!chatProject && !isAutoChatSpace, }) const emptyStateSubtitle = useMemo(() => { if (isAutoChatSpace) { return "Picks the best space for each question" } if (chatSpaceMemoryCount === undefined) { return `Grounded in ${chatSpaceLabel}` } if (chatSpaceMemoryCount === 0) { return `Nothing in ${chatSpaceLabel} yet` } const countLabel = chatSpaceMemoryCount.toLocaleString() const memoryWord = chatSpaceMemoryCount === 1 ? "memory" : "memories" return `${countLabel} ${memoryWord} in ${chatSpaceLabel}` }, [isAutoChatSpace, chatSpaceLabel, chatSpaceMemoryCount]) 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, spaceMode: selectedProjectRef.current === AUTO_CHAT_SPACE_ID ? "auto" : "manual", enableSpaceDiscovery: selectedProjectRef.current === AUTO_CHAT_SPACE_ID, model: selectedModelRef.current, }, }, }), }), [chatApiBase], ) const [pendingThreadLoad, setPendingThreadLoad] = useState<{ id: string messages: UIMessage[] } | null>(null) const [loadedThreadScrollTarget, setLoadedThreadScrollTarget] = useState<{ id: string messageCount: number lastMessageId: string | null } | 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) setLoadedThreadScrollTarget({ id: pendingThreadLoad.id, messageCount: pendingThreadLoad.messages.length, lastMessageId: pendingThreadLoad.messages[pendingThreadLoad.messages.length - 1] ?.id ?? null, }) 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 isScrolledToBottomRef.current = isAtBottom setIsScrolledToBottom(isAtBottom) }, []) const scrollToBottom = useCallback(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight isScrolledToBottomRef.current = true setIsScrolledToBottom(true) } }, []) const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) sendMessage({ text: input }) setInput("") userJustSentRef.current = true scrollToBottom() } const handleSuggestedQuestion = useCallback( (suggestion: string) => { if (status === "submitted" || status === "streaming") return if (!threadId) setThreadId(fallbackChatId) analytics.chatSuggestedQuestionClicked() analytics.chatMessageSent({ source: "suggested" }) sendMessage({ text: suggestion }) userJustSentRef.current = true scrollToBottom() }, [ fallbackChatId, sendMessage, setThreadId, status, threadId, 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=${chatProject}`, { 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) } }, [chatProject]) useEffect(() => { if (!isHistoryOpen) return fetchThreads() analytics.chatHistoryViewed?.() }, [isHistoryOpen, fetchThreads]) 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) const initialThreadIdRef = useRef(threadId) useEffect(() => { if (didAutoLoadRef.current) return const initialThreadId = initialThreadIdRef.current if (!initialThreadId) return didAutoLoadRef.current = true loadThread(initialThreadId) }, [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 analytics.chatMessageSent({ source: queuedMessageSource }) if (queuedHighlightContent) { // Start a fresh thread for highlight-based chats to avoid overwriting existing conversations const newChatId = generateId() chatIdRef.current = newChatId setThreadId(null) setFallbackChatId(newChatId) // Store the highlight message and user reply in refs. // We cannot call setMessages here because setFallbackChatId above triggers // useChat to recreate its internal Chat object (new id → new Chat), which // resets messages to []. Instead, pendingHighlightMessageRef is read by a // separate useEffect that fires after currentChatId has settled, ensuring // setMessages is called on the correct, freshly-created Chat instance. // targetHighlightChatIdRef ensures we only call setMessages once the new // Chat instance (with id=newChatId) is active, not the old one. pendingHighlightReplyRef.current = queuedMessage awaitingHighlightInjectionRef.current = true targetHighlightChatIdRef.current = newChatId pendingHighlightMessageRef.current = [ { id: generateId(), role: "assistant" as const, parts: [ { type: "text" as const, text: `Here is a highlight from your memories:\n\n${queuedHighlightContent}`, }, ], }, ] } else { if (!threadId) setThreadId(fallbackChatId) sendMessage({ text: queuedMessage }) } onConsumeQueuedMessage?.() } }, [ isChatOpen, queuedMessage, queuedHighlightContent, queuedMessageSource, initialSelectedModel, selectedModel, status, sendMessage, onConsumeQueuedMessage, fallbackChatId, setThreadId, threadId, ]) // Inject the pending highlight assistant message once the new Chat instance is ready. // This effect must run AFTER the currentChatId change has been committed and useChat // has recreated its internal Chat object, so that setMessages targets the correct instance. // We gate on currentChatId === targetHighlightChatIdRef to ensure we call setMessages // only when useChat's internal Chat has the new id (not the old one from before setFallbackChatId). useEffect(() => { if ( awaitingHighlightInjectionRef.current && pendingHighlightMessageRef.current && targetHighlightChatIdRef.current && currentChatId === targetHighlightChatIdRef.current ) { const msgs = pendingHighlightMessageRef.current pendingHighlightMessageRef.current = null targetHighlightChatIdRef.current = null setMessages(msgs) } }, [currentChatId, setMessages]) // Send pending highlight reply once the injected assistant message is committed useEffect(() => { if ( awaitingHighlightInjectionRef.current && pendingHighlightReplyRef.current && messages.length >= 1 && messages[0]?.role === "assistant" && status === "ready" ) { awaitingHighlightInjectionRef.current = false const reply = pendingHighlightReplyRef.current pendingHighlightReplyRef.current = null sendMessage({ text: reply }) } }, [messages, sendMessage, status]) // 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 or a thread is loaded useEffect(() => { const lastMessageId = messages[messages.length - 1]?.id ?? null const loadedThreadIsRendered = loadedThreadScrollTarget && currentChatId === loadedThreadScrollTarget.id && messages.length === loadedThreadScrollTarget.messageCount && lastMessageId === loadedThreadScrollTarget.lastMessageId if (loadedThreadIsRendered) { // Trigger the same scroll behavior as the button after loaded messages render. scrollToBottom() setTimeout(scrollToBottom, 50) setTimeout(scrollToBottom, 150) setTimeout(() => { scrollToBottom() setLoadedThreadScrollTarget(null) }, 300) return } const lastMessage = messages[messages.length - 1] if (lastMessage?.role === "user" && messagesContainerRef.current) { scrollToBottom() } else { checkIfScrolledToBottom() } }, [ currentChatId, loadedThreadScrollTarget, messages, checkIfScrolledToBottom, scrollToBottom, ]) useEffect(() => { const isStreaming = status === "streaming" const lastMessage = messages[messages.length - 1] const isLastMessageFromAssistant = lastMessage?.role === "assistant" if ( isStreaming && isLastMessageFromAssistant && (isScrolledToBottomRef.current || userJustSentRef.current) ) { scrollToBottom() } }, [status, messages, scrollToBottom]) useEffect(() => { const container = messagesContainerRef.current if (!container) return const isStreaming = status === "streaming" || status === "submitted" if (!isStreaming) { userJustSentRef.current = false return } const mutationObserver = new MutationObserver(() => { if (isScrolledToBottomRef.current || userJustSentRef.current) { requestAnimationFrame(() => { scrollToBottom() }) } }) mutationObserver.observe(container, { childList: true, subtree: true, characterData: true, }) return () => { mutationObserver.disconnect() } }, [status, scrollToBottom]) // Add scroll event listener to track scroll position useEffect(() => { const container = messagesContainerRef.current if (!container) return const handleScroll = () => { requestAnimationFrame(() => { checkIfScrolledToBottom() if (!isScrolledToBottomRef.current) { userJustSentRef.current = false } }) } container.addEventListener("scroll", handleScroll, { passive: true }) setTimeout(() => { checkIfScrolledToBottom() }, 100) 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 isResponding = status === "submitted" || status === "streaming" const showInputStatusStrip = !isStackedInput || isResponding || messages.length > 0 const chatHistorySheet = ( { setIsHistoryOpen(open) if (!open) { 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 && ( <> )}
{chatToolbarActions}
) : null}
{isInputExpanded && (
)} {messages.length === 0 && ( )}
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={isResponding} activeStatus={ status === "submitted" ? "Thinking…" : status === "streaming" ? "Structuring response…" : "Waiting for input…" } showStatusStrip={showInputStatusStrip} onExpandedChange={setIsInputExpanded} chainOfThoughtComponent={ messages.length > 0 ? : null } stackedToolbar={ isStackedInput ? ( <> ) : undefined } />
) return ( {chatHistorySheet} {isPageDesktop ? (
{pageDesktopToolbarRow}
{shell}
) : ( shell )}
) } export { HomeChatComposer } from "./home-chat-composer" export { ChatEmptyStatePlaceholder } from "./chat-empty-state"