added chat pane items

This commit is contained in:
Mahesh Sanikommmu 2025-11-21 08:49:15 -08:00
parent ed6b6fb2fc
commit 51fb9ddfc7
15 changed files with 1234 additions and 318 deletions

View file

@ -1,7 +1,7 @@
"use client"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat-sidebar"
import { ChatSidebar } from "@/components/chat"
import { AnimatePresence } from "motion/react"
import { MemoriesGrid } from "@/components/new/memories-grid"

View file

@ -0,0 +1,309 @@
"use client"
import { useState, useEffect, useCallback } 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 {
HistoryIcon,
PanelRightCloseIcon,
SearchIcon,
SquarePenIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/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"
export function ChatSidebar() {
const [input, setInput] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
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 { selectedProject } = useProject()
const { setCurrentChatId } = usePersistentChat()
const { messages, sendMessage, status, setMessages, stop } = useChat({
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
credentials: "include",
body: {
metadata: {
projectId: selectedProject,
model: selectedModel,
},
},
}),
maxSteps: 10,
})
const handleSend = () => {
if (!input.trim() || status === "submitted" || status === "streaming")
return
sendMessage({ text: input })
setInput("")
}
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(() => {
console.log("handleNewChat")
const newId = crypto.randomUUID()
setCurrentChatId(newId)
setMessages([])
setInput("")
}, [setCurrentChatId, setMessages])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key.toLowerCase() === "t" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isChatOpen &&
document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA"
) {
e.preventDefault()
handleNewChat()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isChatOpen, handleNewChat])
return (
<AnimatePresence mode="wait">
{!isChatOpen ? (
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="!blur-none z-10" />
Chat with Nova
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2] relative",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<div className="flex items-center justify-between pt-4 px-6 pb-3 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.4)]">
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
<div className="flex items-center gap-2">
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<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]" />
<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="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
</motion.button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 scrollbar-thin">
{messages.length === 0 && <ChatEmptyStatePlaceholder />}
<div
className={cn(
messages.length > 0
? "flex flex-col space-y-3 min-h-full justify-end"
: "",
)}
>
{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") &&
messages[messages.length - 1]?.role === "user" && (
<div className="flex gap-2">
<SuperLoader label="Thinking..." />
</div>
)}
</div>
</div>
<div className="px-4 pb-4 pt-2">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={handleSend}
onStop={stop}
onKeyDown={handleKeyDown}
isResponding={status === "submitted" || status === "streaming"}
/>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
function ChatEmptyStatePlaceholder() {
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 (
<div className="flex flex-col items-center justify-center h-full">
<div className="relative">
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
<LogoBgGradient className="" />
</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-[#0D121A] border w-fit"
>
<SearchIcon className="size-4 text-[#267BF1]" />
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
</Button>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,116 @@
"use client"
import { ArrowUpIcon, ChevronUpIcon, SquareIcon } from "lucide-react"
import NovaOrb from "../nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { useRef, useState } from "react"
interface ChatInputProps {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onSend: () => void
onStop: () => void
onKeyDown?: (e: React.KeyboardEvent) => void
isResponding?: boolean
}
export default function ChatInput({
value,
onChange,
onSend,
onStop,
onKeyDown,
isResponding = false,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
const textarea = e.target
textarea.style.height = "auto"
// Set height based on scrollHeight, with a max of ~96px (4-5 lines)
const newHeight = Math.min(textarea.scrollHeight, 96)
textarea.style.height = `${newHeight}px`
setIsMultiline(textarea.scrollHeight > 52)
}
return (
<div className="bg-[#01173C] rounded-xl">
<div className=" p-3 pr-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<NovaOrb size={24} className="!blur-none z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
Waiting for input...
</p>
</div>
<ChevronUpIcon className="size-4 text-[#525D6E]" />
</div>
<div
className={cn(
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
</div>
)
}
function SendButton({
onClick,
disabled,
}: {
onClick: () => void
disabled: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 transition-opacity",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-[#161F2C]",
)}
>
<ArrowUpIcon className="size-5 text-white" />
</button>
)
}
function StopButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
>
<SquareIcon className="size-4 text-white fill-white" />
</button>
)
}

View file

@ -0,0 +1,96 @@
"use client"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
interface AgentMessageProps {
message: UIMessage
index: number
messagesLength: number
hoveredMessageId: string | null
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
expandedMemories: string | null
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
onToggleMemories: (messageId: string) => void
}
export function AgentMessage({
message,
index,
messagesLength,
hoveredMessageId,
copiedMessageId,
messageFeedback,
expandedMemories,
onCopy,
onLike,
onDislike,
onToggleMemories,
}: AgentMessageProps) {
const isLastAgentMessage =
index === messagesLength - 1 && message.role === "assistant"
const isHovered = hoveredMessageId === message.id
const messageText = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
return (
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<RelatedMemories
message={message}
expandedMemories={expandedMemories}
onToggle={onToggleMemories}
/>
{message.parts.map((part, partIndex) => {
if (part.type === "text") {
return (
<div
key={`${message.id}-${partIndex}`}
className="text-sm text-white/90"
>
<Streamdown>{part.text}</Streamdown>
</div>
)
}
if (part.type === "tool-searchMemories") {
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
return (
<div
key={`${message.id}-${partIndex}`}
className="text-xs text-white/50 italic"
>
Searching memories...
</div>
)
}
}
return null
})}
</div>
</div>
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
</div>
)
}

