"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
) : (