mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-18 23:36:00 +00:00
926 lines
28 KiB
TypeScript
926 lines
28 KiB
TypeScript
"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 (
|
|
<div
|
|
id="chat-empty-state"
|
|
className="flex flex-col items-center justify-center h-full"
|
|
>
|
|
<div className="relative w-32 h-32">
|
|
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16" />
|
|
<LogoBgGradient className="w-full h-full" />
|
|
</div>
|
|
<div className="gap-3 flex flex-col items-center justify-center">
|
|
<p>Ask me anything about your memories...</p>
|
|
<div
|
|
className={cn(
|
|
dmSansClassName(),
|
|
"flex flex-col gap-2 justify-center items-center",
|
|
)}
|
|
>
|
|
{suggestions.map((suggestion) => (
|
|
<Button
|
|
key={suggestion}
|
|
variant="default"
|
|
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
|
|
onClick={() => onSuggestionClick(suggestion)}
|
|
>
|
|
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
|
|
<span className="text-[#267BF1] text-[12px] truncate">
|
|
{suggestion}
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<ModelId>("gemini-2.5-pro")
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
|
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
|
const [messageFeedback, setMessageFeedback] = useState<
|
|
Record<string, "like" | "dislike" | null>
|
|
>({})
|
|
const [expandedMemories, setExpandedMemories] = useState<string | null>(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<string | null>(
|
|
null,
|
|
)
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
|
const sentQueuedMessageRef = useRef<string | null>(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 (
|
|
<AnimatePresence mode="wait">
|
|
{!isChatOpen ? (
|
|
<motion.div
|
|
key="closed"
|
|
className={cn(
|
|
"flex items-start justify-start",
|
|
isMobile
|
|
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
|
|
: "absolute top-[-10px] right-0 m-4",
|
|
dmSansClassName(),
|
|
)}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border text-white cursor-pointer whitespace-nowrap",
|
|
isMobile
|
|
? "gap-2.5 px-5 py-3 text-[15px] border-[#1E2128] shadow-[0_8px_32px_rgba(0,0,0,0.5),0_2px_8px_rgba(0,0,0,0.3)]"
|
|
: "border-[#17181A] shadow-lg",
|
|
)}
|
|
style={{
|
|
background: isMobile
|
|
? "linear-gradient(135deg, #12161C 0%, #0A0D12 100%)"
|
|
: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
|
}}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
|
|
<span className={cn(isMobile && "font-medium")}>
|
|
Chat with Nova
|
|
</span>
|
|
</motion.button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="open"
|
|
className={cn(
|
|
"bg-[#05070A] backdrop-blur-md flex flex-col border border-[#17181AB2] relative pt-4",
|
|
isMobile
|
|
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
|
|
: "w-[450px] rounded-2xl m-4 mt-2",
|
|
dmSansClassName(),
|
|
)}
|
|
style={
|
|
isMobile
|
|
? undefined
|
|
: {
|
|
height: `calc(100vh - ${heightOffset}px)`,
|
|
}
|
|
}
|
|
initial={
|
|
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
|
}
|
|
animate={{ x: 0, y: 0, opacity: 1 }}
|
|
exit={
|
|
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
|
}
|
|
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute top-0 left-0 right-0 flex items-center justify-between pt-4 px-4",
|
|
!isMobile && "rounded-t-2xl",
|
|
)}
|
|
style={{
|
|
background:
|
|
"linear-gradient(180deg, #0A0E14 40.49%, rgba(10, 14, 20, 0.00) 100%)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0 flex-1 mr-2">
|
|
<ChatModelSelector
|
|
selectedModel={selectedModel}
|
|
onModelChange={handleModelChange}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"inline-flex h-10 max-w-[min(192px,42vw)] shrink min-w-0 items-center rounded-full border border-[#73737333] bg-[#0D121A] px-3",
|
|
dmSansClassName(),
|
|
)}
|
|
style={{
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
title={chatSpaceLabel}
|
|
>
|
|
<span className="truncate text-sm text-white">
|
|
{chatSpaceLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Dialog
|
|
open={isHistoryOpen}
|
|
onOpenChange={(open) => {
|
|
setIsHistoryOpen(open)
|
|
if (open) {
|
|
fetchThreads()
|
|
analytics.chatHistoryViewed?.()
|
|
} else {
|
|
setConfirmingDeleteId(null)
|
|
}
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="headers"
|
|
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
|
style={{
|
|
boxShadow:
|
|
"1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
>
|
|
<HistoryIcon className="size-4 text-[#737373]" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
|
|
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
|
|
<DialogTitle>Chat History</DialogTitle>
|
|
<DialogDescription className="text-[#737373]">
|
|
Space: {chatSpaceLabel}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<ScrollArea className="max-h-96">
|
|
{isLoadingThreads ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<SuperLoader label="Loading..." />
|
|
</div>
|
|
) : threads.length === 0 ? (
|
|
<div className="text-sm text-[#737373] text-center py-8">
|
|
No conversations yet
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{threads.map((thread) => {
|
|
const isActive = thread.id === currentChatId
|
|
return (
|
|
<button
|
|
key={thread.id}
|
|
type="button"
|
|
onClick={() => loadThread(thread.id)}
|
|
className={cn(
|
|
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
|
|
isActive
|
|
? "bg-[#267BF1]/10"
|
|
: "hover:bg-[#17181A]",
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium truncate">
|
|
{thread.title || "Untitled Chat"}
|
|
</div>
|
|
<div className="text-xs text-[#737373]">
|
|
{formatRelativeTime(thread.updatedAt)}
|
|
</div>
|
|
</div>
|
|
{confirmingDeleteId === thread.id ? (
|
|
<div className="flex items-center gap-1 ml-2">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
deleteThread(thread.id)
|
|
}}
|
|
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
|
|
>
|
|
<Check className="size-3" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setConfirmingDeleteId(null)
|
|
}}
|
|
className="h-7 w-7"
|
|
>
|
|
<XIcon className="size-3 text-[#737373]" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setConfirmingDeleteId(thread.id)
|
|
}}
|
|
className="h-7 w-7 ml-2"
|
|
>
|
|
<Trash2 className="size-3 text-[#737373]" />
|
|
</Button>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
|
|
onClick={() => {
|
|
handleNewChat()
|
|
setIsHistoryOpen(false)
|
|
}}
|
|
>
|
|
<Plus className="size-4 mr-1" /> New Conversation
|
|
</Button>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Button
|
|
variant="headers"
|
|
className="rounded-full text-base gap-3 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
|
style={{
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
onClick={handleNewChat}
|
|
title="New chat (T)"
|
|
>
|
|
<SquarePenIcon className="size-4 text-[#737373]" />
|
|
{!isMobile && (
|
|
<span
|
|
className={cn(
|
|
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
T
|
|
</span>
|
|
)}
|
|
</Button>
|
|
{/*<motion.button
|
|
onClick={toggleChat}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
|
|
isMobile && "bg-[#0D121A] border border-[#73737333]",
|
|
)}
|
|
style={
|
|
isMobile
|
|
? {
|
|
boxShadow:
|
|
"1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}
|
|
: undefined
|
|
}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
{isMobile ? (
|
|
<XIcon className="size-4" />
|
|
) : (
|
|
<PanelRightCloseIcon className="size-4" />
|
|
)}
|
|
</motion.button>*/}
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className={cn(
|
|
"flex-1 overflow-y-auto px-4 scrollbar-thin",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{isInputExpanded && (
|
|
<div
|
|
className="absolute inset-0 z-10! rounded-2xl pointer-events-none"
|
|
style={{ backgroundColor: "#000000E5" }}
|
|
/>
|
|
)}
|
|
{messages.length === 0 && (
|
|
<ChatEmptyStatePlaceholder
|
|
onSuggestionClick={(suggestion) => {
|
|
analytics.chatSuggestedQuestionClicked()
|
|
analytics.chatMessageSent({ source: "suggested" })
|
|
sendMessage({ text: suggestion })
|
|
}}
|
|
suggestions={emptyStateSuggestions}
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
messages.length > 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
|
|
<div
|
|
key={message.id}
|
|
className={cn(
|
|
"flex gap-2 w-full",
|
|
message.role === "user" ? "justify-end" : "justify-start",
|
|
)}
|
|
onMouseEnter={() =>
|
|
message.role === "assistant" &&
|
|
setHoveredMessageId(message.id)
|
|
}
|
|
onMouseLeave={() =>
|
|
message.role === "assistant" && setHoveredMessageId(null)
|
|
}
|
|
>
|
|
{message.role === "user" ? (
|
|
<UserMessage
|
|
message={message}
|
|
copiedMessageId={copiedMessageId}
|
|
onCopy={handleCopyMessage}
|
|
/>
|
|
) : (
|
|
<AgentMessage
|
|
message={message}
|
|
index={index}
|
|
messagesLength={messages.length}
|
|
hoveredMessageId={hoveredMessageId}
|
|
copiedMessageId={copiedMessageId}
|
|
messageFeedback={messageFeedback}
|
|
expandedMemories={expandedMemories}
|
|
onCopy={handleCopyMessage}
|
|
onLike={handleLikeMessage}
|
|
onDislike={handleDislikeMessage}
|
|
onToggleMemories={handleToggleMemories}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(status === "submitted" || status === "streaming") && (
|
|
<div className="flex gap-2">
|
|
<SuperLoader label="Thinking..." />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!isScrolledToBottom && messages.length > 0 && (
|
|
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
|
<button
|
|
type="button"
|
|
className="cursor-pointer pointer-events-auto"
|
|
onClick={scrollToBottom}
|
|
>
|
|
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
|
|
<ChevronDownIcon className="size-4 text-white" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{chatStreamError && (
|
|
<div
|
|
role="alert"
|
|
className={cn(
|
|
"mx-4 mb-2 rounded-lg bg-amber-950/40 px-3 py-2 text-sm text-amber-50/95",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<div className="flex justify-between gap-2 items-start">
|
|
<div className="min-w-0">
|
|
<p className="font-medium leading-snug">
|
|
{chatStreamError.title}
|
|
</p>
|
|
<p className="text-xs text-amber-100/70 mt-1 leading-snug">
|
|
{chatStreamError.body}
|
|
</p>
|
|
{chatStreamError.otherModels.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{chatStreamError.otherModels.map((id) => {
|
|
const m = modelNames[id]
|
|
return (
|
|
<Button
|
|
key={id}
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-8 text-xs rounded-full bg-[#141922] border-[#73737333] hover:bg-[#1a2230] text-white/90"
|
|
onClick={() => {
|
|
handleModelChange(id)
|
|
analytics.modelChanged({ model: id })
|
|
}}
|
|
>
|
|
Switch to {m.name} {m.version}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={clearError}
|
|
className="shrink-0 p-1 rounded-md text-amber-200/50 hover:text-amber-100/90 hover:bg-white/5"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ChatInput
|
|
value={input}
|
|
onChange={(e) => 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 ? (
|
|
<ChainOfThought messages={messages} />
|
|
) : null
|
|
}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|