View file

@ -0,0 +1,80 @@
import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
import { cn } from "@lib/utils"
interface MessageActionsProps {
messageId: string
messageText: string
isLastMessage: boolean
isHovered: boolean
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
}
export function MessageActions({
messageId,
messageText,
isLastMessage,
isHovered,
copiedMessageId,
messageFeedback,
onCopy,
onLike,
onDislike,
}: MessageActionsProps) {
const shouldShowActions = isHovered || isLastMessage
return (
<div
className={cn(
"flex items-center gap-1 transition-opacity duration-200",
shouldShowActions ? "opacity-100" : "opacity-0",
)}
>
<button
type="button"
onClick={() => onCopy(messageId, messageText)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Copy message"
>
{copiedMessageId === messageId ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50 hover:text-white/80" />
)}
</button>
<button
type="button"
onClick={() => onLike(messageId)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Like message"
>
<ThumbsUp
className={cn(
"size-3.5 transition-colors",
messageFeedback[messageId] === "like"
? "text-green-400 fill-green-400"
: "text-white/50 hover:text-white/80",
)}
/>
</button>
<button
type="button"
onClick={() => onDislike(messageId)}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Dislike message"
>
<ThumbsDown
className={cn(
"size-3.5 transition-colors",
messageFeedback[messageId] === "dislike"
? "text-red-400 fill-red-400"
: "text-white/50 hover:text-white/80",
)}
/>
</button>
</div>
)
}

View file

@ -0,0 +1,124 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
interface MemoryResult {
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface RelatedMemoriesProps {
message: UIMessage
expandedMemories: string | null
onToggle: (messageId: string) => void
}
export function RelatedMemories({
message,
expandedMemories,
onToggle,
}: RelatedMemoriesProps) {
const memoryResults: MemoryResult[] = []
message.parts.forEach((part) => {
if (
part.type === "tool-searchMemories" &&
part.state === "output-available"
) {
const output = part.output as { results?: MemoryResult[] } | undefined
const results = Array.isArray(output?.results) ? output.results : []
memoryResults.push(...results)
}
})
if (memoryResults.length === 0) {
return null
}
const isExpanded = expandedMemories === message.id
return (
<div className="mb-2">
<button
type="button"
className="flex items-center gap-2 text-white/50 hover:text-white/70 transition-colors text-sm"
onClick={() => onToggle(message.id)}
>
Related Memories
{isExpanded ? (
<ChevronUpIcon className="size-3.5" />
) : (
<ChevronDownIcon className="size-3.5" />
)}
</button>
{isExpanded && (
<div className="mt-2 grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 rounded-xl">
{result.title && (
<div className="text-xs text-white/60 line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-white/60 line-clamp-2">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-blue-400 mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="text-xs text-white/50 mt-1 p-1 text-center">
Relevance Score: {(result.score * 100).toFixed(1)}%
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn(
"bg-[#0C1829] rounded-xl border border-white/10",
dmSansClassName(),
)}
key={result.documentId || idx}
>
{content}
</div>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,41 @@
"use client"
import { Copy, Check } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
interface UserMessageProps {
message: UIMessage
copiedMessageId: string | null
onCopy: (messageId: string, text: string) => void
}
export function UserMessage({
message,
copiedMessageId,
onCopy,
}: UserMessageProps) {
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
return (
<div className="flex flex-col items-end w-full">
<div className="bg-[#293952]/60 rounded-lg p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50" />
)}
</button>
</div>
)
}

View file

@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/utils/fonts"
import { ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
interface ChatModelSelectorProps {
selectedModel?: ModelId
onModelChange?: (model: ModelId) => void
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] = useState<ModelId>("gemini-2.5-pro")
const [isOpen, setIsOpen] = useState(false)
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
onModelChange(modelId)
} else {
setInternalModel(modelId)
}
setIsOpen(false)
}
return (
<div className="relative flex items-center gap-2">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-1 !h-10 border-[#73737333] bg-[#0D121A]",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
{isOpen && (
<>
<button
type="button"
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
aria-label="Close model selector"
/>
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
return (
<button
key={model.id}
type="button"
className={cn(
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-[#737373]">
{modelData.version}
</span>
</div>
<div className="text-xs text-[#737373] truncate w-full">
{model.description}
</div>
</button>
)
})}
</div>
</div>
</>
)}
</div>
)
}

View file

@ -1,220 +0,0 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import {
HistoryIcon,
PanelRightCloseIcon,
SendIcon,
SquarePenIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
export function ChatSidebar() {
const [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
// Static placeholder messages for UI display
const messages = [
//{
// message: "Sample memory content",
// type: "memory" as const,
// memories: [
// {
// url: "https://example.com",
// title: "Example Memory",
// description: "This is a sample memory description for UI display purposes.",
// fullContent: "This is a sample memory description for UI display purposes.",
// },
// ],
//},
]
const handleSend = () => {
setMessage("")
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const toggleChat = () => {
setIsChatOpen(!isChatOpen)
}
return (
<AnimatePresence mode="wait">
{!isChatOpen ? (
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="!blur-none z-10" />
Chat with Nova
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] h-[calc(100vh-110px)] bg-[#0A0E14] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2]",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<div className="flex items-center justify-between p-4 px-6">
<p>Chat Title</p>
<div className="flex items-center gap-2">
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
>
<SquarePenIcon className="size-4 text-[#737373]" />
<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="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
</motion.button>
</div>
</div>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
{messages.map((msg, i) => (
<div
key={`message-${i}-${msg.message}`}
className="flex items-start gap-2"
>
{msg.type === "waiting" ? (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={30} className="!blur-none" />
<span className="text-sm">{msg.message}</span>
</div>
) : (
<>
<div
className={cn(
"flex flex-col items-center justify-center w-[30px] h-full",
i !== 0 && "",
)}
>
{i === 0 && (
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
)}
<div className="w-[1px] flex-1 bg-[#293952]/40" />
</div>
{msg.type === "memory" && (
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
{msg.memories?.map((memory) => (
<div
key={memory.url + memory.title}
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
>
{memory.title && (
<h3
className="text-sm font-medium"
style={{
background:
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{memory.title}
</h3>
)}
{memory.url && (
<a
href={memory.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline break-all"
>
{memory.url}
</a>
)}
{memory.description && (
<p className="text-xs text-white/50 mt-1">
{memory.description}
</p>
)}
</div>
))}
</div>
)}
</>
)}
</div>
))}
</div>
<div className="p-4">
<form
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
onSubmit={(e) => {
e.preventDefault()
if (message.trim()) {
handleSend()
}
}}
>
<input
value={message}
onChange={(e) => 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"
/>
<div className="flex justify-end absolute bottom-3 right-2">
<Button
type="submit"
disabled={!message.trim()}
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
size="icon"
>
<SendIcon className="size-4" />
</Button>
</div>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -162,7 +162,7 @@ export function MemoriesGrid() {
</div>
</div>
) : (
<div className="h-full overflow-auto custom-scrollbar">
<div className="h-full overflow-auto scrollbar-thin">
<Masonry
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}`}
items={documents}
@ -237,7 +237,11 @@ const DocumentCard = memo(
? "pb-1"
: "",
)}
style={{ width }}
style={{
width,
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<ContentPreview document={document} />
{!(
@ -277,7 +281,8 @@ const DocumentCard = memo(
}}
>
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
{document.memoryEntries.length} {document.memoryEntries.length === 1 ? "memory" : "memories"}
{document.memoryEntries.length}{" "}
{document.memoryEntries.length === 1 ? "memory" : "memories"}
</p>
<p
className={cn(

View file

@ -1,98 +1,98 @@
'use client';
"use client"
import { motion, useReducedMotion, Variants } from 'motion/react';
import { motion, useReducedMotion, Variants } from "motion/react"
type NovaPathLoaderProps = {
size?: number; // px
colorClassName?: string;
label?: string;
className?: string;
};
size?: number // px
colorClassName?: string
label?: string
className?: string
}
// full SVG path data from nova-2d-anim.svg
const PATH_RIGHT =
'M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z';
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z"
const PATH_LEFT =
'M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z';
"M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z"
// animation for stroke draw
const strokeVariants: Variants = {
hidden: { pathLength: 0, opacity: 0.2 },
visible: (i: number) => ({
pathLength: [0, 1],
opacity: [0.2, 1],
transition: {
duration: 0.9,
repeat: Infinity,
repeatType: 'reverse',
ease: 'easeInOut',
delay: i * 0.18,
},
}),
static: { pathLength: 1, opacity: 0.7 },
};
export function SuperLoader({
size = 42,
colorClassName = 'text-sky-400',
label = 'Loading...',
className = '',
}: NovaPathLoaderProps) {
const prefersReducedMotion = useReducedMotion();
const animateVariant = prefersReducedMotion ? 'static' : 'visible';
return (
<div
role='status'
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
style={{ width: size + 10 }}
>
<motion.svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 21 21'
width={size}
height={size}
className={`shrink-0 ${colorClassName}`}
>
{/* Right path */}
<motion.path
d={PATH_RIGHT}
fill='none'
stroke='currentColor'
strokeWidth={1.4}
strokeLinecap='round'
strokeLinejoin='round'
initial='hidden'
animate={animateVariant}
variants={strokeVariants}
custom={0}
/>
{/* Left path */}
<motion.path
d={PATH_LEFT}
fill='none'
stroke='currentColor'
strokeWidth={1.4}
strokeLinecap='round'
strokeLinejoin='round'
initial='hidden'
animate={animateVariant}
variants={strokeVariants}
custom={1}
/>
</motion.svg>
<span
className='text-xs font-medium text-slate-500'
style={{ fontSize: size * 0.25 }}
>
{label}
</span>
</div>
);
hidden: { pathLength: 0, opacity: 0.2 },
visible: (i: number) => ({
pathLength: [0, 1],
opacity: [0.2, 1],
transition: {
duration: 0.9,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
ease: "easeInOut",
delay: i * 0.18,
},
}),
static: { pathLength: 1, opacity: 0.7 },
}
export function SuperLoader({
size = 42,
colorClassName = "text-sky-400",
label = "Loading...",
className = "",
}: NovaPathLoaderProps) {
const prefersReducedMotion = useReducedMotion()
const animateVariant = prefersReducedMotion ? "static" : "visible"
return (
<div
role="status"
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
style={{ width: size + 10 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 21"
width={size}
height={size}
className={`shrink-0 ${colorClassName}`}
>
<title>Loading...</title>
{/* Right path */}
<motion.path
d={PATH_RIGHT}
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
initial="hidden"
animate={animateVariant}
variants={strokeVariants}
custom={0}
/>
{/* Left path */}
<motion.path
d={PATH_LEFT}
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
initial="hidden"
animate={animateVariant}
variants={strokeVariants}
custom={1}
/>
</motion.svg>
<span
className="text-xs font-medium text-slate-500"
style={{ fontSize: size * 0.25 }}
>
{label}
</span>
</div>
)
}

View file

@ -709,7 +709,7 @@ export function ChatMessages() {
className="text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{modelNames[selectedModel]}
{/*{modelNames[selectedModel]}*/}
</span>
</div>
{status === "streaming" || status === "submitted" ? (

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@ -27,8 +27,8 @@
padding: 0 !important;
font-family: "DM Sans", sans-serif !important;
font-size: 10px !important;
--tweet-header-font-size: 10px !important;
--tweet-header-line-height: 1.25rem !important;
--tweet-header-font-size: 10px !important;
--tweet-header-line-height: 1.25rem !important;
}
.tweet-body-module__ZNRZja__root > * {
@ -53,4 +53,4 @@
.font-dm-sans {
letter-spacing: -0.01em;
line-height: 135%;
}
}

View file

@ -18,10 +18,10 @@ export const models = [
export type ModelId = (typeof models)[number]["id"]
export const modelNames: Record<ModelId, string> = {
"gpt-5": "GPT 5",
"claude-sonnet-4.5": "Claude Sonnet 4.5",
"gemini-2.5-pro": "Gemini 2.5 Pro",
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gpt-5": { name: "GPT", version: "5" },
"claude-sonnet-4.5": { name: "Claude", version: "4.5" },
"gemini-2.5-pro": { name: "Gemini", version: "2.5 Pro" },
}
interface ModelIconProps {

View file

@ -49,3 +49,267 @@ export const LogoFull = ({
</svg>
);
};
export const GradientLogo = ({ className = ""} : { className?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="65"
height="53"
viewBox="0 0 65 53"
fill="none"
className={className}
>
<title>Gradient supermemory logo</title>
<g clip-path="url(#clip0_503_43779)">
<path
d="M64.1607 20.7042H40.3439V0.00268555H32.6488V22.4642C32.6488 24.8499 33.5906 27.141 35.2645 28.8291L54.7116 48.4414L60.1526 42.9542L45.7893 28.4691H64.1651V20.7088L64.1607 20.7042Z"
fill="url(#paint0_linear_503_43779)"
/>
<path
d="M3.9992 9.536L18.3626 24.0213H-0.0134277V31.7816H23.8034V52.483H31.4984V30.0216C31.4984 27.6358 30.5567 25.3446 28.8827 23.6567L9.44012 4.0489L3.9992 9.536Z"
fill="url(#paint1_linear_503_43779)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_503_43779"
x1="3.272"
y1="-0.0520011"
x2="122.975"
y2="9.51098"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#369BFD" />
<stop offset="0.41" stop-color="#36FDFD" />
<stop offset="0.79" stop-color="#36FDB5" />
</linearGradient>
<linearGradient
id="paint1_linear_503_43779"
x1="3.272"
y1="-0.0520011"
x2="122.975"
y2="9.51098"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#369BFD" />
<stop offset="0.41" stop-color="#36FDFD" />
<stop offset="0.79" stop-color="#36FDB5" />
</linearGradient>
<clipPath id="clip0_503_43779">
<rect
width="64.732"
height="52.4854"
fill="white"
transform="translate(-0.0136719)"
/>
</clipPath>
</defs>
</svg>
);
}
export const LogoBgGradient = ({ className = "" }: { className?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="182"
height="184"
viewBox="0 0 182 184"
fill="none"
className={className}
>
<title>Logo background gradient</title>
<g opacity="0.7">
<g filter="url(#filter0_f_503_43757)">
<ellipse
cx="14.2835"
cy="24.7691"
rx="14.2835"
ry="24.7691"
transform="matrix(1 0 0 -1 86.3853 115.758)"
fill="#1410FF"
/>
</g>
<g filter="url(#filter1_f_503_43757)">
<ellipse
cx="80.5032"
cy="92.8882"
rx="14.2835"
ry="24.7691"
transform="rotate(-180 80.5032 92.8882)"
fill="#1410FF"
/>
</g>
<g filter="url(#filter2_f_503_43757)">
<ellipse
cx="23.4137"
cy="11.8923"
rx="23.4137"
ry="11.8923"
transform="matrix(0.979973 -0.199127 -0.12394 -0.99229 69.9859 107.485)"
fill="#0091FF"
/>
</g>
<g filter="url(#filter3_f_503_43757)">
<ellipse
cx="9.9778"
cy="7.53189"
rx="9.9778"
ry="7.53189"
transform="matrix(0.829871 -0.557955 -0.381948 -0.924183 98.8062 105.408)"
fill="#0099FF"
/>
</g>
<g filter="url(#filter4_f_503_43757)">
<ellipse
cx="9.9778"
cy="7.53189"
rx="9.9778"
ry="7.53189"
transform="matrix(-0.829871 -0.557955 0.381948 -0.924184 82.4371 105.912)"
fill="#0099FF"
/>
</g>
<g filter="url(#filter5_f_503_43757)">
<ellipse
cx="5.38795"
cy="10.704"
rx="5.38795"
ry="10.704"
transform="matrix(-3.03601e-08 -1 -1 5.00629e-08 99.5963 84.5005)"
fill="#47A8FD"
/>
</g>
</g>
<defs>
<filter
id="filter0_f_503_43757"
x="20.1657"
y="9.15527e-05"
width="161.006"
height="181.977"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="33.1098"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
<filter
id="filter1_f_503_43757"
x="3.05176e-05"
y="1.89951"
width="161.006"
height="181.977"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="33.1098"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
<filter
id="filter2_f_503_43757"
x="18.7991"
y="28.6653"
width="145.315"
height="124.713"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="24.8324"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
<filter
id="filter3_f_503_43757"
x="54.0543"
y="42.579"
width="100.311"
height="100.602"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="20.6936"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
<filter
id="filter4_f_503_43757"
x="26.8781"
y="43.0827"
width="100.311"
height="100.602"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="20.6936"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
<filter
id="filter5_f_503_43757"
x="28.5235"
y="24.0599"
width="120.737"
height="110.105"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="24.8324"
result="effect1_foregroundBlur_503_43757"
/>
</filter>
</defs>
</svg>
);
}