"use client" import { useState, useEffect, useCallback, useRef } from "react" import { motion, AnimatePresence } 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 { ChevronDownIcon, HistoryIcon, PanelRightCloseIcon, SearchIcon, SquarePenIcon, } from "lucide-react" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import ChatInput from "./input" import ChatModelSelector from "./model-selector" import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo" import { useProject, usePersistentChat } from "@/stores" import type { ModelId } from "@/lib/models" import { SuperLoader } from "../../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage } from "./message/agent-message" import { ChainOfThought } from "./input/chain-of-thought" function ChatEmptyStatePlaceholder({ onSuggestionClick, }: { onSuggestionClick: (suggestion: string) => void }) { const 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?", ] return (

Ask me anything about your memories...

{suggestions.map((suggestion) => ( ))}
) } export function ChatSidebar({ isChatOpen, setIsChatOpen, }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void }) { const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") const [copiedMessageId, setCopiedMessageId] = useState(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) const [messageFeedback, setMessageFeedback] = useState< Record >({}) const [expandedMemories, setExpandedMemories] = useState(null) const [followUpQuestions, setFollowUpQuestions] = useState< Record >({}) const [loadingFollowUps, setLoadingFollowUps] = useState< Record >({}) const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const pendingFollowUpGenerations = useRef>(new Set()) const messagesContainerRef = useRef(null) const { selectedProject } = useProject() const { setCurrentChatId } = usePersistentChat() const { messages, sendMessage, status, setMessages, stop } = useChat({ transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`, credentials: "include", body: { metadata: { projectId: selectedProject, model: selectedModel, }, }, }), onFinish: async (result) => { if (result.message.role !== "assistant") return // Mark this message as needing follow-up generation // We'll generate it after the message is fully in the messages array if (result.message.id) { pendingFollowUpGenerations.current.add(result.message.id) } }, }) // Generate follow-up questions after assistant messages are complete useEffect(() => { const generateFollowUps = async () => { // Find assistant messages that need follow-up generation const messagesToProcess = messages.filter( (msg) => msg.role === "assistant" && pendingFollowUpGenerations.current.has(msg.id) && !followUpQuestions[msg.id] && !loadingFollowUps[msg.id], ) for (const message of messagesToProcess) { // Get complete text from the message const assistantText = message.parts .filter((p) => p.type === "text") .map((p) => p.text) .join(" ") .trim() // Only generate if we have substantial text (at least 50 chars) // This ensures the message is complete, not just the first chunk // Also check if status is idle to ensure streaming is complete if ( assistantText.length < 50 || status === "streaming" || status === "submitted" ) { continue } // Mark as processing pendingFollowUpGenerations.current.delete(message.id) setLoadingFollowUps((prev) => ({ ...prev, [message.id]: true, })) try { // Get recent messages for context const recentMessages = messages.slice(-5).map((msg) => ({ role: msg.role, content: msg.parts .filter((p) => p.type === "text") .map((p) => p.text) .join(" "), })) const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ messages: recentMessages, assistantResponse: assistantText, }), }, ) if (response.ok) { const data = await response.json() if (data.questions && Array.isArray(data.questions)) { setFollowUpQuestions((prev) => ({ ...prev, [message.id]: data.questions, })) } } } catch (error) { console.error("Failed to generate follow-up questions:", error) } finally { setLoadingFollowUps((prev) => ({ ...prev, [message.id]: false, })) } } } // Only generate if not currently streaming or submitted // Small delay to ensure message is fully processed if (status !== "streaming" && status !== "submitted") { const timeoutId = setTimeout(() => { generateFollowUps() }, 300) return () => clearTimeout(timeoutId) } }, [messages, followUpQuestions, loadingFollowUps, status]) 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 sendMessage({ text: input }) setInput("") scrollToBottom() } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() } } const toggleChat = () => { setIsChatOpen(!isChatOpen) } const handleCopyMessage = useCallback((messageId: string, text: string) => { navigator.clipboard.writeText(text) setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) }, []) const handleLikeMessage = useCallback((messageId: string) => { setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "like" ? null : "like", })) }, []) const handleDislikeMessage = useCallback((messageId: string) => { setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "dislike" ? null : "dislike", })) }, []) const handleToggleMemories = useCallback((messageId: string) => { setExpandedMemories((prev) => (prev === messageId ? null : messageId)) }, []) const handleNewChat = useCallback(() => { const newId = crypto.randomUUID() setCurrentChatId(newId) setMessages([]) setInput("") }, [setCurrentChatId, setMessages]) 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]) // 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]) return ( {!isChatOpen ? ( Chat with Nova ) : (
{isInputExpanded && (
)} {messages.length === 0 && ( { sendMessage({ text: suggestion }) }} /> )}
0 ? "flex flex-col space-y-3 min-h-full justify-end 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" ? ( ) : ( { setInput(question) }} /> )}
))} {(status === "submitted" || status === "streaming") && messages[messages.length - 1]?.role === "user" && (
)}
{!isScrolledToBottom && messages.length > 0 && (
)} 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 } /> )} ) }