"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 { PanelRightCloseIcon, SendIcon, CheckIcon, XIcon, Loader2, } from "lucide-react" import { collectValidUrls } from "@/lib/url-helpers" import { $fetch } from "@lib/api" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { useAuth } from "@lib/auth-context" import { useProject } from "@/stores" import { Streamdown } from "streamdown" import { useIsMobile } from "@hooks/use-mobile" interface ChatSidebarProps { formData: { twitter: string linkedin: string description: string otherLinks: string[] } | null } interface DraftDoc { kind: "likes" | "link" | "x_research" content: string metadata: Record title?: string url?: string } export function ChatSidebar({ formData }: ChatSidebarProps) { const { user } = useAuth() const { selectedProject } = useProject() const isMobile = useIsMobile() const [message, setMessage] = useState("") const [isChatOpen, setIsChatOpen] = useState(!isMobile) const [timelineMessages, setTimelineMessages] = useState< { message: string type?: "formData" | "exa" | "memory" | "waiting" memories?: { url: string title: string description: string fullContent: string }[] url?: string title?: string description?: string }[] >([]) const [isLoading, setIsLoading] = useState(false) const [isFetchingDrafts, setIsFetchingDrafts] = useState(false) const [draftDocs, setDraftDocs] = useState([]) const [xResearchStatus, setXResearchStatus] = useState< "correct" | "incorrect" | null >(null) const [isConfirmed, setIsConfirmed] = useState(false) const [processingByUrl, setProcessingByUrl] = useState>( {}, ) const displayedMemoriesRef = useRef>(new Set()) const contextInjectedRef = useRef(false) const draftsBuiltRef = useRef(false) const isProcessingRef = useRef(false) const draftRequestIdRef = useRef(0) const { messages: chatMessages, sendMessage, status, } = useChat({ transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`, credentials: "include", body: { metadata: { projectId: selectedProject, model: "gemini-2.5-pro", }, }, }), }) const buildOnboardingContext = useCallback(() => { if (!formData) return "" const contextParts: string[] = [] if (formData.description?.trim()) { contextParts.push(`User's interests/likes: ${formData.description}`) } if (formData.twitter) { contextParts.push(`X/Twitter profile: ${formData.twitter}`) } if (formData.linkedin) { contextParts.push(`LinkedIn profile: ${formData.linkedin}`) } if (formData.otherLinks.length > 0) { contextParts.push(`Other links: ${formData.otherLinks.join(", ")}`) } const memoryTexts = timelineMessages .filter((msg) => msg.type === "memory" && msg.memories) .flatMap( (msg) => msg.memories?.map((m) => `${m.title}: ${m.description}`) || [], ) if (memoryTexts.length > 0) { contextParts.push(`Extracted memories:\n${memoryTexts.join("\n")}`) } return contextParts.join("\n\n") }, [formData, timelineMessages]) const handleSend = () => { if (!message.trim() || status === "submitted" || status === "streaming") return let messageToSend = message const context = buildOnboardingContext() if (context && !contextInjectedRef.current && chatMessages.length === 0) { messageToSend = `${context}\n\nUser question: ${message}` contextInjectedRef.current = true } sendMessage({ text: messageToSend }) setMessage("") } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() } } const toggleChat = () => { setIsChatOpen(!isChatOpen) } const pollForMemories = useCallback( async (documentIds: string[]) => { const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max const pollInterval = 3000 // 3 seconds for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const response = await $fetch("@get/documents/:id", { params: { id: documentIds[0] ?? "" }, disableValidation: true, }) console.log("response", response) if (response.data) { const document = response.data if (document.memories && document.memories.length > 0) { const newMemories: { url: string title: string description: string fullContent: string }[] = [] document.memories.forEach( (memory: { memory: string; title?: string }) => { if (!displayedMemoriesRef.current.has(memory.memory)) { displayedMemoriesRef.current.add(memory.memory) newMemories.push({ url: document.url || "", title: memory.title || document.title || "Memory", description: memory.memory || "", fullContent: memory.memory || "", }) } }, ) if (newMemories.length > 0 && timelineMessages.length < 10) { setTimelineMessages((prev) => [ ...prev, { message: newMemories .map((memory) => memory.description) .join("\n"), type: "memory" as const, memories: newMemories, }, ]) } } if (document.memories && document.memories.length > 0) { break } } await new Promise((resolve) => setTimeout(resolve, pollInterval)) } catch (error) { console.warn("Error polling for memories:", error) await new Promise((resolve) => setTimeout(resolve, pollInterval)) } } }, [timelineMessages.length], ) const buildDraftDocs = useCallback(async () => { if (!formData || draftsBuiltRef.current) return draftsBuiltRef.current = true const hasContent = formData.twitter || formData.linkedin || formData.otherLinks.length > 0 || formData.description?.trim() if (!hasContent) return const requestId = ++draftRequestIdRef.current setIsFetchingDrafts(true) const drafts: DraftDoc[] = [] const urls = collectValidUrls(formData.linkedin, formData.otherLinks) const allProcessingUrls: string[] = [...urls] if (formData.twitter) { allProcessingUrls.push(formData.twitter) } if (allProcessingUrls.length > 0) { setProcessingByUrl((prev) => { const next = { ...prev } for (const url of allProcessingUrls) { next[url] = true } return next }) } try { if (formData.description?.trim()) { drafts.push({ kind: "likes", content: formData.description, metadata: { sm_source: "consumer", description_source: "user_input", }, title: "Your Interests", }) } // Fetch each URL separately for per-link loading state const linkPromises = urls.map(async (url) => { try { const response = await fetch("/api/onboarding/extract-content", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ urls: [url] }), }) const data = await response.json() return data.results?.[0] || null } catch { return null } finally { // Clear this URL's processing state if (draftRequestIdRef.current === requestId) { setProcessingByUrl((prev) => ({ ...prev, [url]: false })) } } }) // Fetch X/Twitter research const xResearchPromise = formData.twitter ? (async () => { try { const response = await fetch("/api/onboarding/research", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ xUrl: formData.twitter, name: user?.name, email: user?.email, }), }) if (!response.ok) return null const data = await response.json() return data?.text?.trim() || null } catch { return null } finally { // Clear twitter URL's processing state if (draftRequestIdRef.current === requestId) { setProcessingByUrl((prev) => ({ ...prev, [formData.twitter]: false, })) } } })() : Promise.resolve(null) const [exaResults, xResearchResult] = await Promise.all([ Promise.all(linkPromises), xResearchPromise, ]) // Guard against stale request completing after a newer one if (draftRequestIdRef.current !== requestId) return for (const result of exaResults) { if (result && (result.text || result.description)) { drafts.push({ kind: "link", content: result.text || result.description || "", metadata: { sm_source: "consumer", exa_url: result.url, exa_title: result.title, }, title: result.title || "Extracted Content", url: result.url, }) } } if (xResearchResult) { drafts.push({ kind: "x_research", content: xResearchResult, metadata: { sm_source: "consumer", onboarding_source: "x_research", x_url: formData.twitter, }, title: "X/Twitter Profile Research", url: formData.twitter, }) } setDraftDocs(drafts) } catch (error) { console.warn("Error building draft docs:", error) } finally { if (draftRequestIdRef.current === requestId) { setIsFetchingDrafts(false) } } }, [formData, user]) const handleConfirmDocs = useCallback(async () => { if (isConfirmed || isProcessingRef.current) return isProcessingRef.current = true setIsConfirmed(true) setIsLoading(true) try { const documentIds: string[] = [] for (const draft of draftDocs) { if (draft.kind === "x_research" && xResearchStatus !== "correct") { continue } try { const docResponse = await $fetch("@post/documents", { body: { content: draft.content, containerTags: ["sm_project_default"], metadata: draft.metadata, }, }) if (docResponse.data?.id) { documentIds.push(docResponse.data.id) } } catch (error) { console.warn("Error creating document:", error) } } if (documentIds.length > 0) { await pollForMemories(documentIds) } } catch (error) { console.warn("Error confirming documents:", error) setIsConfirmed(false) } finally { setIsLoading(false) isProcessingRef.current = false } }, [draftDocs, xResearchStatus, isConfirmed, pollForMemories]) useEffect(() => { if (!formData) return const formDataMessages: typeof timelineMessages = [] if (formData.twitter) { formDataMessages.push({ message: formData.twitter, url: formData.twitter, title: "X/Twitter", description: formData.twitter, type: "formData" as const, }) } if (formData.linkedin) { formDataMessages.push({ message: formData.linkedin, url: formData.linkedin, title: "LinkedIn", description: formData.linkedin, type: "formData" as const, }) } if (formData.otherLinks.length > 0) { formData.otherLinks.forEach((link) => { formDataMessages.push({ message: link, url: link, title: "Link", description: link, type: "formData" as const, }) }) } if (formData.description?.trim()) { formDataMessages.push({ message: formData.description, title: "Likes", description: formData.description, type: "formData" as const, }) } setTimelineMessages(formDataMessages) buildDraftDocs() }, [formData, buildDraftDocs]) return ( {!isChatOpen ? ( {!isMobile && "Chat with Nova"} ) : ( {isMobile ? ( ) : ( <> Close chat )}
{timelineMessages.map((msg, i) => (
{msg.type === "waiting" ? (
{msg.message}
) : ( <>
{i === 0 && (
)}
{msg.type === "formData" && (
{msg.title && (

{msg.title}

{msg.url && processingByUrl[msg.url] && ( )}
)} {msg.url && ( {msg.url} )} {msg.title === "Likes" && msg.description && (

{msg.description}

)}
)} {msg.type === "memory" && (
{msg.memories?.map((memory) => (
{memory.title && (

{memory.title}

)} {memory.url && ( {memory.url} )} {memory.description && (

{memory.description}

)}
))}
)} )}
))} {chatMessages.map((msg) => { if (msg.role === "user") { const text = msg.parts .filter((part) => part.type === "text") .map((part) => part.text) .join(" ") return (

{text}

) } if (msg.role === "assistant") { return (
{msg.parts.map((part, partIndex) => { if (part.type === "text") { return (
{part.text}
) } if (part.type === "tool-searchMemories") { if ( part.state === "input-available" || part.state === "input-streaming" ) { return (
Searching memories...
) } } return null })}
) } return null })} {(status === "submitted" || status === "streaming") && chatMessages[chatMessages.length - 1]?.role === "user" && (
Thinking...
)} {timelineMessages.length === 0 && chatMessages.length === 0 && !isLoading && !formData && (
Waiting for your input
)} {isLoading && (
Extracting memories...
)}
{draftDocs.some((d) => d.kind === "x_research") && !isConfirmed && (

Your Profile Summary

{draftDocs.find((d) => d.kind === "x_research")?.content}

Is this accurate?
{xResearchStatus === "incorrect" && ( <>

If incorrect, share your info in the input below, or you can add memories later as well.

)}
)} {!draftDocs.some((d) => d.kind === "x_research") && draftDocs.length > 0 && !isConfirmed && (
)}
{isFetchingDrafts && (
Getting all relevant info about you...
)}
{ e.preventDefault() if (message.trim()) { handleSend() } }} > setMessage(e.target.value)} onKeyDown={handleKeyDown} placeholder="Chat with your Supermemory" className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10" disabled={status === "submitted" || status === "streaming"} />
)} ) }