"use client"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/components/dialog"
import { ScrollArea } from "@ui/components/scroll-area"
import {
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 { 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 ChatSidebar({
isChatOpen,
setIsChatOpen,
queuedMessage,
onConsumeQueuedMessage,
emptyStateSuggestions,
}: {
isChatOpen: boolean
setIsChatOpen: (open: boolean) => void
queuedMessage?: string | null
onConsumeQueuedMessage?: () => void
emptyStateSuggestions?: string[]
}) {
const isMobile = useIsMobile()
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 [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 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 chatTransport = useMemo(
() =>
new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/chat`,
credentials: "include",
prepareSendMessagesRequest: ({ messages }) => ({
body: {
messages,
metadata: {
chatId: chatIdRef.current,
projectId: selectedProject,
model: selectedModel,
},
},
}),
}),
[selectedProject, selectedModel],
)
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
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])
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
analytics.chatMessageSent({ source: "typed" })
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) => {
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: unknown
createdAt: string
}) => ({
id: m.id,
role: m.role,
parts: m.parts || [],
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],
)
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
) {
sentQueuedMessageRef.current = queuedMessage
analytics.chatMessageSent({ source: "highlight" })
sendMessage({ text: queuedMessage })
onConsumeQueuedMessage?.()
}
}, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage])
// 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])
return (
{!isChatOpen ? (
Chat with Nova
) : (
{!isMobile && (
T
)}
{/*
{isMobile ? (
) : (
)}
*/}
{isInputExpanded && (
)}
{messages.length === 0 && (
{
analytics.chatSuggestedQuestionClicked()
analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions}
/>
)}
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" ? (
) : (
)}
))}
{(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 (
{
handleModelChange(id)
analytics.modelChanged({ model: id })
}}
>
Switch to {m.name} {m.version}
)
})}
)}
)}
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
}
/>
)}
)
}