aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2025-08-16 18:58:33 -0700
committerGitHub <[email protected]>2025-08-16 18:58:33 -0700
commit91d957f478fa91a2e7487486f82fcc48f6184719 (patch)
tree3f870c04b3dce315bba1b21aa2da158494e71774 /apps/web/components
parentMerge pull request #355 from supermemoryai/archive (diff)
parentNew Version of Supermemory Consumer App (diff)
downloadsupermemory-91d957f478fa91a2e7487486f82fcc48f6184719.tar.xz
supermemory-91d957f478fa91a2e7487486f82fcc48f6184719.zip
Merge pull request #366 from supermemoryai/mahesh/supermemory-new
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/connect-ai-modal.tsx219
-rw-r--r--apps/web/components/create-project-dialog.tsx119
-rw-r--r--apps/web/components/glass-menu-effect.tsx37
-rw-r--r--apps/web/components/install-prompt.tsx118
-rw-r--r--apps/web/components/memory-list-view.tsx802
-rw-r--r--apps/web/components/menu.tsx618
-rw-r--r--apps/web/components/project-selector.tsx566
-rw-r--r--apps/web/components/referral-upgrade-modal.tsx290
-rw-r--r--apps/web/components/spinner.tsx8
-rw-r--r--apps/web/components/text-shimmer.tsx57
-rw-r--r--apps/web/components/tour.tsx413
-rw-r--r--apps/web/components/views/add-memory.tsx1425
-rw-r--r--apps/web/components/views/billing.tsx261
-rw-r--r--apps/web/components/views/chat/chat-messages.tsx322
-rw-r--r--apps/web/components/views/chat/index.tsx124
-rw-r--r--apps/web/components/views/connections-tab-content.tsx382
-rw-r--r--apps/web/components/views/mcp/index.tsx311
-rw-r--r--apps/web/components/views/mcp/installation-dialog-content.tsx79
-rw-r--r--apps/web/components/views/profile.tsx266
-rw-r--r--apps/web/components/views/projects.tsx742
20 files changed, 7159 insertions, 0 deletions
diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx
new file mode 100644
index 00000000..ca5240cd
--- /dev/null
+++ b/apps/web/components/connect-ai-modal.tsx
@@ -0,0 +1,219 @@
+"use client"
+
+import { useIsMobile } from "@hooks/use-mobile"
+import { Button } from "@ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@ui/components/dialog"
+import { Input } from "@ui/components/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import { CopyableCell } from "@ui/copyable-cell"
+import { CopyIcon, ExternalLink } from "lucide-react"
+import Image from "next/image"
+import { useState } from "react"
+import { toast } from "sonner"
+
+const clients = {
+ cursor: "Cursor",
+ claude: "Claude Desktop",
+ vscode: "VSCode",
+ cline: "Cline",
+ "gemini-cli": "Gemini CLI",
+ "claude-code": "Claude Code",
+ "roo-cline": "Roo Cline",
+ witsy: "Witsy",
+ enconvo: "Enconvo",
+} as const
+
+interface ConnectAIModalProps {
+ children: React.ReactNode
+}
+
+export function ConnectAIModal({ children }: ConnectAIModalProps) {
+ const [client, setClient] = useState<keyof typeof clients>("cursor")
+ const [isOpen, setIsOpen] = useState(false)
+ const [showAllTools, setShowAllTools] = useState(false)
+ const isMobile = useIsMobile()
+ const installCommand = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`
+
+ const copyToClipboard = () => {
+ navigator.clipboard.writeText(installCommand)
+ toast.success("Copied to clipboard!")
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
+ <DialogTrigger asChild>{children}</DialogTrigger>
+ <DialogContent className="sm:max-w-4xl">
+ <DialogHeader>
+ <DialogTitle>Connect Supermemory to Your AI</DialogTitle>
+ <DialogDescription>
+ Connect supermemory to your favorite AI tools using the Model Context Protocol (MCP).
+ This allows your AI assistant to create, search, and access your memories directly.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mb-6 block md:hidden">
+ <label
+ className="text-sm font-medium text-white/80 block mb-2"
+ htmlFor="mcp-server-url"
+ >
+ MCP Server URL
+ </label>
+ <div className="p-3 bg-white/5 rounded border border-white/10">
+ <CopyableCell
+ className="font-mono text-sm text-blue-400"
+ value="https://api.supermemory.ai/mcp"
+ />
+ </div>
+ <p className="text-xs text-white/50 mt-2">
+ Click URL to copy to clipboard. Use this URL to configure supermemory in your AI assistant.
+ </p>
+ </div>
+
+ <div className="space-y-6">
+ <div className="hidden md:block">
+ <h3 className="text-sm font-medium mb-3">Supported AI Tools</h3>
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
+ {Object.entries(clients)
+ .slice(0, showAllTools ? undefined : isMobile ? 4 : 6)
+ .map(([key, clientName]) => (
+ <div
+ key={clientName}
+ className="flex items-center gap-3 px-3 py-3 bg-muted rounded-md"
+ >
+ <div className="w-8 h-8 relative flex-shrink-0 flex items-center justify-center">
+ <Image
+ src={`/mcp-supported-tools/${key == "claude-code" ? "claude" : key}.png`}
+ alt={clientName}
+ width={isMobile ? 20 : 32}
+ height={isMobile ? 20 : 32}
+ className={"rounded object-contain"}
+ onError={(e) => {
+ const target = e.target as HTMLImageElement;
+ target.style.display = 'none';
+ const parent = target.parentElement;
+ if (parent && !parent.querySelector('.fallback-text')) {
+ const fallback = document.createElement('span');
+ fallback.className = 'fallback-text text-xs font-bold text-muted-foreground';
+ fallback.textContent = clientName.substring(0, 2).toUpperCase();
+ parent.appendChild(fallback);
+ }
+ }}
+ />
+ </div>
+ <span className="text-sm font-medium">{clientName}</span>
+ </div>
+ ))}
+ </div>
+ {Object.entries(clients).length > 6 && (
+ <div className="mt-3 text-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowAllTools(!showAllTools)}
+ className="text-xs text-muted-foreground hover:text-foreground"
+ >
+ {showAllTools ? "Show Less" : `Show All`}
+ </Button>
+ </div>
+ )}
+ </div>
+
+ <div className="hidden md:block">
+ <h3 className="text-sm font-medium mb-3">Quick Installation</h3>
+ <div className="space-y-3 flex gap-2 items-center justify-between">
+ <Select
+ value={client}
+ onValueChange={(value) => setClient(value as keyof typeof clients)}
+ >
+ <SelectTrigger className="w-48 mb-0">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(clients).map(([key, value]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-4 relative flex-shrink-0 flex items-center justify-center">
+ <Image
+ src={`/mcp-supported-tools/${key == "claude-code" ? "claude" : key}.png`}
+ alt={value}
+ width={16}
+ height={16}
+ className="rounded object-contain"
+ onError={(e) => {
+ const target = e.target as HTMLImageElement;
+ target.style.display = 'none';
+ const parent = target.parentElement;
+ if (parent && !parent.querySelector('.fallback-text')) {
+ const fallback = document.createElement('span');
+ fallback.className = 'fallback-text text-xs font-bold text-muted-foreground';
+ fallback.textContent = value.substring(0, 1).toUpperCase();
+ parent.appendChild(fallback);
+ }
+ }}
+ />
+ </div>
+ {value}
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <div className="relative w-full flex items-center">
+ <Input
+ className="font-mono text-xs w-full pr-10"
+ readOnly
+ value={installCommand}
+ />
+
+ <Button
+ onClick={copyToClipboard}
+ className="absolute right-0 cursor-pointer"
+ variant="ghost"
+ >
+ <CopyIcon className="size-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <h3 className="text-sm font-medium mb-3">What You Can Do</h3>
+ <ul className="space-y-2 text-sm text-muted-foreground">
+ <li>• Ask your AI to save important information as memories</li>
+ <li>• Search through your saved memories during conversations</li>
+ <li>• Get contextual information from your knowledge base</li>
+ <li>• Seamlessly integrate with your existing AI workflow</li>
+ </ul>
+ </div>
+
+ <div className="flex justify-between items-center pt-4 border-t">
+ <Button
+ variant="outline"
+ onClick={() => window.open("https://docs.supermemory.ai/supermemory-mcp/introduction", "_blank")}
+ >
+ <ExternalLink className="w-4 h-4 mr-2" />
+ Learn More
+ </Button>
+ <Button onClick={() => setIsOpen(false)}>
+ Done
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/apps/web/components/create-project-dialog.tsx b/apps/web/components/create-project-dialog.tsx
new file mode 100644
index 00000000..904c3f2d
--- /dev/null
+++ b/apps/web/components/create-project-dialog.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import { Button } from "@repo/ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@repo/ui/components/dialog"
+import { Input } from "@repo/ui/components/input"
+import { Label } from "@repo/ui/components/label"
+import { Loader2 } from "lucide-react"
+import { motion, AnimatePresence } from "motion/react"
+import { useState } from "react"
+import { useProjectMutations } from "@/hooks/use-project-mutations"
+
+interface CreateProjectDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
+ const [projectName, setProjectName] = useState("")
+ const { createProjectMutation } = useProjectMutations()
+
+ const handleClose = () => {
+ onOpenChange(false)
+ setProjectName("")
+ }
+
+ const handleCreate = () => {
+ if (projectName.trim()) {
+ createProjectMutation.mutate(projectName, {
+ onSuccess: () => {
+ handleClose()
+ }
+ })
+ }
+ }
+
+ return (
+ <AnimatePresence>
+ {open && (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-2xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Create New Project</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Give your project a unique name
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <motion.div
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.1 }}
+ className="flex flex-col gap-2"
+ >
+ <Label htmlFor="projectName">Project Name</Label>
+ <Input
+ id="projectName"
+ className="bg-white/5 border-white/10 text-white"
+ placeholder="My Awesome Project"
+ value={projectName}
+ onChange={(e) => setProjectName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && projectName.trim()) {
+ handleCreate()
+ }
+ }}
+ />
+ <p className="text-xs text-white/50">
+ This will help you organize your memories
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ type="button"
+ variant="outline"
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={handleClose}
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ type="button"
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={createProjectMutation.isPending || !projectName.trim()}
+ onClick={handleCreate}
+ >
+ {createProjectMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Creating...
+ </>
+ ) : (
+ "Create Project"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ )
+}
diff --git a/apps/web/components/glass-menu-effect.tsx b/apps/web/components/glass-menu-effect.tsx
new file mode 100644
index 00000000..9d4d4b68
--- /dev/null
+++ b/apps/web/components/glass-menu-effect.tsx
@@ -0,0 +1,37 @@
+import { motion } from "motion/react"
+
+interface GlassMenuEffectProps {
+ rounded?: string
+ className?: string
+}
+
+export function GlassMenuEffect({
+ rounded = "rounded-[28px]",
+ className = "",
+}: GlassMenuEffectProps) {
+ return (
+ <motion.div
+ className={`absolute inset-0 ${className}`}
+ layout
+ style={{
+ transform: "translateZ(0)",
+ willChange: "auto",
+ }}
+ transition={{
+ layout: {
+ type: "spring",
+ damping: 35,
+ stiffness: 180,
+ },
+ }}
+ >
+ <div
+ className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`}
+ style={{
+ transform: "translateZ(0)",
+ willChange: "transform",
+ }}
+ />
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/install-prompt.tsx b/apps/web/components/install-prompt.tsx
new file mode 100644
index 00000000..cde987c4
--- /dev/null
+++ b/apps/web/components/install-prompt.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react"
+import { motion, AnimatePresence } from "motion/react"
+import { X, Download, Share } from "lucide-react"
+import { Button } from "@repo/ui/components/button"
+
+export function InstallPrompt() {
+ const [isIOS, setIsIOS] = useState(false)
+ const [showPrompt, setShowPrompt] = useState(false)
+ const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
+
+ useEffect(() => {
+ const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
+ const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches
+ const hasSeenPrompt = localStorage.getItem('install-prompt-dismissed') === 'true'
+
+ setIsIOS(isIOSDevice)
+
+ const isDevelopment = process.env.NODE_ENV === 'development'
+ setShowPrompt(!hasSeenPrompt && (isDevelopment || (!isInStandaloneMode && (isIOSDevice || 'serviceWorker' in navigator))))
+
+ const handleBeforeInstallPrompt = (e: Event) => {
+ e.preventDefault()
+ setDeferredPrompt(e)
+ if (!hasSeenPrompt) {
+ setShowPrompt(true)
+ }
+ }
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
+
+ return () => {
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
+ }
+ }, [])
+
+ const handleInstall = async () => {
+ if (deferredPrompt) {
+ deferredPrompt.prompt()
+ const { outcome } = await deferredPrompt.userChoice
+ if (outcome === 'accepted') {
+ localStorage.setItem('install-prompt-dismissed', 'true')
+ setShowPrompt(false)
+ }
+ setDeferredPrompt(null)
+ }
+ }
+
+ const handleDismiss = () => {
+ localStorage.setItem('install-prompt-dismissed', 'true')
+ setShowPrompt(false)
+ }
+
+ if (!showPrompt) {
+ return null
+ }
+
+ return (
+ <AnimatePresence>
+ <motion.div
+ animate={{ y: 0, opacity: 1 }}
+ exit={{ y: 100, opacity: 0 }}
+ initial={{ y: 100, opacity: 0 }}
+ className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden"
+ >
+ <div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10">
+ <div className="flex items-start justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <div className="w-8 h-8 bg-[#0f1419] rounded-lg flex items-center justify-center">
+ <Download className="w-4 h-4" />
+ </div>
+ <h3 className="font-semibold text-sm">Install Supermemory</h3>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleDismiss}
+ className="text-white/60 hover:text-white h-6 w-6 p-0"
+ >
+ <X className="w-4 h-4" />
+ </Button>
+ </div>
+
+ <p className="text-white/80 text-xs mb-4 leading-relaxed">
+ Add Supermemory to your home screen for quick access and a better experience.
+ </p>
+
+ {isIOS ? (
+ <div className="space-y-3">
+ <p className="text-white/70 text-xs flex items-center gap-1">
+ 1. Tap the <Share className="w-3 h-3 inline" /> Share button in Safari
+ </p>
+ <p className="text-white/70 text-xs">
+ 2. Select "Add to Home Screen" ➕
+ </p>
+ <Button
+ variant="secondary"
+ size="sm"
+ onClick={handleDismiss}
+ className="w-full text-xs"
+ >
+ Got it
+ </Button>
+ </div>
+ ) : (
+ <Button
+ onClick={handleInstall}
+ size="sm"
+ className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs"
+ >
+ <Download className="w-3 h-3 mr-1" />
+ Add to Home Screen
+ </Button>
+ )}
+ </div>
+ </motion.div>
+ </AnimatePresence>
+ )
+}
diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx
new file mode 100644
index 00000000..654a3ad1
--- /dev/null
+++ b/apps/web/components/memory-list-view.tsx
@@ -0,0 +1,802 @@
+"use client"
+
+import {
+ GoogleDocs,
+ GoogleDrive,
+ GoogleSheets,
+ GoogleSlides,
+ MicrosoftExcel,
+ MicrosoftOneNote,
+ MicrosoftPowerpoint,
+ MicrosoftWord,
+ NotionDoc,
+ OneDrive,
+ PDF,
+} from "@repo/ui/assets/icons"
+import { Badge } from "@repo/ui/components/badge"
+import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@repo/ui/components/sheet"
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+} from "@repo/ui/components/drawer"
+import { colors } from "@repo/ui/memory-graph/constants"
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import { Label1Regular } from "@ui/text/label/label-1-regular"
+import { Brain, Calendar, ExternalLink, FileText, Sparkles } from "lucide-react"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import type { z } from "zod"
+import { analytics } from "@/lib/analytics"
+import useResizeObserver from "@/hooks/use-resize-observer"
+import { useIsMobile } from "@hooks/use-mobile"
+import { cn } from "@lib/utils"
+
+type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
+type DocumentWithMemories = DocumentsResponse["documents"][0]
+type MemoryEntry = DocumentWithMemories["memoryEntries"][0]
+
+interface MemoryListViewProps {
+ children?: React.ReactNode
+ documents: DocumentWithMemories[]
+ isLoading: boolean
+ isLoadingMore: boolean
+ error: Error | null
+ totalLoaded: number
+ hasMore: boolean
+ loadMoreDocuments: () => Promise<void>
+}
+
+const GreetingMessage = memo(() => {
+ const getGreeting = () => {
+ const hour = new Date().getHours()
+ if (hour < 12) return "Good morning"
+ if (hour < 17) return "Good afternoon"
+ return "Good evening"
+ }
+
+ return (
+ <div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3">
+ <div>
+ <h1
+ className="text-lg md:text-xl font-semibold"
+ style={{ color: colors.text.primary }}
+ >
+ {getGreeting()}!
+ </h1>
+ <p
+ className="text-xs md:text-sm"
+ style={{ color: colors.text.muted }}
+ >
+ Welcome back to your memory collection
+ </p>
+ </div>
+ </div>
+ )
+})
+
+const formatDate = (date: string | Date) => {
+ const dateObj = new Date(date)
+ const now = new Date()
+ const currentYear = now.getFullYear()
+ const dateYear = dateObj.getFullYear()
+
+ const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+ const month = monthNames[dateObj.getMonth()]
+ const day = dateObj.getDate()
+
+ const getOrdinalSuffix = (n: number) => {
+ const s = ["th", "st", "nd", "rd"]
+ const v = n % 100
+ return n + (s[(v - 20) % 10] || s[v] || s[0]!)
+ }
+
+ const formattedDay = getOrdinalSuffix(day)
+
+ if (dateYear !== currentYear) {
+ return `${month} ${formattedDay}, ${dateYear}`
+ }
+
+ return `${month} ${formattedDay}`
+}
+
+const formatDocumentType = (type: string) => {
+ // Special case for PDF
+ if (type.toLowerCase() === "pdf") return "PDF"
+
+ // Replace underscores with spaces and capitalize each word
+ return type
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ")
+}
+
+const getDocumentIcon = (type: string, className: string) => {
+ const iconProps = {
+ className,
+ style: { color: colors.text.muted },
+ }
+
+ switch (type) {
+ case "google_doc":
+ return <GoogleDocs {...iconProps} />
+ case "google_sheet":
+ return <GoogleSheets {...iconProps} />
+ case "google_slide":
+ return <GoogleSlides {...iconProps} />
+ case "google_drive":
+ return <GoogleDrive {...iconProps} />
+ case "notion":
+ case "notion_doc":
+ return <NotionDoc {...iconProps} />
+ case "word":
+ case "microsoft_word":
+ return <MicrosoftWord {...iconProps} />
+ case "excel":
+ case "microsoft_excel":
+ return <MicrosoftExcel {...iconProps} />
+ case "powerpoint":
+ case "microsoft_powerpoint":
+ return <MicrosoftPowerpoint {...iconProps} />
+ case "onenote":
+ case "microsoft_onenote":
+ return <MicrosoftOneNote {...iconProps} />
+ case "onedrive":
+ return <OneDrive {...iconProps} />
+ case "pdf":
+ return <PDF {...iconProps} />
+ default:
+ return <FileText {...iconProps} />
+ }
+}
+
+const getSourceUrl = (document: DocumentWithMemories) => {
+ if (document.type === "google_doc" && document.customId) {
+ return `https://docs.google.com/document/d/${document.customId}`
+ }
+ if (document.type === "google_sheet" && document.customId) {
+ return `https://docs.google.com/spreadsheets/d/${document.customId}`
+ }
+ if (document.type === "google_slide" && document.customId) {
+ return `https://docs.google.com/presentation/d/${document.customId}`
+ }
+ // Fallback to existing URL for all other document types
+ return document.url
+}
+
+const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
+ return (
+ <button
+ className="p-4 rounded-lg border transition-all relative overflow-hidden cursor-pointer"
+ style={{
+ backgroundColor: memory.isLatest
+ ? colors.memory.primary
+ : "rgba(255, 255, 255, 0.02)",
+ borderColor: memory.isLatest
+ ? colors.memory.border
+ : "rgba(255, 255, 255, 0.1)",
+ backdropFilter: "blur(8px)",
+ WebkitBackdropFilter: "blur(8px)",
+ }}
+ tabIndex={0}
+ type="button"
+ >
+ <div className="flex items-start gap-2 relative z-10">
+ <div
+ className="p-1 rounded"
+ style={{
+ backgroundColor: memory.isLatest
+ ? colors.memory.secondary
+ : "transparent",
+ }}
+ >
+ <Brain
+ className={`w-4 h-4 flex-shrink-0 transition-all ${memory.isLatest ? "text-blue-400" : "text-blue-400/50"
+ }`}
+ />
+ </div>
+ <div className="flex-1 space-y-2">
+ <Label1Regular
+ className="text-sm leading-relaxed text-left"
+ style={{ color: colors.text.primary }}
+ >
+ {memory.memory}
+ </Label1Regular>
+ <div className="flex items-center gap-2 flex-wrap">
+ {memory.isForgotten && (
+ <Badge
+ className="text-xs border-red-500/30 backdrop-blur-sm"
+ style={{
+ backgroundColor: colors.status.forgotten,
+ color: "#dc2626",
+ backdropFilter: "blur(4px)",
+ WebkitBackdropFilter: "blur(4px)",
+ }}
+ variant="destructive"
+ >
+ Forgotten
+ </Badge>
+ )}
+ {memory.isLatest && (
+ <Badge
+ className="text-xs border-blue-400/30 backdrop-blur-sm"
+ style={{
+ backgroundColor: colors.memory.secondary,
+ color: colors.accent.primary,
+ backdropFilter: "blur(4px)",
+ WebkitBackdropFilter: "blur(4px)",
+ }}
+ variant="default"
+ >
+ Latest
+ </Badge>
+ )}
+ {memory.forgetAfter && (
+ <Badge
+ className="text-xs backdrop-blur-sm"
+ style={{
+ borderColor: colors.status.expiring,
+ color: colors.status.expiring,
+ backgroundColor: "rgba(251, 165, 36, 0.1)",
+ backdropFilter: "blur(4px)",
+ WebkitBackdropFilter: "blur(4px)",
+ }}
+ variant="outline"
+ >
+ Expires: {formatDate(memory.forgetAfter)}
+ </Badge>
+ )}
+ </div>
+ <div
+ className="flex items-center gap-4 text-xs"
+ style={{ color: colors.text.muted }}
+ >
+ <span className="flex items-center gap-1">
+ <Calendar className="w-3 h-3" />
+ {formatDate(memory.createdAt)}
+ </span>
+ <span className="font-mono">v{memory.version}</span>
+ {memory.sourceRelevanceScore && (
+ <span
+ className="flex items-center gap-1"
+ style={{
+ color:
+ memory.sourceRelevanceScore > 70
+ ? colors.accent.emerald
+ : colors.text.muted,
+ }}
+ >
+ <Sparkles className="w-3 h-3" />
+ {memory.sourceRelevanceScore}%
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ </button>
+ )
+})
+
+const DocumentCard = memo(
+ ({
+ document,
+ onOpenDetails,
+ }: {
+ document: DocumentWithMemories
+ onOpenDetails: (document: DocumentWithMemories) => void
+ }) => {
+ const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
+ const forgottenMemories = document.memoryEntries.filter(
+ (m) => m.isForgotten,
+ )
+
+ return (
+ <Card
+ className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full"
+ onClick={() => {
+ analytics.documentCardClicked()
+ onOpenDetails(document)
+ }}
+ style={{
+ backgroundColor: colors.document.primary,
+ }}
+ >
+ <CardHeader className="relative z-10 px-0">
+ <div className="flex items-center justify-between gap-2">
+ <div className="flex items-center gap-1">
+ {getDocumentIcon(document.type, "w-4 h-4 flex-shrink-0")}
+ <p className={cn("text-sm font-medium line-clamp-1", document.url ? "max-w-[190px]" : "max-w-[200px]")}>{document.title || "Untitled Document"}</p>
+ </div>
+ {document.url && (
+ <button
+ className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
+ onClick={(e) => {
+ e.stopPropagation()
+ const sourceUrl = getSourceUrl(document)
+ window.open(sourceUrl ?? undefined, "_blank")
+ }}
+ style={{
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
+ color: colors.text.secondary,
+ }}
+ type="button"
+ >
+ <ExternalLink className="w-3 h-3" />
+ </button>
+ )}
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
+ <span>{formatDate(document.createdAt)}</span>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="relative z-10 px-0">
+ {document.summary && (
+ <p
+ className="text-xs line-clamp-2 mb-3"
+ style={{ color: colors.text.muted }}
+ >
+ {document.summary}
+ </p>
+ )}
+ <div className="flex items-center gap-2 flex-wrap">
+ {activeMemories.length > 0 && (
+ <Badge
+ className="text-xs text-accent-foreground"
+ style={{
+ backgroundColor: colors.memory.secondary,
+ }}
+ variant="secondary"
+ >
+ <Brain className="w-3 h-3 mr-1" />
+ {activeMemories.length}{" "}
+ {activeMemories.length === 1 ? "memory" : "memories"}
+ </Badge>
+ )}
+ {forgottenMemories.length > 0 && (
+ <Badge
+ className="text-xs"
+ style={{
+ borderColor: "rgba(255, 255, 255, 0.2)",
+ color: colors.text.muted,
+ }}
+ variant="outline"
+ >
+ {forgottenMemories.length} forgotten
+ </Badge>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+ },
+)
+
+const DocumentDetailSheet = memo(
+ ({
+ document,
+ isOpen,
+ onClose,
+ isMobile,
+ }: {
+ document: DocumentWithMemories | null
+ isOpen: boolean
+ onClose: () => void
+ isMobile: boolean
+ }) => {
+ if (!document) return null
+
+ const [isSummaryExpanded, setIsSummaryExpanded] = useState(false)
+ const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
+ const forgottenMemories = document.memoryEntries.filter(
+ (m) => m.isForgotten,
+ )
+
+ const HeaderContent = ({ TitleComponent }: { TitleComponent: typeof SheetTitle | typeof DrawerTitle }) => (
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex items-start gap-3 flex-1">
+ <div
+ className="p-2 rounded-lg"
+ style={{
+ backgroundColor: colors.document.secondary,
+ border: `1px solid ${colors.document.border}`,
+ }}
+ >
+ {getDocumentIcon(document.type, "w-5 h-5")}
+ </div>
+ <div className="flex-1">
+ <TitleComponent style={{ color: colors.text.primary }}>
+ {document.title || "Untitled Document"}
+ </TitleComponent>
+ <div
+ className="flex items-center gap-2 mt-1 text-xs"
+ style={{ color: colors.text.muted }}
+ >
+ <span>{formatDocumentType(document.type)}</span>
+ <span>•</span>
+ <span>
+ {formatDate(document.createdAt)}
+ </span>
+ {document.url && (
+ <>
+ <span>•</span>
+ <button
+ className="flex items-center gap-1 transition-all hover:gap-2"
+ onClick={() => {
+ const sourceUrl = getSourceUrl(document)
+ window.open(sourceUrl ?? undefined, "_blank")
+ }}
+ style={{ color: colors.accent.primary }}
+ type="button"
+ >
+ View source
+ <ExternalLink className="w-3 h-3" />
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+
+ const SummarySection = () => {
+ if (!document.summary) return null
+
+ const shouldShowToggle = document.summary.length > 200 // Show toggle for longer summaries
+
+ return (
+ <div
+ className="mt-4 p-3 rounded-lg"
+ style={{
+ backgroundColor: "rgba(255, 255, 255, 0.03)",
+ border: "1px solid rgba(255, 255, 255, 0.08)",
+ }}
+ >
+ <p
+ className={`text-sm ${!isSummaryExpanded ? 'line-clamp-3' : ''}`}
+ style={{ color: colors.text.muted }}
+ >
+ {document.summary}
+ </p>
+ {shouldShowToggle && (
+ <button
+ onClick={() => setIsSummaryExpanded(!isSummaryExpanded)}
+ className="mt-2 text-xs hover:underline transition-all"
+ style={{ color: colors.accent.primary }}
+ type="button"
+ >
+ {isSummaryExpanded ? 'Show less' : 'Show more'}
+ </button>
+ )}
+ </div>
+ )
+ }
+
+ const MemoryContent = () => (
+ <div className="p-6 space-y-6">
+ {activeMemories.length > 0 && (
+ <div>
+ <div
+ className="text-sm font-medium mb-4 flex items-start gap-2 px-3 py-2 rounded-lg"
+ style={{
+ color: colors.text.secondary,
+ backgroundColor: colors.memory.primary,
+ border: `1px solid ${colors.memory.border}`,
+ }}
+ >
+ <Brain className="w-4 h-4 text-blue-400" />
+ Active Memories ({activeMemories.length})
+ </div>
+ <div className="space-y-3">
+ {activeMemories.map((memory, index) => (
+ <div
+ className="animate-in fade-in slide-in-from-right-2"
+ key={memory.id}
+ style={{ animationDelay: `${index * 50}ms` }}
+ >
+ <MemoryDetailItem memory={memory} />
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {forgottenMemories.length > 0 && (
+ <div>
+ <div
+ className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60"
+ style={{
+ color: colors.text.muted,
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
+ border: "1px solid rgba(255, 255, 255, 0.08)",
+ }}
+ >
+ Forgotten Memories ({forgottenMemories.length})
+ </div>
+ <div className="space-y-3 opacity-40">
+ {forgottenMemories.map((memory) => (
+ <MemoryDetailItem key={memory.id} memory={memory} />
+ ))}
+ </div>
+ </div>
+ )}
+
+ {activeMemories.length === 0 &&
+ forgottenMemories.length === 0 && (
+ <div
+ className="text-center py-12 rounded-lg"
+ style={{
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
+ border: "1px solid rgba(255, 255, 255, 0.08)",
+ }}
+ >
+ <Brain
+ className="w-12 h-12 mx-auto mb-4 opacity-30"
+ style={{ color: colors.text.muted }}
+ />
+ <p style={{ color: colors.text.muted }}>
+ No memories found for this document
+ </p>
+ </div>
+ )}
+ </div>
+ )
+
+ if (isMobile) {
+ return (
+ <Drawer onOpenChange={onClose} open={isOpen}>
+ <DrawerContent
+ className="border-0 p-0 overflow-hidden max-h-[90vh]"
+ style={{
+ backgroundColor: colors.background.secondary,
+ borderTop: `1px solid ${colors.document.border}`,
+ backdropFilter: "blur(20px)",
+ WebkitBackdropFilter: "blur(20px)",
+ }}
+ >
+ {/* Header section with glass effect */}
+ <div
+ className="p-4 relative border-b"
+ style={{
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
+ borderBottom: `1px solid ${colors.document.border}`,
+ }}
+ >
+ <DrawerHeader className="pb-0 px-0 text-left">
+ <HeaderContent TitleComponent={DrawerTitle} />
+ </DrawerHeader>
+
+ <SummarySection />
+ </div>
+
+ <div className="flex-1 memory-drawer-scroll overflow-y-auto">
+ <MemoryContent />
+ </div>
+ </DrawerContent>
+ </Drawer>
+ )
+ }
+
+ return (
+ <Sheet onOpenChange={onClose} open={isOpen}>
+ <SheetContent
+ className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
+ style={{
+ backgroundColor: colors.background.secondary,
+ borderLeft: `1px solid ${colors.document.border}`,
+ backdropFilter: "blur(20px)",
+ WebkitBackdropFilter: "blur(20px)",
+ }}
+ >
+ {/* Header section with glass effect */}
+ <div
+ className="p-6 relative"
+ style={{
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
+ borderBottom: `1px solid ${colors.document.border}`,
+ }}
+ >
+ <SheetHeader className="pb-0">
+ <HeaderContent TitleComponent={SheetTitle} />
+ </SheetHeader>
+
+ <SummarySection />
+ </div>
+
+ <div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
+ <MemoryContent />
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+ },
+)
+
+export const MemoryListView = ({
+ children,
+ documents,
+ isLoading,
+ isLoadingMore,
+ error,
+ hasMore,
+ loadMoreDocuments,
+}: MemoryListViewProps) => {
+ const [selectedSpace, _] = useState<string>("all")
+ const [selectedDocument, setSelectedDocument] =
+ useState<DocumentWithMemories | null>(null)
+ const [isDetailOpen, setIsDetailOpen] = useState(false)
+ const parentRef = useRef<HTMLDivElement>(null)
+ const containerRef = useRef<HTMLDivElement>(null)
+ const isMobile = useIsMobile()
+
+ const gap = 14
+
+ const { width: containerWidth } = useResizeObserver(containerRef);
+ const columnWidth = isMobile ? containerWidth : 320
+ const columns = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap)))
+
+ // Filter documents based on selected space
+ const filteredDocuments = useMemo(() => {
+ if (!documents) return []
+
+ if (selectedSpace === "all") {
+ return documents
+ }
+
+ return documents
+ .map((doc) => ({
+ ...doc,
+ memoryEntries: doc.memoryEntries.filter(
+ (memory) =>
+ (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace,
+ ),
+ }))
+ .filter((doc) => doc.memoryEntries.length > 0)
+ }, [documents, selectedSpace])
+
+ const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
+ analytics.memoryDetailOpened()
+ setSelectedDocument(document)
+ setIsDetailOpen(true)
+ }, [])
+
+ const handleCloseDetails = useCallback(() => {
+ setIsDetailOpen(false)
+ setTimeout(() => setSelectedDocument(null), 300)
+ }, [])
+
+ const virtualItems = useMemo(() => {
+ const items = []
+ for (let i = 0; i < filteredDocuments.length; i += columns) {
+ items.push(filteredDocuments.slice(i, i + columns))
+ }
+ return items
+ }, [filteredDocuments, columns])
+
+ const virtualizer = useVirtualizer({
+ count: virtualItems.length,
+ getScrollElement: () => parentRef.current,
+ overscan: 5,
+ estimateSize: () => 200,
+ })
+
+ useEffect(() => {
+ const [lastItem] = [...virtualizer.getVirtualItems()].reverse()
+
+ if (!lastItem || !hasMore || isLoadingMore) {
+ return
+ }
+
+ if (lastItem.index >= virtualItems.length - 1) {
+ loadMoreDocuments()
+ }
+ }, [hasMore, isLoadingMore, loadMoreDocuments, virtualizer.getVirtualItems(), virtualItems.length])
+
+ // Always render with consistent structure
+ return (
+ <>
+ <div
+ className="h-full overflow-hidden relative pb-20"
+ style={{ backgroundColor: colors.background.primary }}
+ ref={containerRef}
+ >
+ {error ? (
+ <div className="h-full flex items-center justify-center p-4">
+ <div className="rounded-xl overflow-hidden">
+ <div
+ className="relative z-10 px-6 py-4"
+ style={{ color: colors.text.primary }}
+ >
+ Error loading documents: {error.message}
+ </div>
+ </div>
+ </div>
+ ) : isLoading ? (
+ <div className="h-full flex items-center justify-center p-4">
+ <div className="rounded-xl overflow-hidden">
+ <div
+ className="relative z-10 px-6 py-4"
+ style={{ color: colors.text.primary }}
+ >
+ <div className="flex items-center gap-2">
+ <Sparkles className="w-4 h-4 animate-spin text-blue-400" />
+ <span>Loading memory list...</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : filteredDocuments.length === 0 && !isLoading ? (
+ <div className="h-full flex items-center justify-center p-4">
+ {children}
+ </div>
+ ) : (
+ <div
+ ref={parentRef}
+ className="h-full overflow-auto mt-20 custom-scrollbar"
+ >
+ <GreetingMessage />
+
+ <div
+ className="w-full relative"
+ style={{ height: `${virtualizer.getTotalSize() + (virtualItems.length * gap)}px` }}
+ >
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const rowItems = virtualItems[virtualRow.index]
+ if (!rowItems) return null
+
+ return (
+ <div
+ key={virtualRow.key}
+ data-index={virtualRow.index}
+ ref={virtualizer.measureElement}
+ className="absolute top-0 left-0 w-full"
+ style={{ transform: `translateY(${virtualRow.start + (virtualRow.index * gap)}px)` }}
+ >
+ <div
+ className="grid justify-start"
+ style={{ gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`, gap: `${gap}px` }}
+ >
+ {rowItems.map((document) => (
+ <DocumentCard
+ key={document.id}
+ document={document}
+ onOpenDetails={handleOpenDetails}
+ />
+ ))}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {isLoadingMore && (
+ <div className="py-8 flex items-center justify-center">
+ <div className="flex items-center gap-2">
+ <Sparkles className="w-4 h-4 animate-spin text-blue-400" />
+ <span style={{ color: colors.text.primary }}>
+ Loading more memories...
+ </span>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ <DocumentDetailSheet
+ document={selectedDocument}
+ isOpen={isDetailOpen}
+ onClose={handleCloseDetails}
+ isMobile={isMobile}
+ />
+ </>
+ )
+}
diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx
new file mode 100644
index 00000000..622b94b1
--- /dev/null
+++ b/apps/web/components/menu.tsx
@@ -0,0 +1,618 @@
+"use client"
+
+import { useIsMobile } from "@hooks/use-mobile"
+import {
+ fetchConsumerProProduct,
+ fetchMemoriesFeature,
+} from "@repo/lib/queries"
+import { Button } from "@repo/ui/components/button"
+import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
+import { GlassMenuEffect } from "@ui/other/glass-effect"
+import { useCustomer } from "autumn-js/react"
+import {
+ MessageSquareMore,
+ Plus,
+ User,
+ X,
+} from "lucide-react"
+import { AnimatePresence, LayoutGroup, motion } from "motion/react"
+import { useEffect, useState } from "react"
+import { useMobilePanel } from "@/lib/mobile-panel-context"
+import { TOUR_STEP_IDS } from "@/lib/tour-constants"
+import { ProjectSelector } from "./project-selector"
+import { useTour } from "./tour"
+import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
+import { MCPView } from "./views/mcp"
+import { ProfileView } from "./views/profile"
+import { useChatOpen } from "@/stores"
+import { Drawer } from "vaul"
+
+const MCPIcon = ({ className }: { className?: string }) => {
+ return (
+ <svg
+ className={className}
+ fill="currentColor"
+ fillRule="evenodd"
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>ModelContextProtocol</title>
+ <path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
+ <path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
+ </svg>
+ )
+}
+
+function Menu({ id }: { id?: string }) {
+ const [isHovered, setIsHovered] = useState(false)
+ const [expandedView, setExpandedView] = useState<
+ "addUrl" | "mcp" | "projects" | "profile" | null
+ >(null)
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+ const [isCollapsing, setIsCollapsing] = useState(false)
+ const [showAddMemoryView, setShowAddMemoryView] = useState(false)
+ const isMobile = useIsMobile()
+ const { activePanel, setActivePanel } = useMobilePanel()
+ const { setMenuExpanded } = useTour()
+ const autumn = useCustomer()
+ const { setIsOpen } = useChatOpen()
+
+ const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
+
+ const memoriesUsed = memoriesCheck?.usage ?? 0
+ const memoriesLimit = memoriesCheck?.included_usage ?? 0
+
+ const { data: proCheck } = fetchConsumerProProduct(autumn as any)
+
+ useEffect(() => {
+ if (memoriesCheck) {
+ console.log({ memoriesCheck })
+ }
+
+ if (proCheck) {
+ console.log({ proCheck })
+ }
+ }, [memoriesCheck, proCheck])
+
+ const isProUser = proCheck?.allowed ?? false
+
+ const shouldShowLimitWarning =
+ !isProUser && memoriesUsed >= memoriesLimit * 0.8
+
+ // Map menu item keys to tour IDs
+ const menuItemTourIds: Record<string, string> = {
+ addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY,
+ projects: TOUR_STEP_IDS.MENU_PROJECTS,
+ mcp: TOUR_STEP_IDS.MENU_MCP,
+ }
+
+ const menuItems = [
+ {
+ icon: Plus,
+ text: "Add Memory",
+ key: "addUrl" as const,
+ disabled: false,
+ },
+ {
+ icon: MessageSquareMore,
+ text: "Chat",
+ key: "chat" as const,
+ disabled: false,
+ },
+ {
+ icon: MCPIcon,
+ text: "MCP",
+ key: "mcp" as const,
+ disabled: false,
+ },
+ {
+ icon: User,
+ text: "Profile",
+ key: "profile" as const,
+ disabled: false,
+ },
+ ]
+
+ const handleMenuItemClick = (
+ key: "chat" | "addUrl" | "mcp" | "projects" | "profile",
+ ) => {
+ if (key === "chat") {
+ setIsOpen(true)
+ setIsMobileMenuOpen(false)
+ if (isMobile) {
+ setActivePanel("chat")
+ }
+ } else {
+ if (expandedView === key) {
+ setIsCollapsing(true)
+ setExpandedView(null)
+ } else if (key === "addUrl") {
+ setShowAddMemoryView(true)
+ setExpandedView(null)
+ } else {
+ setExpandedView(key)
+ }
+ if (isMobile) {
+ setActivePanel("menu")
+ }
+ }
+ }
+
+ // Watch for active panel changes on mobile
+ useEffect(() => {
+ if (isMobile && activePanel !== "menu" && activePanel !== null) {
+ // Another panel became active, close the menu
+ setIsMobileMenuOpen(false)
+ setExpandedView(null)
+ }
+ }, [isMobile, activePanel])
+
+ // Notify tour provider about expansion state changes
+ useEffect(() => {
+ const isExpanded = isMobile
+ ? isMobileMenuOpen || !!expandedView
+ : isHovered || !!expandedView
+ setMenuExpanded(isExpanded)
+ }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded])
+
+ // Calculate width based on state
+ const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
+
+ // Dynamic z-index for mobile based on active panel
+ const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]"
+
+ return (
+ <>
+ {/* Desktop Menu */}
+ {!isMobile && (
+ <LayoutGroup>
+ <div className="fixed h-screen w-full p-4 items-center top-0 left-0 pointer-events-none z-[60] flex">
+ <motion.nav
+ animate={{
+ width: menuWidth,
+ scale: 1,
+ }}
+ className="pointer-events-auto group relative flex text-sm font-medium flex-col items-start overflow-hidden rounded-3xl shadow-2xl"
+ id={id}
+ initial={{ width: 56, scale: 0.95 }}
+ layout
+ onMouseEnter={() => !expandedView && setIsHovered(true)}
+ onMouseLeave={() => !expandedView && setIsHovered(false)}
+ transition={{
+ width: {
+ duration: 0.2,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ scale: {
+ duration: 0.5,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ layout: {
+ duration: 0.2,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ }}
+ >
+ {/* Glass effect background */}
+ <motion.div className="absolute inset-0" layout>
+ <GlassMenuEffect />
+ </motion.div>
+
+ {/* Menu content */}
+ <motion.div
+ className="relative z-20 flex flex-col gap-6 w-full"
+ layout
+ >
+ <AnimatePresence
+ initial={false}
+ mode="wait"
+ onExitComplete={() => setIsCollapsing(false)}
+ >
+ {!expandedView ? (
+ <motion.div
+ animate={{
+ opacity: 1,
+ }}
+ className="w-full flex flex-col gap-6 p-4"
+ exit={{
+ opacity: 0,
+ transition: {
+ duration: 0.2,
+ ease: "easeOut",
+ },
+ }}
+ initial={{
+ opacity: 0,
+ }}
+ key="menu-items"
+ layout
+ style={{
+ transform: "translateZ(0)",
+ willChange: "opacity",
+ }}
+ transition={{
+ opacity: {
+ duration: 0.15,
+ ease: "easeInOut",
+ },
+ }}
+ >
+ <div className="flex flex-col gap-6">
+ {menuItems.map((item, index) => (
+ <div key={item.key}>
+ <motion.button
+ animate={{
+ opacity: 1,
+ y: 0,
+ scale: 1,
+ transition: {
+ duration: 0.1,
+ },
+ }}
+ className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
+ id={menuItemTourIds[item.key]}
+ initial={{ opacity: 0, y: 20, scale: 0.95 }}
+ layout
+ onClick={() => handleMenuItemClick(item.key)}
+ type="button"
+ whileHover={{
+ scale: 1.02,
+ transition: { duration: 0.1 },
+ }}
+ whileTap={{ scale: 0.98 }}
+ >
+ <motion.div
+ animate={{
+ scale: 1,
+ transition: {
+ delay: expandedView === null ? 0.15 : 0,
+ duration: 0.1,
+ },
+ }}
+ initial={{ scale: 0.8 }}
+ layout="position"
+ >
+ <item.icon className="duration-200 h-6 w-6 drop-shadow-lg flex-shrink-0" />
+ </motion.div>
+ <motion.p
+ animate={{
+ opacity: isHovered ? 1 : 0,
+ x: isHovered ? 0 : -10,
+ }}
+ className="drop-shadow-lg absolute left-10 whitespace-nowrap flex items-center gap-2"
+ initial={{ opacity: 0, x: -10 }}
+ style={{
+ transform: "translateZ(0)",
+ }}
+ transition={{
+ duration: 0.3,
+ delay: index * 0.03,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ >
+ {item.text}
+ {/* Show warning indicator for Add Memory when limits approached */}
+ {shouldShowLimitWarning &&
+ item.key === "addUrl" && (
+ <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
+ {memoriesLimit - memoriesUsed} left
+ </span>
+ )}
+ </motion.p>
+ </motion.button>
+ {index === 0 && (
+ <motion.div
+ animate={{
+ opacity: 1,
+ scaleX: 1,
+ }}
+ className="w-full h-px bg-white/20 mt-3 origin-left"
+ initial={{ opacity: 0, scaleX: 0 }}
+ transition={{
+ duration: 0.3,
+ delay: 0.1,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ />
+ )}
+ </div>
+ ))}
+ </div>
+ </motion.div>
+ ) : (
+ <motion.div
+ animate={{
+ opacity: 1,
+ }}
+ className="w-full p-4"
+ exit={{
+ opacity: 0,
+ transition: {
+ duration: 0.2,
+ ease: "easeOut",
+ },
+ }}
+ initial={{
+ opacity: 0,
+ }}
+ key="expanded-view"
+ layout
+ style={{
+ transform: "translateZ(0)",
+ willChange: "opacity, transform",
+ }}
+ transition={{
+ opacity: {
+ duration: 0.15,
+ ease: "easeInOut",
+ },
+ }}
+ >
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex items-center justify-between mb-4"
+ initial={{ opacity: 0, y: -10 }}
+ layout
+ transition={{
+ delay: 0.05,
+ duration: 0.2,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ >
+ <HeadingH2Bold className="text-white">
+ {expandedView === "mcp" && "Model Context Protocol"}
+ {expandedView === "profile" && "Profile"}
+ </HeadingH2Bold>
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ initial={{ opacity: 0, scale: 0.8 }}
+ transition={{
+ delay: 0.08,
+ duration: 0.2,
+ }}
+ >
+ <Button
+ className="text-white/70 hover:text-white transition-colors duration-200"
+ onClick={() => {
+ setIsCollapsing(true)
+ setExpandedView(null)
+ }}
+ size="icon"
+ variant="ghost"
+ >
+ <X className="h-5 w-5" />
+ </Button>
+ </motion.div>
+ </motion.div>
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="max-h-[70vh] overflow-y-auto pr-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{
+ delay: 0.1,
+ duration: 0.25,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ >
+ {expandedView === "mcp" && <MCPView />}
+ {expandedView === "profile" && <ProfileView />}
+ </motion.div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </motion.div>
+ </motion.nav>
+ </div>
+ </LayoutGroup>
+ )}
+
+ {/* Mobile Menu with Vaul Drawer */}
+ {isMobile && (
+ <Drawer.Root
+ open={isMobileMenuOpen || !!expandedView}
+ onOpenChange={(open) => {
+ if (!open) {
+ setIsMobileMenuOpen(false)
+ setExpandedView(null)
+ setActivePanel(null)
+ }
+ }}
+ >
+ {/* Menu Trigger Button */}
+ {!isMobileMenuOpen && !expandedView && (
+ <Drawer.Trigger asChild>
+ <div className={`fixed bottom-8 right-6 z-100 ${mobileZIndex}`}>
+ <motion.button
+ animate={{ scale: 1, opacity: 1 }}
+ className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl"
+ initial={{ scale: 0.8, opacity: 0 }}
+ onClick={() => {
+ setIsMobileMenuOpen(true)
+ setActivePanel("menu")
+ }}
+ transition={{
+ duration: 0.3,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ type="button"
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ {/* Glass effect background */}
+ <div className="absolute inset-0 rounded-full">
+ <GlassMenuEffect rounded="rounded-full" />
+ </div>
+ <svg
+ className="h-6 w-6 relative z-10"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth={2}
+ viewBox="0 0 24 24"
+ >
+ <title>Open menu</title>
+ <path
+ d="M4 6h16M4 12h16M4 18h16"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </motion.button>
+ </div>
+ </Drawer.Trigger>
+ )}
+
+ <Drawer.Portal>
+ <Drawer.Overlay className="fixed inset-0 bg-black/40 z-[60]" />
+ <Drawer.Content className="bg-transparent fixed bottom-0 left-0 right-0 z-[70] outline-none">
+ <div className="w-full flex flex-col text-sm font-medium shadow-2xl relative overflow-hidden rounded-t-3xl max-h-[80vh]">
+ {/* Glass effect background */}
+ <div className="absolute inset-0 rounded-t-3xl">
+ <GlassMenuEffect rounded="rounded-t-3xl" />
+ </div>
+
+ {/* Drag Handle */}
+ <div className="relative z-20 flex justify-center py-3">
+ <div className="w-12 h-1 bg-white/30 rounded-full" />
+ </div>
+
+ {/* Menu content */}
+ <div className="relative z-20 flex flex-col w-full px-6 pb-8">
+ <AnimatePresence
+ initial={false}
+ mode="wait"
+ onExitComplete={() => setIsCollapsing(false)}
+ >
+ {!expandedView ? (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="w-full flex flex-col gap-6"
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ key="menu-items-mobile"
+ layout
+ >
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ initial={{ opacity: 0, y: -10 }}
+ transition={{ delay: 0.08 }}
+ >
+ <ProjectSelector />
+ </motion.div>
+
+ {/* Menu Items */}
+ <div className="flex flex-col gap-3">
+ {menuItems.map((item, index) => (
+ <div key={item.key}>
+ <motion.button
+ animate={{
+ opacity: 1,
+ y: 0,
+ transition: {
+ delay: 0.1 + index * 0.05,
+ duration: 0.3,
+ ease: "easeOut",
+ },
+ }}
+ className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative"
+ id={menuItemTourIds[item.key]}
+ initial={{ opacity: 0, y: 10 }}
+ layout
+ onClick={() => {
+ handleMenuItemClick(item.key)
+ if (item.key !== "mcp" && item.key !== "profile") {
+ setIsMobileMenuOpen(false)
+ }
+ }}
+ type="button"
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <item.icon className="h-5 w-5 drop-shadow-lg flex-shrink-0" />
+ <span className="drop-shadow-lg text-sm font-medium flex-1 text-left">
+ {item.text}
+ </span>
+ {/* Show warning indicator for Add Memory when limits approached */}
+ {shouldShowLimitWarning &&
+ item.key === "addUrl" && (
+ <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
+ {memoriesLimit - memoriesUsed} left
+ </span>
+ )}
+ </motion.button>
+ {/* Add horizontal line after first item */}
+ {index === 0 && (
+ <motion.div
+ animate={{
+ opacity: 1,
+ scaleX: 1,
+ }}
+ className="w-full h-px bg-white/20 mt-2 origin-left"
+ initial={{ opacity: 0, scaleX: 0 }}
+ transition={{
+ duration: 0.3,
+ delay: 0.15 + index * 0.05,
+ ease: [0.4, 0, 0.2, 1],
+ }}
+ />
+ )}
+ </div>
+ ))}
+ </div>
+ </motion.div>
+ ) : (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="w-full p-2 flex flex-col"
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ key="expanded-view-mobile"
+ layout
+ >
+ <div className="flex-1">
+ <motion.div className="mb-4 flex items-center justify-between" layout>
+ <HeadingH2Bold className="text-white">
+ {expandedView === "addUrl" && "Add Memory"}
+ {expandedView === "mcp" && "Model Context Protocol"}
+ {expandedView === "profile" && "Profile"}
+ </HeadingH2Bold>
+ <Button
+ className="text-white/70 hover:text-white transition-colors duration-200"
+ onClick={() => {
+ setIsCollapsing(true)
+ setExpandedView(null)
+ }}
+ size="icon"
+ variant="ghost"
+ >
+ <X className="h-5 w-5" />
+ </Button>
+ </motion.div>
+ <div className="max-h-[60vh] overflow-y-auto pr-1">
+ {expandedView === "addUrl" && (
+ <AddMemoryExpandedView />
+ )}
+ {expandedView === "mcp" && <MCPView />}
+ {expandedView === "profile" && <ProfileView />}
+ </div>
+ </div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+ </div>
+ </Drawer.Content>
+ </Drawer.Portal>
+ </Drawer.Root>
+ )}
+
+ {showAddMemoryView && (
+ <AddMemoryView
+ initialTab="note"
+ onClose={() => setShowAddMemoryView(false)}
+ />
+ )}
+ </>
+ )
+}
+
+export default Menu
diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx
new file mode 100644
index 00000000..bb62d8ca
--- /dev/null
+++ b/apps/web/components/project-selector.tsx
@@ -0,0 +1,566 @@
+"use client"
+
+import { $fetch } from "@repo/lib/api"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { ChevronDown, FolderIcon, Plus, Trash2, Loader2, MoreVertical, MoreHorizontal } from "lucide-react"
+import { motion, AnimatePresence } from "motion/react"
+import { useState } from "react"
+import { toast } from "sonner"
+import { CreateProjectDialog } from "./create-project-dialog"
+import { useProject } from "@/stores"
+import { useProjectName } from "@/hooks/use-project-name"
+import { useProjectMutations } from "@/hooks/use-project-mutations"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@repo/ui/components/dropdown-menu"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@repo/ui/components/dialog"
+import { Button } from "@repo/ui/components/button"
+import { Label } from "@repo/ui/components/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@repo/ui/components/select"
+import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
+
+interface Project {
+ id: string
+ name: string
+ containerTag: string
+ createdAt: string
+ updatedAt: string
+ isExperimental?: boolean
+}
+
+export function ProjectSelector() {
+ const queryClient = useQueryClient()
+ const [isOpen, setIsOpen] = useState(false)
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
+ const { selectedProject } = useProject()
+ const projectName = useProjectName()
+ const { switchProject, deleteProjectMutation } = useProjectMutations()
+ const [deleteDialog, setDeleteDialog] = useState<{
+ open: boolean
+ project: null | { id: string; name: string; containerTag: string }
+ action: "move" | "delete"
+ targetProjectId: string
+ }>({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: DEFAULT_PROJECT_ID,
+ })
+ const [expDialog, setExpDialog] = useState<{
+ open: boolean
+ projectId: string
+ }>({
+ open: false,
+ projectId: "",
+ })
+
+ const { data: projects = [], isLoading } = useQuery({
+ queryKey: ["projects"],
+ queryFn: async () => {
+ const response = await $fetch("@get/projects")
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load projects")
+ }
+
+ return response.data?.projects || []
+ },
+ staleTime: 30 * 1000,
+ })
+
+ const enableExperimentalMutation = useMutation({
+ mutationFn: async (projectId: string) => {
+ const response = await $fetch(`@post/projects/${projectId}/enable-experimental`)
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to enable experimental mode")
+ }
+ return response.data
+ },
+ onSuccess: () => {
+ toast.success("Experimental mode enabled for project")
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ setExpDialog({ open: false, projectId: "" })
+ },
+ onError: (error) => {
+ toast.error("Failed to enable experimental mode", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ const handleProjectSelect = (containerTag: string) => {
+ switchProject(containerTag)
+ setIsOpen(false)
+ }
+
+ const handleCreateNewProject = () => {
+ setIsOpen(false)
+ setShowCreateDialog(true)
+ }
+
+ return (
+ <div className="relative">
+ <motion.button
+ className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition-colors"
+ onClick={() => setIsOpen(!isOpen)}
+ whileHover={{ scale: 1.01 }}
+ whileTap={{ scale: 0.99 }}
+ >
+ <FolderIcon className="h-3.5 w-3.5 text-white/70" />
+ <span className="text-xs font-medium text-white/90 max-w-32 truncate">
+ {isLoading ? "..." : projectName}
+ </span>
+ <motion.div
+ animate={{ rotate: isOpen ? 180 : 0 }}
+ transition={{ duration: 0.15 }}
+ >
+ <ChevronDown className="h-3 w-3 text-white/50" />
+ </motion.div>
+ </motion.button>
+
+ <AnimatePresence>
+ {isOpen && (
+ <>
+ <motion.div
+ className="fixed inset-0 z-40"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ onClick={() => setIsOpen(false)}
+ />
+
+ <motion.div
+ className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden"
+ initial={{ opacity: 0, y: -5, scale: 0.98 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: -5, scale: 0.98 }}
+ transition={{ duration: 0.15 }}
+ >
+ <div className="p-1.5 max-h-64 overflow-y-auto">
+ {/* Default Project */}
+ <motion.div
+ className={`flex items-center justify-between p-2 rounded-md transition-colors cursor-pointer ${
+ selectedProject === DEFAULT_PROJECT_ID
+ ? "bg-white/15"
+ : "hover:bg-white/8"
+ }`}
+ onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
+ >
+ <div className="flex items-center gap-2">
+ <FolderIcon className="h-3.5 w-3.5 text-white/70" />
+ <span className="text-xs font-medium text-white">Default</span>
+ </div>
+ </motion.div>
+
+ {/* User Projects */}
+ {projects
+ .filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
+ .map((project: Project, index: number) => (
+ <motion.div
+ key={project.id}
+ className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
+ selectedProject === project.containerTag
+ ? "bg-white/15"
+ : "hover:bg-white/8"
+ }`}
+ initial={{ opacity: 0, x: -5 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: index * 0.03 }}
+ >
+ <div
+ className="flex items-center gap-2 flex-1 cursor-pointer"
+ onClick={() => handleProjectSelect(project.containerTag)}
+ >
+ <FolderIcon className="h-3.5 w-3.5 text-white/70" />
+ <span className="text-xs font-medium text-white truncate max-w-32">
+ {project.name}
+ </span>
+ </div>
+ <div className="flex items-center gap-1">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <motion.button
+ className="p-1 hover:bg-white/10 rounded transition-all"
+ onClick={(e) => e.stopPropagation()}
+ whileHover={{ scale: 1.1 }}
+ whileTap={{ scale: 0.9 }}
+ >
+ <MoreHorizontal className="h-3 w-3 text-white/50" />
+ </motion.button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ className="bg-black/90 border-white/10"
+ >
+ {/* Show experimental toggle only if NOT experimental and NOT default project */}
+ {!project.isExperimental &&
+ project.containerTag !== DEFAULT_PROJECT_ID && (
+ <DropdownMenuItem
+ className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs"
+ onClick={(e) => {
+ e.stopPropagation()
+ setExpDialog({
+ open: true,
+ projectId: project.id,
+ })
+ setIsOpen(false)
+ }}
+ >
+ <div className="h-3 w-3 mr-2 rounded border border-blue-400" />
+ Enable Experimental Mode
+ </DropdownMenuItem>
+ )}
+ {project.isExperimental && (
+ <DropdownMenuItem
+ className="text-blue-300/50 text-xs"
+ disabled
+ >
+ <div className="h-3 w-3 mr-2 rounded bg-blue-400" />
+ Experimental Mode Active
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem
+ className="text-red-400 hover:text-red-300 cursor-pointer text-xs"
+ onClick={(e) => {
+ e.stopPropagation()
+ setDeleteDialog({
+ open: true,
+ project: {
+ id: project.id,
+ name: project.name,
+ containerTag: project.containerTag,
+ },
+ action: "move",
+ targetProjectId: "",
+ })
+ setIsOpen(false)
+ }}
+ >
+ <Trash2 className="h-3 w-3 mr-2" />
+ Delete Project
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </motion.div>
+ ))}
+
+ <motion.div
+ className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1"
+ onClick={handleCreateNewProject}
+ whileHover={{ x: 1 }}
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ delay: (projects.length + 1) * 0.03 }}
+ >
+ <Plus className="h-3.5 w-3.5 text-white/70" />
+ <span className="text-xs font-medium text-white/80">New Project</span>
+ </motion.div>
+ </div>
+ </motion.div>
+ </>
+ )}
+ </AnimatePresence>
+
+ <CreateProjectDialog
+ open={showCreateDialog}
+ onOpenChange={setShowCreateDialog}
+ />
+
+ {/* Delete Project Dialog */}
+ <AnimatePresence>
+ {deleteDialog.open && deleteDialog.project && (
+ <Dialog
+ onOpenChange={(open) =>
+ setDeleteDialog((prev) => ({ ...prev, open }))
+ }
+ open={deleteDialog.open}
+ >
+ <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Delete Project</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Are you sure you want to delete "{deleteDialog.project.name}"?
+ Choose what to do with the documents in this project.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <input
+ checked={deleteDialog.action === "move"}
+ className="w-4 h-4"
+ id="move"
+ name="action"
+ onChange={() =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ action: "move",
+ }))
+ }
+ type="radio"
+ />
+ <Label
+ className="text-white cursor-pointer text-sm"
+ htmlFor="move"
+ >
+ Move documents to another project
+ </Label>
+ </div>
+ {deleteDialog.action === "move" && (
+ <motion.div
+ animate={{ opacity: 1, height: "auto" }}
+ className="ml-6"
+ exit={{ opacity: 0, height: 0 }}
+ initial={{ opacity: 0, height: 0 }}
+ >
+ <Select
+ onValueChange={(value) =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ targetProjectId: value,
+ }))
+ }
+ value={deleteDialog.targetProjectId}
+ >
+ <SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
+ <SelectValue placeholder="Select target project..." />
+ </SelectTrigger>
+ <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ value={DEFAULT_PROJECT_ID}
+ >
+ Default Project
+ </SelectItem>
+ {projects
+ .filter(
+ (p: Project) =>
+ p.id !== deleteDialog.project?.id &&
+ p.containerTag !== DEFAULT_PROJECT_ID,
+ )
+ .map((project: Project) => (
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key={project.id}
+ value={project.id}
+ >
+ {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </motion.div>
+ )}
+ <div className="flex items-center space-x-2">
+ <input
+ checked={deleteDialog.action === "delete"}
+ className="w-4 h-4"
+ id="delete"
+ name="action"
+ onChange={() =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ action: "delete",
+ }))
+ }
+ type="radio"
+ />
+ <Label
+ className="text-white cursor-pointer text-sm"
+ htmlFor="delete"
+ >
+ Delete all documents in this project
+ </Label>
+ </div>
+ {deleteDialog.action === "delete" && (
+ <motion.p
+ animate={{ opacity: 1 }}
+ className="text-sm text-red-400 ml-6"
+ initial={{ opacity: 0 }}
+ >
+ ⚠️ This action cannot be undone. All documents will be
+ permanently deleted.
+ </motion.p>
+ )}
+ </div>
+ </div>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() =>
+ setDeleteDialog({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: "",
+ })
+ }
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className={`${deleteDialog.action === "delete"
+ ? "bg-red-600 hover:bg-red-700"
+ : "bg-white/10 hover:bg-white/20"
+ } text-white border-white/20`}
+ disabled={
+ deleteProjectMutation.isPending ||
+ (deleteDialog.action === "move" &&
+ !deleteDialog.targetProjectId)
+ }
+ onClick={() => {
+ if (deleteDialog.project) {
+ deleteProjectMutation.mutate({
+ projectId: deleteDialog.project.id,
+ action: deleteDialog.action,
+ targetProjectId:
+ deleteDialog.action === "move"
+ ? deleteDialog.targetProjectId
+ : undefined,
+ }, {
+ onSuccess: () => {
+ setDeleteDialog({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: "",
+ })
+ }
+ })
+ }
+ }}
+ type="button"
+ >
+ {deleteProjectMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ {deleteDialog.action === "move"
+ ? "Moving..."
+ : "Deleting..."}
+ </>
+ ) : deleteDialog.action === "move" ? (
+ "Move & Delete Project"
+ ) : (
+ "Delete Everything"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+
+ {/* Experimental Mode Confirmation Dialog */}
+ <AnimatePresence>
+ {expDialog.open && (
+ <Dialog
+ onOpenChange={(open) => setExpDialog({ ...expDialog, open })}
+ open={expDialog.open}
+ >
+ <DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="flex flex-col gap-4"
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle className="text-white">
+ Enable Experimental Mode?
+ </DialogTitle>
+ <DialogDescription className="text-white/60">
+ Experimental mode enables beta features and advanced memory
+ relationships for this project.
+ <br />
+ <br />
+ <span className="text-yellow-400 font-medium">
+ Warning:
+ </span>{" "}
+ This action is{" "}
+ <span className="text-red-400 font-bold">irreversible</span>
+ . Once enabled, you cannot return to regular mode for this
+ project.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() =>
+ setExpDialog({ open: false, projectId: "" })
+ }
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-blue-600 hover:bg-blue-700 text-white"
+ disabled={enableExperimentalMutation.isPending}
+ onClick={() =>
+ enableExperimentalMutation.mutate(expDialog.projectId)
+ }
+ type="button"
+ >
+ {enableExperimentalMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Enabling...
+ </>
+ ) : (
+ "Enable Experimental Mode"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
diff --git a/apps/web/components/referral-upgrade-modal.tsx b/apps/web/components/referral-upgrade-modal.tsx
new file mode 100644
index 00000000..2ad1b18a
--- /dev/null
+++ b/apps/web/components/referral-upgrade-modal.tsx
@@ -0,0 +1,290 @@
+"use client"
+
+import { useAuth } from "@lib/auth-context"
+import {
+ fetchMemoriesFeature,
+ fetchSubscriptionStatus,
+} from "@lib/queries"
+import { Button } from "@repo/ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle
+} from "@repo/ui/components/dialog"
+import { Input } from "@repo/ui/components/input"
+import { Label } from "@repo/ui/components/label"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/tabs"
+import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
+import { useCustomer } from "autumn-js/react"
+import {
+ CheckCircle,
+ Copy,
+ CreditCard,
+ Gift,
+ LoaderIcon,
+ Share2,
+ Users
+} from "lucide-react"
+import { motion } from "motion/react"
+import Link from "next/link"
+import { useState } from "react"
+
+interface ReferralUpgradeModalProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalProps) {
+ const { user } = useAuth()
+ const autumn = useCustomer()
+ const [isLoading, setIsLoading] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
+ const memoriesUsed = memoriesCheck?.usage ?? 0
+ const memoriesLimit = memoriesCheck?.included_usage ?? 0
+
+ // Fetch subscription status
+ const {
+ data: status = {
+ consumer_pro: null,
+ },
+ isLoading: isCheckingStatus,
+ } = fetchSubscriptionStatus(autumn as any)
+
+ const isPro = status.consumer_pro
+
+ // Handle upgrade
+ const handleUpgrade = async () => {
+ setIsLoading(true)
+ try {
+ await autumn.attach({
+ productId: "consumer_pro",
+ successUrl: "https://app.supermemory.ai/",
+ })
+ window.location.reload()
+ } catch (error) {
+ console.error(error)
+ setIsLoading(false)
+ }
+ }
+
+ // Generate referral link (you'll need to implement this based on your referral system)
+ const referralLink = `https://app.supermemory.ai/ref/${user?.id || 'user'}`
+
+ const handleCopyReferralLink = async () => {
+ try {
+ await navigator.clipboard.writeText(referralLink)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (error) {
+ console.error('Failed to copy:', error)
+ }
+ }
+
+ const handleShare = async () => {
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title: 'Join Supermemory',
+ text: 'Check out Supermemory - the best way to organize and search your digital memories!',
+ url: referralLink,
+ })
+ } catch (error) {
+ console.error('Error sharing:', error)
+ }
+ } else {
+ handleCopyReferralLink()
+ }
+ }
+
+ if (user?.isAnonymous) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-md bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Get More Memories</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Sign in to access referrals and upgrade options
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-center py-6">
+ <Button
+ asChild
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ >
+ <Link href="/login">Sign in</Link>
+ </Button>
+ </div>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-lg bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader className="mb-4">
+ <DialogTitle>Get More Memories</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Expand your memory capacity through referrals or upgrades
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* Current Usage */}
+ <div className="bg-white/5 rounded-lg p-4 mb-6">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm text-white/70">Current Usage</span>
+ <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}>
+ {memoriesUsed} / {memoriesLimit} memories
+ </span>
+ </div>
+ <div className="w-full bg-white/10 rounded-full h-2">
+ <div
+ className={`h-2 rounded-full transition-all ${
+ memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500"
+ }`}
+ style={{
+ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
+ }}
+ />
+ </div>
+ </div>
+
+ {/* Tabs */}
+ <Tabs defaultValue="refer" className="w-full">
+ <TabsList className="grid w-full grid-cols-2 bg-white/5">
+ <TabsTrigger value="refer" className="flex items-center gap-2">
+ <Users className="w-4 h-4" />
+ Refer Friends
+ </TabsTrigger>
+ {!isPro && (
+ <TabsTrigger value="upgrade" className="flex items-center gap-2">
+ <CreditCard className="w-4 h-4" />
+ Upgrade Plan
+ </TabsTrigger>
+ )}
+ </TabsList>
+
+ <TabsContent value="refer" className="space-y-4 mt-6">
+ <div className="text-center">
+ <Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" />
+ <HeadingH3Bold className="text-white mb-2">
+ Invite Friends, Get More Memories
+ </HeadingH3Bold>
+ <p className="text-white/70 text-sm">
+ For every friend who joins, you both get +5 extra memories!
+ </p>
+ </div>
+
+ <div className="bg-white/5 rounded-lg p-4">
+ <Label className="text-sm text-white/70 mb-2 block">
+ Your Referral Link
+ </Label>
+ <div className="flex gap-2">
+ <Input
+ className="flex-1 bg-white/10 border-white/20 text-white"
+ readOnly
+ value={referralLink}
+ />
+ <Button
+ className="bg-white/5 hover:bg-white/10 text-white border-white/20"
+ onClick={handleCopyReferralLink}
+ size="sm"
+ variant="outline"
+ >
+ {copied ? (
+ <CheckCircle className="w-4 h-4" />
+ ) : (
+ <Copy className="w-4 h-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+
+ <Button
+ className="w-full bg-white/5 hover:bg-white/10 text-white border-white/20"
+ onClick={handleShare}
+ variant="outline"
+ >
+ <Share2 className="w-4 h-4 mr-2" />
+ Share Link
+ </Button>
+ </TabsContent>
+
+ {!isPro && (
+ <TabsContent value="upgrade" className="space-y-4 mt-6">
+ <div className="text-center">
+ <CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" />
+ <HeadingH3Bold className="text-white mb-2">
+ Upgrade to Pro
+ </HeadingH3Bold>
+ <p className="text-white/70 text-sm">
+ Get unlimited memories and advanced features
+ </p>
+ </div>
+
+ <div className="bg-white/5 rounded-lg border border-white/10 p-4">
+ <h4 className="font-medium text-white mb-3">
+ Pro Plan Benefits
+ </h4>
+ <ul className="space-y-2">
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 500 memories (vs {memoriesLimit} free)
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 10 connections
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Advanced search
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Priority support
+ </li>
+ </ul>
+ </div>
+
+ <Button
+ className="w-full bg-blue-600 hover:bg-blue-700 text-white"
+ disabled={isLoading || isCheckingStatus}
+ onClick={handleUpgrade}
+ >
+ {isLoading || isCheckingStatus ? (
+ <>
+ <LoaderIcon className="h-4 w-4 animate-spin mr-2" />
+ Upgrading...
+ </>
+ ) : (
+ "Upgrade to Pro - $15/month"
+ )}
+ </Button>
+
+ <p className="text-xs text-white/50 text-center">
+ Cancel anytime. No questions asked.
+ </p>
+ </TabsContent>
+ )}
+ </Tabs>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/apps/web/components/spinner.tsx b/apps/web/components/spinner.tsx
new file mode 100644
index 00000000..aad15e33
--- /dev/null
+++ b/apps/web/components/spinner.tsx
@@ -0,0 +1,8 @@
+import { cn } from "@lib/utils";
+import { Loader2 } from "lucide-react";
+
+export function Spinner({ className }: { className?: string }) {
+ return (
+ <Loader2 className={cn("size-4 animate-spin", className)} />
+ )
+} \ No newline at end of file
diff --git a/apps/web/components/text-shimmer.tsx b/apps/web/components/text-shimmer.tsx
new file mode 100644
index 00000000..bef0e7a9
--- /dev/null
+++ b/apps/web/components/text-shimmer.tsx
@@ -0,0 +1,57 @@
+'use client';
+import React, { useMemo, type JSX } from 'react';
+import { motion } from 'motion/react';
+import { cn } from '@lib/utils';
+
+export type TextShimmerProps = {
+ children: string;
+ as?: React.ElementType;
+ className?: string;
+ duration?: number;
+ spread?: number;
+};
+
+function TextShimmerComponent({
+ children,
+ as: Component = 'p',
+ className,
+ duration = 2,
+ spread = 2,
+}: TextShimmerProps) {
+ const MotionComponent = motion.create(
+ Component as keyof JSX.IntrinsicElements
+ );
+
+ const dynamicSpread = useMemo(() => {
+ return children.length * spread;
+ }, [children, spread]);
+
+ return (
+ <MotionComponent
+ className={cn(
+ 'relative inline-block bg-[length:250%_100%,auto] bg-clip-text',
+ 'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]',
+ '[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
+ 'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
+ className
+ )}
+ initial={{ backgroundPosition: '100% center' }}
+ animate={{ backgroundPosition: '0% center' }}
+ transition={{
+ repeat: Infinity,
+ duration,
+ ease: 'linear',
+ }}
+ style={
+ {
+ '--spread': `${dynamicSpread}px`,
+ backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
+ } as React.CSSProperties
+ }
+ >
+ {children}
+ </MotionComponent>
+ );
+}
+
+export const TextShimmer = React.memo(TextShimmerComponent);
diff --git a/apps/web/components/tour.tsx b/apps/web/components/tour.tsx
new file mode 100644
index 00000000..981effbc
--- /dev/null
+++ b/apps/web/components/tour.tsx
@@ -0,0 +1,413 @@
+"use client"
+
+import { Button } from "@repo/ui/components/button"
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
+import { AnimatePresence, motion } from "motion/react"
+import * as React from "react"
+import { analytics } from "@/lib/analytics"
+
+// Types
+export interface TourStep {
+ content: React.ReactNode
+ selectorId: string
+ position?: "top" | "bottom" | "left" | "right" | "center"
+ onClickWithinArea?: () => void
+}
+
+interface TourContextType {
+ currentStep: number
+ totalSteps: number
+ nextStep: () => void
+ previousStep: () => void
+ endTour: () => void
+ isActive: boolean
+ isPaused: boolean
+ startTour: () => void
+ setSteps: (steps: TourStep[]) => void
+ steps: TourStep[]
+ isTourCompleted: boolean
+ setIsTourCompleted: (completed: boolean) => void
+ // Expansion state tracking
+ setMenuExpanded: (expanded: boolean) => void
+ setChatExpanded: (expanded: boolean) => void
+}
+
+// Context
+const TourContext = React.createContext<TourContextType | undefined>(undefined)
+
+export function useTour() {
+ const context = React.useContext(TourContext)
+ if (!context) {
+ throw new Error("useTour must be used within a TourProvider")
+ }
+ return context
+}
+
+// Provider
+interface TourProviderProps {
+ children: React.ReactNode
+ onComplete?: () => void
+ className?: string
+ isTourCompleted?: boolean
+}
+
+export function TourProvider({
+ children,
+ onComplete,
+ className,
+ isTourCompleted: initialCompleted = false,
+}: TourProviderProps) {
+ const [currentStep, setCurrentStep] = React.useState(-1)
+ const [steps, setSteps] = React.useState<TourStep[]>([])
+ const [isActive, setIsActive] = React.useState(false)
+ const [isTourCompleted, setIsTourCompleted] = React.useState(initialCompleted)
+
+ // Track expansion states
+ const [isMenuExpanded, setIsMenuExpanded] = React.useState(false)
+ const [isChatExpanded, setIsChatExpanded] = React.useState(false)
+
+ // Calculate if tour should be paused
+ const isPaused = React.useMemo(() => {
+ return isActive && (isMenuExpanded || isChatExpanded)
+ }, [isActive, isMenuExpanded, isChatExpanded])
+
+ const startTour = React.useCallback(() => {
+ console.debug("Starting tour with", steps.length, "steps")
+ analytics.tourStarted()
+ setCurrentStep(0)
+ setIsActive(true)
+ }, [steps])
+
+ const endTour = React.useCallback(() => {
+ setCurrentStep(-1)
+ setIsActive(false)
+ setIsTourCompleted(true) // Mark tour as completed when ended/skipped
+ analytics.tourSkipped()
+ if (onComplete) {
+ onComplete()
+ }
+ }, [onComplete])
+
+ const nextStep = React.useCallback(() => {
+ if (currentStep < steps.length - 1) {
+ setCurrentStep(currentStep + 1)
+ } else {
+ analytics.tourCompleted()
+ endTour()
+ setIsTourCompleted(true)
+ }
+ }, [currentStep, steps.length, endTour])
+
+ const previousStep = React.useCallback(() => {
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1)
+ }
+ }, [currentStep])
+
+ const setMenuExpanded = React.useCallback((expanded: boolean) => {
+ setIsMenuExpanded(expanded)
+ }, [])
+
+ const setChatExpanded = React.useCallback((expanded: boolean) => {
+ setIsChatExpanded(expanded)
+ }, [])
+
+ const value = React.useMemo(
+ () => ({
+ currentStep,
+ totalSteps: steps.length,
+ nextStep,
+ previousStep,
+ endTour,
+ isActive,
+ isPaused,
+ startTour,
+ setSteps,
+ steps,
+ isTourCompleted,
+ setIsTourCompleted,
+ setMenuExpanded,
+ setChatExpanded,
+ }),
+ [
+ currentStep,
+ steps,
+ nextStep,
+ previousStep,
+ endTour,
+ isActive,
+ isPaused,
+ startTour,
+ isTourCompleted,
+ setMenuExpanded,
+ setChatExpanded,
+ ],
+ )
+
+ return (
+ <TourContext.Provider value={value}>
+ {children}
+ {isActive && !isPaused && (
+ <>
+ {console.log(
+ "Rendering TourHighlight for step:",
+ currentStep,
+ currentStep >= 0 && currentStep < steps.length
+ ? steps[currentStep]
+ : "No step",
+ )}
+ <TourHighlight
+ className={className}
+ currentStepIndex={currentStep}
+ steps={steps}
+ />
+ </>
+ )}
+ </TourContext.Provider>
+ )
+}
+
+// Tour Highlight Component
+function TourHighlight({
+ currentStepIndex,
+ steps,
+ className,
+}: {
+ currentStepIndex: number
+ steps: TourStep[]
+ className?: string
+}) {
+ const { nextStep, previousStep, endTour } = useTour()
+ const [elementRect, setElementRect] = React.useState<DOMRect | null>(null)
+
+ // Get current step safely
+ const step =
+ currentStepIndex >= 0 && currentStepIndex < steps.length
+ ? steps[currentStepIndex]
+ : null
+
+ React.useEffect(() => {
+ if (!step) return
+
+ // Use requestAnimationFrame to ensure DOM is ready
+ const rafId = requestAnimationFrame(() => {
+ const element = document.getElementById(step.selectorId)
+ console.debug(
+ "Looking for element with ID:",
+ step.selectorId,
+ "Found:",
+ !!element,
+ )
+ if (element) {
+ const rect = element.getBoundingClientRect()
+ console.debug("Element rect:", {
+ id: step.selectorId,
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ bottom: rect.bottom,
+ right: rect.right,
+ })
+ setElementRect(rect)
+ }
+ })
+
+ // Add click listener for onClickWithinArea
+ let clickHandler: ((e: MouseEvent) => void) | null = null
+ if (step.onClickWithinArea) {
+ const element = document.getElementById(step.selectorId)
+ if (element) {
+ clickHandler = (e: MouseEvent) => {
+ if (element.contains(e.target as Node)) {
+ step.onClickWithinArea?.()
+ }
+ }
+ document.addEventListener("click", clickHandler)
+ }
+ }
+
+ return () => {
+ cancelAnimationFrame(rafId)
+ if (clickHandler) {
+ document.removeEventListener("click", clickHandler)
+ }
+ }
+ }, [step])
+
+ if (!step) return null
+
+ // Keep the wrapper mounted but animate the content
+ return (
+ <AnimatePresence mode="wait">
+ {elementRect && (
+ <motion.div
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ key={`tour-step-${currentStepIndex}`}
+ transition={{ duration: 0.2 }}
+ >
+ {/* Highlight Border */}
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className={`fixed z-[101] pointer-events-none ${className}`}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ style={{
+ top: elementRect.top + window.scrollY,
+ left: elementRect.left + window.scrollX,
+ width: elementRect.width,
+ height: elementRect.height,
+ }}
+ >
+ <div className="absolute inset-0 rounded-lg outline-4 outline-blue-500/50 outline-offset-0" />
+ </motion.div>
+
+ {/* Tooltip with Glass Effect */}
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="fixed z-[102] w-72 rounded-lg shadow-xl overflow-hidden"
+ exit={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
+ initial={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
+ style={{
+ top: (() => {
+ const baseTop =
+ step.position === "bottom"
+ ? elementRect.bottom + 8
+ : step.position === "top"
+ ? elementRect.top - 200
+ : elementRect.top + elementRect.height / 2 - 100
+
+ // Ensure tooltip stays within viewport
+ const maxTop = window.innerHeight - 250 // Leave space for tooltip height
+ const minTop = 10
+ return Math.max(minTop, Math.min(baseTop, maxTop))
+ })(),
+ left: (() => {
+ const baseLeft =
+ step.position === "right"
+ ? elementRect.right + 8
+ : step.position === "left"
+ ? elementRect.left - 300
+ : elementRect.left + elementRect.width / 2 - 150
+
+ // Ensure tooltip stays within viewport
+ const maxLeft = window.innerWidth - 300 // Tooltip width
+ const minLeft = 10
+ return Math.max(minLeft, Math.min(baseLeft, maxLeft))
+ })(),
+ }}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-lg" />
+
+ {/* Content */}
+ <div className="relative z-10 p-4">
+ <div className="mb-4 text-white">{step.content}</div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-gray-300">
+ {currentStepIndex + 1} / {steps.length}
+ </span>
+ <div className="flex gap-2">
+ <Button
+ className="border-white/20 text-white hover:bg-white/10"
+ onClick={endTour}
+ size="sm"
+ variant="outline"
+ >
+ Skip
+ </Button>
+ {currentStepIndex > 0 && (
+ <Button
+ className="border-white/20 text-white hover:bg-white/10"
+ onClick={previousStep}
+ size="sm"
+ variant="outline"
+ >
+ Previous
+ </Button>
+ )}
+ <Button
+ className="bg-white/20 text-white hover:bg-white/30"
+ onClick={nextStep}
+ size="sm"
+ >
+ {currentStepIndex === steps.length - 1 ? "Finish" : "Next"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </motion.div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ )
+}
+
+// Tour Alert Dialog
+interface TourAlertDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) {
+ const { startTour, setIsTourCompleted } = useTour()
+
+ const handleStart = () => {
+ console.debug("TourAlertDialog: Starting tour")
+ onOpenChange(false)
+ startTour()
+ }
+
+ const handleSkip = () => {
+ analytics.tourSkipped()
+ setIsTourCompleted(true) // Mark tour as completed when skipped
+ onOpenChange(false)
+ }
+
+ if (!open) return null
+
+ return (
+ <AnimatePresence>
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[102] w-[90vw] max-w-2xl rounded-lg shadow-xl overflow-hidden"
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-lg" />
+
+ {/* Content */}
+ <div className="relative z-10 p-8 md:p-10 lg:p-12">
+ <h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
+ Welcome to supermemory™
+ </h2>
+ <p className="text-lg md:text-xl text-gray-200 mb-8 leading-relaxed">
+ This is your personal knowledge graph where all your memories are
+ stored and connected. Let's take a quick tour to help you get
+ familiar with the interface.
+ </p>
+ <div className="flex gap-4 justify-end">
+ <Button
+ className="border-white/20 text-white hover:bg-white/10 px-6 py-2 text-base"
+ onClick={handleSkip}
+ size="lg"
+ variant="outline"
+ >
+ Skip Tour
+ </Button>
+ <Button
+ className="bg-white/20 text-white hover:bg-white/30 px-6 py-2 text-base"
+ onClick={handleStart}
+ size="lg"
+ >
+ Start Tour
+ </Button>
+ </div>
+ </div>
+ </motion.div>
+ </AnimatePresence>
+ )
+}
diff --git a/apps/web/components/views/add-memory.tsx b/apps/web/components/views/add-memory.tsx
new file mode 100644
index 00000000..744faa35
--- /dev/null
+++ b/apps/web/components/views/add-memory.tsx
@@ -0,0 +1,1425 @@
+import { $fetch } from "@lib/api"
+import {
+ fetchConsumerProProduct,
+ fetchMemoriesFeature,
+} from "@repo/lib/queries"
+import { Button } from "@repo/ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@repo/ui/components/dialog"
+import { Input } from "@repo/ui/components/input"
+import { Label } from "@repo/ui/components/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@repo/ui/components/select"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@repo/ui/components/tabs"
+import { Textarea } from "@repo/ui/components/textarea"
+import { useForm } from "@tanstack/react-form"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ Dropzone,
+ DropzoneContent,
+ DropzoneEmptyState,
+} from "@ui/components/shadcn-io/dropzone"
+import { useCustomer } from "autumn-js/react"
+import {
+ Brain,
+ FileIcon,
+ Link as LinkIcon,
+ Loader2,
+ PlugIcon,
+ Plus,
+ UploadIcon,
+} from "lucide-react"
+import { AnimatePresence, motion } from "motion/react"
+import Link from "next/link"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useProject } from "@/stores"
+import { ConnectionsTabContent } from "./connections-tab-content"
+import { analytics } from "@/lib/analytics"
+
+// // Processing status component
+// function ProcessingStatus({ status }: { status: string }) {
+// const statusConfig = {
+// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" },
+// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" },
+// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" },
+// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" },
+// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" },
+// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" },
+// }
+
+// const config =
+// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown
+
+// return (
+// <div className={`flex items-center gap-1 text-xs ${config.color}`}>
+// <span>{config.icon}</span>
+// <span>{config.label}</span>
+// </div>
+// )
+// }
+
+export function AddMemoryView({
+ onClose,
+ initialTab = "note",
+}: {
+ onClose?: () => void
+ initialTab?: "note" | "link" | "file" | "connect"
+}) {
+ const queryClient = useQueryClient()
+ const { selectedProject, setSelectedProject } = useProject()
+ const [showAddDialog, setShowAddDialog] = useState(true)
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([])
+ const [activeTab, setActiveTab] = useState<
+ "note" | "link" | "file" | "connect"
+ >(initialTab)
+ const autumn = useCustomer()
+ const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false)
+ const [newProjectName, setNewProjectName] = useState("")
+
+ // Check memory limits
+ const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
+
+ const memoriesUsed = memoriesCheck?.usage ?? 0
+ const memoriesLimit = memoriesCheck?.included_usage ?? 0
+
+ // Check if user is pro
+ const { data: proCheck } = fetchConsumerProProduct(autumn as any)
+ const isProUser = proCheck?.allowed ?? false
+
+ const canAddMemory = memoriesUsed < memoriesLimit
+
+ // Fetch projects for the dropdown
+ const { data: projects = [], isLoading: isLoadingProjects } = useQuery({
+ queryKey: ["projects"],
+ queryFn: async () => {
+ const response = await $fetch("@get/projects")
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load projects")
+ }
+
+ return response.data?.projects || []
+ },
+ staleTime: 30 * 1000,
+ })
+
+ // Create project mutation
+ const createProjectMutation = useMutation({
+ mutationFn: async (name: string) => {
+ const response = await $fetch("@post/projects", {
+ body: { name },
+ })
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to create project")
+ }
+
+ return response.data
+ },
+ onSuccess: (data) => {
+ analytics.projectCreated()
+ toast.success("Project created successfully!")
+ setShowCreateProjectDialog(false)
+ setNewProjectName("")
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ // Set the newly created project as selected
+ if (data?.containerTag) {
+ setSelectedProject(data.containerTag)
+ // Update form values
+ addContentForm.setFieldValue("project", data.containerTag)
+ fileUploadForm.setFieldValue("project", data.containerTag)
+ }
+ },
+ onError: (error) => {
+ toast.error("Failed to create project", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ const addContentForm = useForm({
+ defaultValues: {
+ content: "",
+ project: selectedProject || "sm_project_default",
+ },
+ onSubmit: async ({ value, formApi }) => {
+ addContentMutation.mutate({
+ content: value.content,
+ project: value.project,
+ contentType: activeTab as "note" | "link",
+ })
+ formApi.reset()
+ },
+ validators: {
+ onChange: z.object({
+ content: z.string().min(1, "Content is required"),
+ project: z.string(),
+ }),
+ },
+ })
+
+ // Re-validate content field when tab changes between note/link
+ // biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is
+ useEffect(() => {
+ // Trigger validation of the content field when switching between note/link
+ if (activeTab === "note" || activeTab === "link") {
+ const currentValue = addContentForm.getFieldValue("content")
+ if (currentValue) {
+ addContentForm.validateField("content", "change")
+ }
+ }
+ }, [activeTab])
+
+ // Form for file upload metadata
+ const fileUploadForm = useForm({
+ defaultValues: {
+ title: "",
+ description: "",
+ project: selectedProject || "sm_project_default",
+ },
+ onSubmit: async ({ value, formApi }) => {
+ if (selectedFiles.length === 0) {
+ toast.error("Please select a file to upload")
+ return
+ }
+
+ for (const file of selectedFiles) {
+ fileUploadMutation.mutate({
+ file,
+ title: value.title || undefined,
+ description: value.description || undefined,
+ project: value.project,
+ })
+ }
+
+ formApi.reset()
+ setSelectedFiles([])
+ },
+ })
+
+ const handleUpgrade = async () => {
+ try {
+ await autumn.attach({
+ productId: "consumer_pro",
+ successUrl: "https://app.supermemory.ai/",
+ })
+ window.location.reload()
+ } catch (error) {
+ console.error(error)
+ }
+ }
+
+ const addContentMutation = useMutation({
+ mutationFn: async ({
+ content,
+ project,
+ contentType,
+ }: {
+ content: string
+ project: string
+ contentType: "note" | "link"
+ }) => {
+ // close the modal
+ onClose?.()
+
+ const processingPromise = (async () => {
+ // First, create the memory
+ const response = await $fetch("@post/memories", {
+ body: {
+ content: content,
+ containerTags: [project],
+ metadata: {
+ sm_source: "consumer", // Use "consumer" source to bypass limits
+ },
+ },
+ })
+
+ if (response.error) {
+ throw new Error(
+ response.error?.message || `Failed to add ${contentType}`,
+ )
+ }
+
+ const memoryId = response.data.id
+
+ // Polling function to check status
+ const pollForCompletion = async (): Promise<any> => {
+ let attempts = 0
+ const maxAttempts = 60 // Maximum 5 minutes (60 attempts * 5 seconds)
+
+ while (attempts < maxAttempts) {
+ try {
+ const memory = await $fetch<{ status: string; content: string }>(
+ "@get/memories/" + memoryId,
+ )
+
+ if (memory.error) {
+ throw new Error(
+ memory.error?.message || "Failed to fetch memory status",
+ )
+ }
+
+ // Check if processing is complete
+ // Adjust this condition based on your API response structure
+ if (
+ memory.data?.status === "done" ||
+ // Sometimes the memory might be ready when it has content and no processing status
+ memory.data?.content
+ ) {
+ return memory.data
+ }
+
+ // If still processing, wait and try again
+ await new Promise((resolve) => setTimeout(resolve, 5000)) // Wait 5 seconds
+ attempts++
+ } catch (error) {
+ console.error("Error polling memory status:", error)
+ // Don't throw immediately, retry a few times
+ if (attempts >= 3) {
+ throw new Error("Failed to check processing status")
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5000))
+ attempts++
+ }
+ }
+
+ // If we've exceeded max attempts, throw an error
+ throw new Error("Memory processing timed out. Please check back later.")
+ }
+
+ // Wait for completion
+ const completedMemory = await pollForCompletion()
+ return completedMemory
+ })()
+
+ toast.promise(processingPromise, {
+ loading: "Processing...",
+ success: `${contentType === "link" ? "Link" : "Note"} created successfully!`,
+ error: (err) => `Failed to add ${contentType}: ${err instanceof Error ? err.message : "Unknown error"}`,
+ })
+
+ return processingPromise
+ },
+ onMutate: async ({ content, project, contentType }) => {
+ console.log("🚀 onMutate starting...")
+
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] })
+ console.log("✅ Cancelled queries")
+
+ // Snapshot the previous value
+ const previousMemories = queryClient.getQueryData([
+ "documents-with-memories",
+ project,
+ ])
+ console.log("📸 Previous memories:", previousMemories)
+
+ // Create optimistic memory
+ const optimisticMemory = {
+ id: `temp-${Date.now()}`,
+ content: contentType === "link" ? "" : content,
+ url: contentType === "link" ? content : null,
+ title:
+ contentType === "link" ? "Processing..." : content.substring(0, 100),
+ description: contentType === "link" ? "Extracting content..." : "Processing content...",
+ containerTags: [project],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ status: "queued",
+ type: contentType,
+ metadata: {
+ processingStage: "queued",
+ processingMessage: "Added to processing queue"
+ },
+ memoryEntries: [],
+ isOptimistic: true,
+ }
+ console.log("🎯 Created optimistic memory:", optimisticMemory)
+
+ // Optimistically update to include the new memory
+ queryClient.setQueryData(["documents-with-memories", project], (old: any) => {
+ console.log("🔄 Old data:", old)
+ const newData = old
+ ? {
+ ...old,
+ documents: [optimisticMemory, ...(old.documents || [])],
+ totalCount: (old.totalCount || 0) + 1,
+ }
+ : { documents: [optimisticMemory], totalCount: 1 }
+ console.log("✨ New data:", newData)
+ return newData
+ })
+
+ console.log("✅ onMutate completed")
+ return { previousMemories, optimisticId: optimisticMemory.id }
+ },
+ // If the mutation fails, roll back to the previous value
+ onError: (error, variables, context) => {
+ if (context?.previousMemories) {
+ queryClient.setQueryData(
+ ["documents-with-memories", variables.project],
+ context.previousMemories,
+ )
+ }
+ },
+ onSuccess: (_data, variables) => {
+ analytics.memoryAdded({
+ type: variables.contentType === "link" ? "link" : "note",
+ project_id: variables.project,
+ content_length: variables.content.length,
+ })
+
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] })
+
+ setTimeout(() => {
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] })
+ }, 30000) // 30 seconds
+
+ setTimeout(() => {
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] })
+ }, 120000) // 2 minutes
+
+ setShowAddDialog(false)
+ onClose?.()
+ },
+ })
+
+ const fileUploadMutation = useMutation({
+ mutationFn: async ({
+ file,
+ title,
+ description,
+ project,
+ }: {
+ file: File
+ title?: string
+ description?: string
+ project: string
+ }) => {
+ // TEMPORARILY DISABLED: Limit check disabled
+ // Check if user can add more memories
+ // if (!canAddMemory && !isProUser) {
+ // throw new Error(
+ // `Free plan limit reached (${memoriesLimit} memories). Upgrade to Pro for up to 500 memories.`,
+ // );
+ // }
+
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("containerTags", JSON.stringify([project]))
+
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memories/file`,
+ {
+ method: "POST",
+ body: formData,
+ credentials: "include",
+ },
+ )
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.error || "Failed to upload file")
+ }
+
+ const data = await response.json()
+
+ // If we have metadata, we can update the document after creation
+ if (title || description) {
+ await $fetch(`@patch/memories/${data.id}`, {
+ body: {
+ metadata: {
+ ...(title && { title }),
+ ...(description && { description }),
+ sm_source: "consumer", // Use "consumer" source to bypass limits
+ },
+ },
+ })
+ }
+
+ return data
+ },
+ // Optimistic update
+ onMutate: async ({ file, title, description, project }) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] })
+
+ // Snapshot the previous value
+ const previousMemories = queryClient.getQueryData([
+ "documents-with-memories",
+ project,
+ ])
+
+ // Create optimistic memory for the file
+ const optimisticMemory = {
+ id: `temp-file-${Date.now()}`,
+ content: "",
+ url: null,
+ title: title || file.name,
+ description: description || `Uploading ${file.name}...`,
+ containerTags: [project],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ status: "processing",
+ type: "file",
+ metadata: {
+ fileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ },
+ memoryEntries: [],
+ }
+
+ // Optimistically update to include the new memory
+ queryClient.setQueryData(["documents-with-memories", project], (old: any) => {
+ if (!old) return { documents: [optimisticMemory], totalCount: 1 }
+ return {
+ ...old,
+ documents: [optimisticMemory, ...(old.documents || [])],
+ totalCount: (old.totalCount || 0) + 1,
+ }
+ })
+
+ // Return a context object with the snapshotted value
+ return { previousMemories }
+ },
+ // If the mutation fails, roll back to the previous value
+ onError: (error, variables, context) => {
+ if (context?.previousMemories) {
+ queryClient.setQueryData(
+ ["documents-with-memories", variables.project],
+ context.previousMemories,
+ )
+ }
+ toast.error("Failed to upload file", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ onSuccess: (_data, variables) => {
+ analytics.memoryAdded({
+ type: "file",
+ project_id: variables.project,
+ file_size: variables.file.size,
+ file_type: variables.file.type,
+ })
+ toast.success("File uploaded successfully!", {
+ description: "Your file is being processed",
+ })
+ setShowAddDialog(false)
+ onClose?.()
+ },
+ // Always refetch after error or success
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ },
+ })
+
+ return (
+ <AnimatePresence mode="wait">
+ {showAddDialog && (
+ <Dialog
+ key="add-memory-dialog"
+ onOpenChange={(open) => {
+ setShowAddDialog(open)
+ if (!open) onClose?.()
+ }}
+ open={showAddDialog}
+ >
+ <DialogContent className="sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80]">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Add to Memory</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Save any webpage, article, or file to your memory
+ </DialogDescription>
+ {
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="mt-2"
+ initial={{ opacity: 0, y: -10 }}
+ >
+ <div className="text-xs text-white/50">
+ {memoriesUsed} of {memoriesLimit} memories used
+ {!isProUser && memoriesUsed >= memoriesLimit * 0.8 && (
+ <span className="text-yellow-400 ml-2">
+ • {memoriesLimit - memoriesUsed} remaining
+ </span>
+ )}
+ </div>
+ {!canAddMemory && !isProUser && (
+ <motion.div
+ animate={{ opacity: 1, height: "auto" }}
+ className="mt-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
+ initial={{ opacity: 0, height: 0 }}
+ >
+ <p className="text-sm text-yellow-400">
+ You've reached the free plan limit.
+ <Button
+ asChild
+ className="text-yellow-400 hover:text-yellow-300 px-2"
+ onClick={handleUpgrade}
+ size="sm"
+ variant="link"
+ >
+ Upgrade to Pro
+ </Button>
+ for up to 5000 memories.
+ </p>
+ </motion.div>
+ )}
+ </motion.div>
+ }
+ </DialogHeader>
+
+ <Tabs
+ className="mt-4"
+ onValueChange={(v) =>
+ setActiveTab(v as "note" | "link" | "file" | "connect")
+ }
+ value={activeTab}
+ >
+ <TabsList className="grid w-full grid-cols-4 bg-white/5">
+ <TabsTrigger
+ className="data-[state=active]:bg-white/10"
+ value="note"
+ >
+ <Brain className="h-4 w-4 mr-2" />
+ Note
+ </TabsTrigger>
+ <TabsTrigger
+ className="data-[state=active]:bg-white/10"
+ value="link"
+ >
+ <LinkIcon className="h-4 w-4 mr-2" />
+ Link
+ </TabsTrigger>
+ <TabsTrigger
+ className="data-[state=active]:bg-white/10"
+ value="file"
+ >
+ <FileIcon className="h-4 w-4 mr-2" />
+ File
+ </TabsTrigger>
+ <TabsTrigger
+ className="data-[state=active]:bg-white/10"
+ value="connect"
+ >
+ <PlugIcon className="h-4 w-4 mr-2" />
+ Connect
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent className="space-y-4 mt-4" value="note">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ addContentForm.handleSubmit()
+ }}
+ >
+ <div className="grid gap-4">
+ {/* Note Input */}
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="note-content"
+ >
+ Note
+ </label>
+ <addContentForm.Field
+ name="content"
+ validators={{
+ onChange: ({ value }) => {
+ if (!value || value.trim() === "") {
+ return "Note is required"
+ }
+ return undefined
+ },
+ }}
+ >
+ {({ state, handleChange, handleBlur }) => (
+ <>
+ <Textarea
+ className={`bg-white/5 border-white/10 text-white min-h-32 max-h-64 overflow-y-auto resize-none ${addContentMutation.isPending
+ ? "opacity-50"
+ : ""
+ }`}
+ disabled={addContentMutation.isPending}
+ id="note-content"
+ onBlur={handleBlur}
+ onChange={(e) => handleChange(e.target.value)}
+ placeholder="Write your note here..."
+ value={state.value}
+ />
+ {state.meta.errors.length > 0 && (
+ <motion.p
+ animate={{ opacity: 1, height: "auto" }}
+ className="text-sm text-red-400 mt-1"
+ exit={{ opacity: 0, height: 0 }}
+ initial={{ opacity: 0, height: 0 }}
+ >
+ {state.meta.errors
+ .map((error) =>
+ typeof error === "string"
+ ? error
+ : (error?.message ??
+ `Error: ${JSON.stringify(error)}`),
+ )
+ .join(", ")}
+ </motion.p>
+ )}
+ </>
+ )}
+ </addContentForm.Field>
+ </motion.div>
+
+ {/* Project Selection */}
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : ""
+ }`}
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.15 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="note-project"
+ >
+ Project
+ </label>
+ <addContentForm.Field name="project">
+ {({ state, handleChange }) => (
+ <Select
+ disabled={
+ isLoadingProjects ||
+ addContentMutation.isPending
+ }
+ onValueChange={(value) => {
+ if (value === "create-new-project") {
+ setShowCreateProjectDialog(true)
+ } else {
+ handleChange(value)
+ }
+ }}
+ value={state.value}
+ >
+ <SelectTrigger
+ className="bg-white/5 border-white/10 text-white"
+ id="note-project"
+ >
+ <SelectValue placeholder="Select a project" />
+ </SelectTrigger>
+ <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key="default"
+ value="sm_project_default"
+ >
+ Default Project
+ </SelectItem>
+ {projects
+ .filter(
+ (p) =>
+ p.containerTag !== "sm_project_default" &&
+ p.id,
+ )
+ .map((project) => (
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key={project.id || project.containerTag}
+ value={project.containerTag}
+ >
+ {project.name}
+ </SelectItem>
+ ))}
+ <SelectItem
+ className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
+ key="create-new"
+ value="create-new-project"
+ >
+ <div className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ <span>Create new project</span>
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </addContentForm.Field>
+ <p className="text-xs text-white/50 mt-1">
+ Choose which project to save this note to
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter className="mt-6">
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setShowAddDialog(false)
+ onClose?.()
+ addContentForm.reset()
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ addContentMutation.isPending ||
+ !addContentForm.state.canSubmit
+ }
+ type="submit"
+ >
+ {addContentMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Adding...
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4 mr-2" />
+ Add Note
+ </>
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </form>
+ </TabsContent>
+
+ <TabsContent className="space-y-4 mt-4" value="link">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ addContentForm.handleSubmit()
+ }}
+ >
+ <div className="grid gap-4">
+ {/* Link Input */}
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="link-content"
+ >
+ Link
+ </label>
+ <addContentForm.Field
+ name="content"
+ validators={{
+ onChange: ({ value }) => {
+ if (!value || value.trim() === "") {
+ return "Link is required"
+ }
+ try {
+ new URL(value)
+ return undefined
+ } catch {
+ return "Please enter a valid link"
+ }
+ },
+ }}
+ >
+ {({ state, handleChange, handleBlur }) => (
+ <>
+ <Input
+ className={`bg-white/5 border-white/10 text-white ${addContentMutation.isPending
+ ? "opacity-50"
+ : ""
+ }`}
+ disabled={addContentMutation.isPending}
+ id="link-content"
+ onBlur={handleBlur}
+ onChange={(e) => handleChange(e.target.value)}
+ placeholder="https://example.com/article"
+ value={state.value}
+ />
+ {state.meta.errors.length > 0 && (
+ <motion.p
+ animate={{ opacity: 1, height: "auto" }}
+ className="text-sm text-red-400 mt-1"
+ exit={{ opacity: 0, height: 0 }}
+ initial={{ opacity: 0, height: 0 }}
+ >
+ {state.meta.errors
+ .map((error) =>
+ typeof error === "string"
+ ? error
+ : (error?.message ??
+ `Error: ${JSON.stringify(error)}`),
+ )
+ .join(", ")}
+ </motion.p>
+ )}
+ </>
+ )}
+ </addContentForm.Field>
+ </motion.div>
+
+ {/* Project Selection */}
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : ""
+ }`}
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.15 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="link-project"
+ >
+ Project
+ </label>
+ <addContentForm.Field name="project">
+ {({ state, handleChange }) => (
+ <Select
+ disabled={
+ isLoadingProjects ||
+ addContentMutation.isPending
+ }
+ onValueChange={(value) => {
+ if (value === "create-new-project") {
+ setShowCreateProjectDialog(true)
+ } else {
+ handleChange(value)
+ }
+ }}
+ value={state.value}
+ >
+ <SelectTrigger
+ className="bg-white/5 border-white/10 text-white"
+ id="link-project"
+ >
+ <SelectValue placeholder="Select a project" />
+ </SelectTrigger>
+ <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key="default"
+ value="sm_project_default"
+ >
+ Default Project
+ </SelectItem>
+ {projects
+ .filter(
+ (p) =>
+ p.containerTag !== "sm_project_default" &&
+ p.id,
+ )
+ .map((project) => (
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key={project.id || project.containerTag}
+ value={project.containerTag}
+ >
+ {project.name}
+ </SelectItem>
+ ))}
+ <SelectItem
+ className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
+ key="create-new"
+ value="create-new-project"
+ >
+ <div className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ <span>Create new project</span>
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </addContentForm.Field>
+ <p className="text-xs text-white/50 mt-1">
+ Choose which project to save this link to
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter className="mt-6">
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setShowAddDialog(false)
+ onClose?.()
+ addContentForm.reset()
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ addContentMutation.isPending ||
+ !addContentForm.state.canSubmit
+ }
+ type="submit"
+ >
+ {addContentMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Adding...
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4 mr-2" />
+ Add Link
+ </>
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </form>
+ </TabsContent>
+
+ <TabsContent className="space-y-4 mt-4" value="file">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ fileUploadForm.handleSubmit()
+ }}
+ >
+ <div className="grid gap-4">
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <label className="text-sm font-medium" htmlFor="file">
+ File
+ </label>
+ <Dropzone
+ accept={{
+ "application/pdf": [".pdf"],
+ "application/msword": [".doc"],
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ [".docx"],
+ "text/plain": [".txt"],
+ "text/markdown": [".md"],
+ "text/csv": [".csv"],
+ "application/json": [".json"],
+ "image/*": [
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ],
+ }}
+ className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40"
+ maxFiles={10}
+ maxSize={10 * 1024 * 1024} // 10MB
+ onDrop={(acceptedFiles) =>
+ setSelectedFiles(acceptedFiles)
+ }
+ src={selectedFiles}
+ >
+ <DropzoneEmptyState />
+ <DropzoneContent className="overflow-auto" />
+ </Dropzone>
+ </motion.div>
+
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.15 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="file-title"
+ >
+ Title (optional)
+ </label>
+ <fileUploadForm.Field name="title">
+ {({ state, handleChange, handleBlur }) => (
+ <Input
+ className="bg-white/5 border-white/10 text-white"
+ id="file-title"
+ onBlur={handleBlur}
+ onChange={(e) => handleChange(e.target.value)}
+ placeholder="Give this file a title"
+ value={state.value}
+ />
+ )}
+ </fileUploadForm.Field>
+ </motion.div>
+
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.2 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="file-description"
+ >
+ Description (optional)
+ </label>
+ <fileUploadForm.Field name="description">
+ {({ state, handleChange, handleBlur }) => (
+ <Textarea
+ className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none"
+ id="file-description"
+ onBlur={handleBlur}
+ onChange={(e) => handleChange(e.target.value)}
+ placeholder="Add notes or context about this file"
+ value={state.value}
+ />
+ )}
+ </fileUploadForm.Field>
+ </motion.div>
+
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.25 }}
+ >
+ <label
+ className="text-sm font-medium"
+ htmlFor="file-project"
+ >
+ Project
+ </label>
+ <fileUploadForm.Field name="project">
+ {({ state, handleChange }) => (
+ <Select
+ disabled={isLoadingProjects}
+ onValueChange={(value) => {
+ if (value === "create-new-project") {
+ setShowCreateProjectDialog(true)
+ } else {
+ handleChange(value)
+ }
+ }}
+ value={state.value}
+ >
+ <SelectTrigger
+ className="bg-white/5 border-white/10 text-white"
+ id="file-project"
+ >
+ <SelectValue placeholder="Select a project" />
+ </SelectTrigger>
+ <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key="default"
+ value="sm_project_default"
+ >
+ Default Project
+ </SelectItem>
+ {projects
+ .filter(
+ (p) =>
+ p.containerTag !== "sm_project_default" &&
+ p.id,
+ )
+ .map((project) => (
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key={project.id || project.containerTag}
+ value={project.containerTag}
+ >
+ {project.name}
+ </SelectItem>
+ ))}
+ <SelectItem
+ className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
+ key="create-new"
+ value="create-new-project"
+ >
+ <div className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ <span>Create new project</span>
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </fileUploadForm.Field>
+ <p className="text-xs text-white/50 mt-1">
+ Choose which project to save this file to
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter className="mt-6">
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setShowAddDialog(false)
+ onClose?.()
+ fileUploadForm.reset()
+ setSelectedFiles([])
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ fileUploadMutation.isPending ||
+ selectedFiles.length === 0
+ }
+ type="submit"
+ >
+ {fileUploadMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Uploading...
+ </>
+ ) : (
+ <>
+ <UploadIcon className="h-4 w-4 mr-2" />
+ Upload File
+ </>
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </form>
+ </TabsContent>
+
+ <TabsContent className="space-y-4 mt-4" value="connect">
+ <ConnectionsTabContent />
+ </TabsContent>
+ </Tabs>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+
+ {/* Create Project Dialog */}
+ {showCreateProjectDialog && (
+ <Dialog
+ key="create-project-dialog"
+ onOpenChange={setShowCreateProjectDialog}
+ open={showCreateProjectDialog}
+ >
+ <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80]">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Create New Project</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Give your project a unique name
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <Label htmlFor="projectName">Project Name</Label>
+ <Input
+ className="bg-white/5 border-white/10 text-white"
+ id="projectName"
+ onChange={(e) => setNewProjectName(e.target.value)}
+ placeholder="My Awesome Project"
+ value={newProjectName}
+ />
+ <p className="text-xs text-white/50">
+ This will help you organize your memories
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setShowCreateProjectDialog(false)
+ setNewProjectName("")
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ createProjectMutation.isPending || !newProjectName.trim()
+ }
+ onClick={() => createProjectMutation.mutate(newProjectName)}
+ type="button"
+ >
+ {createProjectMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Creating...
+ </>
+ ) : (
+ "Create Project"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ )
+}
+
+export function AddMemoryExpandedView() {
+ const [showDialog, setShowDialog] = useState(false)
+ const [selectedTab, setSelectedTab] = useState<
+ "note" | "link" | "file" | "connect"
+ >("note")
+
+ const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => {
+ setSelectedTab(tab)
+ setShowDialog(true)
+ }
+
+ return (
+ <>
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="space-y-6"
+ initial={{ opacity: 0, y: 10 }}
+ >
+ <p className="text-sm text-white/70">
+ Save any webpage, article, or file to your memory
+ </p>
+
+ <div className="flex flex-wrap gap-2">
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => handleOpenDialog("note")}
+ size="sm"
+ variant="outline"
+ >
+ <Brain className="h-4 w-4 mr-2" />
+ Note
+ </Button>
+ </motion.div>
+
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => handleOpenDialog("link")}
+ size="sm"
+ variant="outline"
+ >
+ <LinkIcon className="h-4 w-4 mr-2" />
+ Link
+ </Button>
+ </motion.div>
+
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => handleOpenDialog("file")}
+ size="sm"
+ variant="outline"
+ >
+ <FileIcon className="h-4 w-4 mr-2" />
+ File
+ </Button>
+ </motion.div>
+
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => handleOpenDialog("connect")}
+ size="sm"
+ variant="outline"
+ >
+ <PlugIcon className="h-4 w-4 mr-2" />
+ Connect
+ </Button>
+ </motion.div>
+ </div>
+ </motion.div>
+
+ {showDialog && (
+ <AddMemoryView
+ initialTab={selectedTab}
+ onClose={() => setShowDialog(false)}
+ />
+ )}
+ </>
+ )
+}
diff --git a/apps/web/components/views/billing.tsx b/apps/web/components/views/billing.tsx
new file mode 100644
index 00000000..b0c436bb
--- /dev/null
+++ b/apps/web/components/views/billing.tsx
@@ -0,0 +1,261 @@
+import { useAuth } from "@lib/auth-context"
+import {
+ fetchConnectionsFeature,
+ fetchMemoriesFeature,
+ fetchSubscriptionStatus,
+} from "@lib/queries"
+import { Button } from "@ui/components/button"
+import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold"
+import { useCustomer } from "autumn-js/react"
+import { CheckCircle, LoaderIcon, X } from "lucide-react"
+import { motion } from "motion/react"
+import Link from "next/link"
+import { useState, useEffect } from "react"
+import { analytics } from "@/lib/analytics"
+
+export function BillingView() {
+ const autumn = useCustomer()
+ const { user } = useAuth()
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ analytics.billingViewed()
+ }, [])
+
+ const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
+
+ const memoriesUsed = memoriesCheck?.usage ?? 0
+ const memoriesLimit = memoriesCheck?.included_usage ?? 0
+
+ const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any)
+
+ const connectionsUsed = connectionsCheck?.usage ?? 0
+
+ // Fetch subscription status with React Query
+ const {
+ data: status = {
+ consumer_pro: null,
+ },
+ isLoading: isCheckingStatus,
+ } = fetchSubscriptionStatus(autumn as any)
+
+ // Handle upgrade
+ const handleUpgrade = async () => {
+ analytics.upgradeInitiated()
+ setIsLoading(true)
+ try {
+ await autumn.attach({
+ productId: "consumer_pro",
+ successUrl: "https://app.supermemory.ai/",
+ })
+ analytics.upgradeCompleted()
+ window.location.reload()
+ } catch (error) {
+ console.error(error)
+ setIsLoading(false)
+ }
+ }
+
+ // Handle manage billing
+ const handleManageBilling = async () => {
+ analytics.billingPortalOpened()
+ await autumn.openBillingPortal({
+ returnUrl: "https://app.supermemory.ai",
+ })
+ }
+
+ const isPro = status.consumer_pro
+
+ if (user?.isAnonymous) {
+ return (
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="text-center py-8"
+ initial={{ opacity: 0, scale: 0.9 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <p className="text-white/70 mb-4">Sign in to unlock premium features</p>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ asChild
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ size="sm"
+ >
+ <Link href="/login">Sign in</Link>
+ </Button>
+ </motion.div>
+ </motion.div>
+ )
+ }
+
+ if (isPro) {
+ return (
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="space-y-6"
+ initial={{ opacity: 0, y: 10 }}
+ >
+ <div className="space-y-3">
+ <HeadingH3Bold className="text-white flex items-center gap-2">
+ Pro Plan
+ <span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
+ Active
+ </span>
+ </HeadingH3Bold>
+ <p className="text-sm text-white/70">
+ You're enjoying expanded memory capacity with supermemory Pro!
+ </p>
+ </div>
+
+ {/* Current Usage */}
+ <div className="space-y-3">
+ <h4 className="text-sm font-medium text-white/90">Current Usage</h4>
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <span className="text-sm text-white/70">Memories</span>
+ <span className="text-sm text-white/90">
+ {memoriesUsed} / {memoriesLimit}
+ </span>
+ </div>
+ <div className="w-full bg-white/10 rounded-full h-2">
+ <div
+ className="bg-green-500 h-2 rounded-full transition-all"
+ style={{
+ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
+ }}
+ />
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <span className="text-sm text-white/70">Connections</span>
+ <span className="text-sm text-white/90">
+ {connectionsUsed} / 10
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={handleManageBilling}
+ size="sm"
+ variant="outline"
+ >
+ Manage Billing
+ </Button>
+ </motion.div>
+ </motion.div>
+ )
+ }
+
+ return (
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="space-y-6"
+ initial={{ opacity: 0, y: 10 }}
+ >
+ {/* Current Usage - Free Plan */}
+ <div className="space-y-3">
+ <HeadingH3Bold className="text-white">Current Plan: Free</HeadingH3Bold>
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <span className="text-sm text-white/70">Memories</span>
+ <span
+ className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}
+ >
+ {memoriesUsed} / {memoriesLimit}
+ </span>
+ </div>
+ <div className="w-full bg-white/10 rounded-full h-2">
+ <div
+ className={`h-2 rounded-full transition-all ${
+ memoriesUsed >= memoriesLimit ? "bg-red-500" : "bg-blue-500"
+ }`}
+ style={{
+ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
+ }}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Comparison */}
+ <div className="space-y-4">
+ <HeadingH3Bold className="text-white">Upgrade to Pro</HeadingH3Bold>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* Free Plan */}
+ <div className="p-4 bg-white/5 rounded-lg border border-white/10">
+ <h4 className="font-medium text-white/90 mb-3">Free Plan</h4>
+ <ul className="space-y-2">
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 200 memories
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <X className="h-4 w-4 text-red-400" />
+ No connections
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Basic search
+ </li>
+ </ul>
+ </div>
+
+ {/* Pro Plan */}
+ <div className="p-4 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
+ <h4 className="font-medium text-white mb-3 flex items-center gap-2">
+ Pro Plan
+ <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full">
+ Recommended
+ </span>
+ </h4>
+ <ul className="space-y-2">
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 5000 memories
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 10 connections
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Advanced search
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Priority support
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
+ <Button
+ className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0 w-full"
+ disabled={isLoading || isCheckingStatus}
+ onClick={handleUpgrade}
+ size="sm"
+ >
+ {isLoading || isCheckingStatus ? (
+ <>
+ <LoaderIcon className="h-4 w-4 animate-spin mr-2" />
+ Upgrading...
+ </>
+ ) : (
+ <div>Upgrade to Pro - $15/month (only for first 100 users)</div>
+ )}
+ </Button>
+ </motion.div>
+
+ <p className="text-xs text-white/50 text-center">
+ Cancel anytime. No questions asked.
+ </p>
+ </div>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx
new file mode 100644
index 00000000..0517ac8d
--- /dev/null
+++ b/apps/web/components/views/chat/chat-messages.tsx
@@ -0,0 +1,322 @@
+'use client'
+
+import { useChat, useCompletion } from '@ai-sdk/react'
+import { DefaultChatTransport } from 'ai'
+import { useEffect, useRef, useState } from 'react'
+import { useProject, usePersistentChat } from '@/stores'
+import { Button } from '@ui/components/button'
+import { Input } from '@ui/components/input'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { Spinner } from '../../spinner'
+import { X, ArrowUp, Check, RotateCcw, Copy } from 'lucide-react'
+import { useGraphHighlights } from '@/stores/highlights'
+import { cn } from '@lib/utils'
+import { TextShimmer } from '@/components/text-shimmer'
+import { toast } from 'sonner'
+
+function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) {
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
+ const bottomRef = useRef<HTMLDivElement>(null)
+ const [isAutoScroll, setIsAutoScroll] = useState(true)
+ const [isFarFromBottom, setIsFarFromBottom] = useState(false)
+
+ function scrollToBottom(behavior: ScrollBehavior = 'auto') {
+ const node = bottomRef.current
+ if (node) node.scrollIntoView({ behavior, block: 'end' })
+ }
+
+ useEffect(function observeBottomVisibility() {
+ const container = scrollContainerRef.current
+ const sentinel = bottomRef.current
+ if (!container || !sentinel) return
+
+ const observer = new IntersectionObserver((entries) => {
+ if (!entries || entries.length === 0) return
+ const isIntersecting = entries.some((e) => e.isIntersecting)
+ setIsAutoScroll(isIntersecting)
+ }, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 })
+ observer.observe(sentinel)
+ return () => observer.disconnect()
+ }, [])
+
+ useEffect(function observeContentResize() {
+ const container = scrollContainerRef.current
+ if (!container) return
+ const resizeObserver = new ResizeObserver(() => {
+ if (isAutoScroll) scrollToBottom('auto')
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
+ setIsFarFromBottom(distanceFromBottom > 100)
+ })
+ resizeObserver.observe(container)
+ return () => resizeObserver.disconnect()
+ }, [isAutoScroll])
+
+ function enableAutoScroll() {
+ setIsAutoScroll(true)
+ }
+
+ useEffect(function autoScrollOnNewContent() {
+ if (isAutoScroll) scrollToBottom('auto')
+ }, [isAutoScroll, ...triggerKeys])
+
+ function recomputeDistanceFromBottom() {
+ const container = scrollContainerRef.current
+ if (!container) return
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
+ setIsFarFromBottom(distanceFromBottom > 100)
+ }
+
+ useEffect(() => {
+ recomputeDistanceFromBottom()
+ }, [...triggerKeys])
+
+ function onScroll() {
+ recomputeDistanceFromBottom()
+ }
+
+ return { scrollContainerRef, bottomRef, isAutoScroll, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } as const
+}
+
+export function ChatMessages() {
+ const { selectedProject } = useProject()
+ const { currentChatId, setCurrentChatId, setConversation, getCurrentConversation, setConversationTitle, getCurrentChat } = usePersistentChat()
+
+ const activeChatIdRef = useRef<string | null>(null)
+ const shouldGenerateTitleRef = useRef<boolean>(false)
+
+ const { setDocumentIds } = useGraphHighlights()
+
+ const { messages, sendMessage, status, stop, setMessages, id, regenerate } = useChat({
+ id: currentChatId ?? undefined,
+ transport: new DefaultChatTransport({
+ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
+ credentials: 'include',
+ body: { metadata: { projectId: selectedProject } },
+ }),
+ maxSteps: 2,
+ onFinish: (result) => {
+ const activeId = activeChatIdRef.current
+ if (!activeId) return
+ if (result.message.role !== 'assistant') return
+
+ if (shouldGenerateTitleRef.current) {
+ const textPart = result.message.parts.find((p: any) => p?.type === 'text') as any
+ const text = textPart?.text?.trim()
+ if (text) {
+ shouldGenerateTitleRef.current = false
+ complete(text)
+ }
+ }
+ },
+ })
+
+ useEffect(() => {
+ activeChatIdRef.current = (currentChatId ?? id) ?? null
+ }, [currentChatId, id])
+
+ useEffect(() => {
+ if (id && id !== currentChatId) {
+ setCurrentChatId(id)
+ }
+ }, [id, currentChatId, setCurrentChatId])
+
+ useEffect(() => {
+ const msgs = getCurrentConversation()
+ setMessages(msgs ?? [])
+ setInput('')
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentChatId])
+
+ useEffect(() => {
+ const activeId = currentChatId ?? id
+ if (activeId && messages.length > 0) {
+ setConversation(activeId, messages)
+ }
+ }, [messages, currentChatId, id, setConversation])
+
+ const [input, setInput] = useState('')
+ const { complete } = useCompletion({
+ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`,
+ credentials: 'include',
+ onFinish: (_, completion) => {
+ const activeId = activeChatIdRef.current
+ if (!completion || !activeId) return
+ setConversationTitle(activeId, completion.trim())
+ }
+ })
+
+ // Update graph highlights from the most recent tool-searchMemories output
+ useEffect(() => {
+ try {
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant')
+ if (!lastAssistant) return
+ const lastSearchPart = [...(lastAssistant.parts as any[])]
+ .reverse()
+ .find((p) => p?.type === 'tool-searchMemories' && p?.state === 'output-available')
+ if (!lastSearchPart) return
+ const output = (lastSearchPart as any).output
+ const ids = Array.isArray(output?.results)
+ ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[]
+ : []
+ if (ids.length > 0) {
+ setDocumentIds(ids)
+ }
+ } catch { }
+ }, [messages, setDocumentIds])
+
+ useEffect(() => {
+ const currentSummary = getCurrentChat()
+ const hasTitle = Boolean(currentSummary?.title && currentSummary.title.trim().length > 0)
+ shouldGenerateTitleRef.current = !hasTitle
+ }, [currentChatId, id, getCurrentChat])
+ const { scrollContainerRef, bottomRef, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } = useStickyAutoScroll([messages, status])
+
+ return (
+ <>
+ <div className="relative grow">
+ <div ref={scrollContainerRef} onScroll={onScroll} className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7">
+ {messages.map((message) => (
+ <div key={message.id} className={cn('flex flex-col', message.role === 'user' ? 'items-end' : 'items-start')}>
+ <div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg">
+ {message.parts
+ .filter((part) => ['text', 'tool-searchMemories', 'tool-addMemory'].includes(part.type))
+ .map((part, index) => {
+ switch (part.type) {
+ case 'text':
+ return (
+ <div key={index} className="prose prose-sm prose-invert max-w-none">
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{(part as any).text}</ReactMarkdown>
+ </div>
+ )
+ case 'tool-searchMemories':
+ switch (part.state) {
+ case 'input-available':
+ case 'input-streaming':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <Spinner className="size-4" /> Searching memories...
+ </div>
+ )
+ case 'output-error':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <X className="size-4" /> Error recalling memories
+ </div>
+ )
+ case 'output-available':
+ {
+ const output = (part as any).output
+ const foundCount = typeof output === 'object' && output !== null && 'count' in output ? Number(output.count) || 0 : 0
+ const ids = Array.isArray(output?.results) ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[] : []
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <Check className="size-4" /> Found {foundCount} memories
+ </div>
+ )
+ }
+
+ }
+ case 'tool-addMemory':
+ switch (part.state) {
+ case 'input-available':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <Spinner className="size-4" /> Adding memory...
+ </div>
+ )
+ case 'output-error':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <X className="size-4" /> Error adding memory
+ </div>
+ )
+ case 'output-available':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <Check className="size-4" /> Memory added
+ </div>
+ )
+ case 'input-streaming':
+ return (
+ <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground">
+ <Spinner className="size-4" /> Adding memory...
+ </div>
+ )
+ }
+ }
+
+ return null
+ })}
+ </div>
+ {message.role === 'assistant' && (
+ <div className='flex items-center gap-0.5 mt-0.5'>
+ <Button variant="ghost" size="icon" className='size-7 text-muted-foreground hover:text-foreground' onClick={() => {
+ navigator.clipboard.writeText(message.parts.filter((p) => p.type === 'text')?.map((p) => (p as any).text).join('\n') ?? '')
+ toast.success('Copied to clipboard')
+ }}>
+ <Copy className="size-3.5" />
+ </Button>
+ <Button variant="ghost" size="icon" className='size-6 text-muted-foreground hover:text-foreground' onClick={() => regenerate({ messageId: message.id })}>
+ <RotateCcw className="size-3.5" />
+ </Button>
+
+ </div>
+ )}
+ </div>
+ ))}
+ {status === 'submitted' && (
+ <div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full">
+ <Spinner className="size-4" />
+ <TextShimmer className='text-sm' duration={1.5}>Thinking...</TextShimmer>
+ </div>
+ )}
+ <div ref={bottomRef} />
+ </div>
+
+ <Button
+ type="button"
+ onClick={() => {
+ enableAutoScroll()
+ scrollToBottom('smooth')
+ }}
+ className={cn(
+ 'rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center',
+ 'transition-all duration-200 ease-out',
+ isFarFromBottom ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none',
+ )}
+ variant="default"
+ size="sm"
+ >
+ Scroll to bottom
+ </Button>
+ </div>
+
+ <form
+ className="flex gap-2 px-4 pb-4 pt-1 relative"
+ onSubmit={(e) => {
+ e.preventDefault()
+ if (status === 'submitted') return
+ if (status === 'streaming') {
+ stop()
+ return
+ }
+ if (input.trim()) {
+ enableAutoScroll()
+ scrollToBottom('auto')
+ sendMessage({ text: input })
+ setInput('')
+ }
+ }}
+ >
+ <div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" />
+ <Input className="w-full" value={input} onChange={(e) => setInput(e.target.value)} disabled={status === 'submitted'} placeholder="Say something..." />
+ <Button type="submit" disabled={status === 'submitted'}>
+ {status === 'ready' ? <ArrowUp className="size-4" /> : status === 'submitted' ? <Spinner className="size-4" /> : <X className="size-4" />}
+ </Button>
+ </form>
+ </>
+ )
+}
+
+
diff --git a/apps/web/components/views/chat/index.tsx b/apps/web/components/views/chat/index.tsx
new file mode 100644
index 00000000..a547f590
--- /dev/null
+++ b/apps/web/components/views/chat/index.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import { useChatOpen, useProject } from '@/stores';
+import { usePersistentChat } from '@/stores';
+import { Button } from '@ui/components/button';
+import { Plus, X, Trash2, HistoryIcon } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@ui/components/dialog";
+import { ScrollArea } from '@ui/components/scroll-area';
+import { cn } from '@lib/utils';
+import { ChatMessages } from './chat-messages';
+import { formatDistanceToNow } from 'date-fns';
+import { analytics } from '@/lib/analytics';
+
+export function ChatRewrite() {
+ const { setIsOpen } = useChatOpen();
+ const { selectedProject } = useProject();
+ const { conversations, currentChatId, setCurrentChatId, getCurrentChat } = usePersistentChat();
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const sorted = useMemo(() => {
+ return [...conversations].sort((a, b) => (a.lastUpdated < b.lastUpdated ? 1 : -1));
+ }, [conversations]);
+
+ function handleNewChat() {
+ analytics.newChatStarted();
+ const newId = crypto.randomUUID();
+ setCurrentChatId(newId);
+ setIsDialogOpen(false);
+ }
+
+ function formatRelativeTime(isoString: string): string {
+ return formatDistanceToNow(new Date(isoString), { addSuffix: true });
+ }
+
+ return (
+ <div className='flex flex-col h-full overflow-y-hidden border-l bg-background'>
+ <div className="border-b px-4 py-3 flex justify-between items-center">
+ <h3 className="text-lg font-semibold line-clamp-1 text-ellipsis overflow-hidden">{getCurrentChat()?.title ?? "New Chat"}</h3>
+ <div className="flex items-center gap-2">
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="icon" onClick={() => analytics.chatHistoryViewed()}>
+ <HistoryIcon className='size-4 text-muted-foreground' />
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader className="pb-4 border-b rounded-t-lg">
+ <DialogTitle className="">
+ Conversations
+ </DialogTitle>
+ <DialogDescription>
+ Project <span className="font-mono font-medium">{selectedProject}</span>
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-96">
+ <div className="flex flex-col gap-1">
+ {sorted.map((c) => {
+ const isActive = c.id === currentChatId;
+ return (
+ <div
+ key={c.id}
+ role="button"
+ tabIndex={0}
+ onClick={() => {
+ setCurrentChatId(c.id);
+ setIsDialogOpen(false);
+ }}
+ className={cn(
+ 'flex items-center justify-between rounded-md px-3 py-2 outline-none',
+ 'transition-colors',
+ isActive ? 'bg-primary/10' : 'hover:bg-muted'
+ )}
+ aria-current={isActive ? 'true' : undefined}
+ >
+ <div className="min-w-0">
+ <div className="flex items-center gap-2">
+ <span className={cn('text-sm font-medium truncate', isActive ? 'text-foreground' : undefined)}>
+ {c.title || 'Untitled Chat'}
+ </span>
+ </div>
+ <div className="text-xs text-muted-foreground">
+ Last updated {formatRelativeTime(c.lastUpdated)}
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ analytics.chatDeleted();
+ }}
+ aria-label="Delete conversation"
+ >
+ <Trash2 className="size-4 text-muted-foreground" />
+ </Button>
+ </div>
+ );
+ })}
+ {sorted.length === 0 && (
+ <div className="text-xs text-muted-foreground px-3 py-2">No conversations yet</div>
+ )}
+ </div>
+ </ScrollArea>
+ <Button variant="outline" size="lg" className="w-full border-dashed" onClick={handleNewChat}>
+ <Plus className='size-4 mr-1' /> New Conversation
+ </Button>
+ </DialogContent>
+ </Dialog>
+ <Button variant="outline" size="icon" onClick={handleNewChat}>
+ <Plus className='size-4 text-muted-foreground' />
+ </Button>
+ <Button variant="outline" size="icon" onClick={() => setIsOpen(false)}>
+ <X className='size-4 text-muted-foreground' />
+ </Button>
+ </div>
+ </div>
+ <ChatMessages />
+ </div >
+ );
+} \ No newline at end of file
diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx
new file mode 100644
index 00000000..177c2a4d
--- /dev/null
+++ b/apps/web/components/views/connections-tab-content.tsx
@@ -0,0 +1,382 @@
+"use client"
+
+import { $fetch } from "@lib/api"
+import {
+ fetchConnectionsFeature,
+ fetchConsumerProProduct,
+} from "@repo/lib/queries"
+import { Button } from "@repo/ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@repo/ui/components/dialog"
+import { Skeleton } from "@repo/ui/components/skeleton"
+import type { ConnectionResponseSchema } from "@repo/validation/api"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
+import { useCustomer } from "autumn-js/react"
+import { Plus, Trash2 } from "lucide-react"
+import { AnimatePresence, motion } from "motion/react"
+import Link from "next/link"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import type { z } from "zod"
+import { useProject } from "@/stores"
+import { analytics } from "@/lib/analytics"
+
+// Define types
+type Connection = z.infer<typeof ConnectionResponseSchema>
+
+// Connector configurations
+const CONNECTORS = {
+ "google-drive": {
+ title: "Google Drive",
+ description: "Connect your Google Docs, Sheets, and Slides",
+ icon: GoogleDrive,
+ },
+ notion: {
+ title: "Notion",
+ description: "Import your Notion pages and databases",
+ icon: Notion,
+ },
+ onedrive: {
+ title: "OneDrive",
+ description: "Access your Microsoft Office documents",
+ icon: OneDrive,
+ },
+} as const
+
+type ConnectorProvider = keyof typeof CONNECTORS
+
+export function ConnectionsTabContent() {
+ const queryClient = useQueryClient()
+ const [showAddDialog, setShowAddDialog] = useState(false)
+ const { selectedProject } = useProject()
+ const autumn = useCustomer()
+
+ const handleUpgrade = async () => {
+ try {
+ await autumn.attach({
+ productId: "consumer_pro",
+ successUrl: "https://app.supermemory.ai/",
+ })
+ window.location.reload()
+ } catch (error) {
+ console.error(error)
+ }
+ }
+
+ const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any)
+ const connectionsUsed = connectionsCheck?.balance ?? 0
+ const connectionsLimit = connectionsCheck?.included_usage ?? 0
+
+ const { data: proCheck } = fetchConsumerProProduct(autumn as any)
+ const isProUser = proCheck?.allowed ?? false
+
+ const canAddConnection = connectionsUsed < connectionsLimit
+
+ // Fetch connections
+ const {
+ data: connections = [],
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["connections"],
+ queryFn: async () => {
+ const response = await $fetch("@post/connections/list", {
+ body: {
+ containerTags: [],
+ },
+ })
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load connections")
+ }
+
+ return response.data as Connection[]
+ },
+ staleTime: 30 * 1000,
+ refetchInterval: 60 * 1000,
+ })
+
+ // Show error toast if connections fail to load
+ useEffect(() => {
+ if (error) {
+ toast.error("Failed to load connections", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ }
+ }, [error])
+
+ // Add connection mutation
+ const addConnectionMutation = useMutation({
+ mutationFn: async (provider: ConnectorProvider) => {
+ // Check if user can add connections
+ if (!canAddConnection && !isProUser) {
+ throw new Error(
+ "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
+ )
+ }
+
+ const response = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [selectedProject],
+ },
+ })
+
+ // biome-ignore lint/style/noNonNullAssertion: its fine
+ if ("data" in response && !("error" in response.data!)) {
+ return response.data
+ }
+
+ throw new Error(response.error?.message || "Failed to connect")
+ },
+ onSuccess: (data, provider) => {
+ analytics.connectionAdded(provider)
+ analytics.connectionAuthStarted()
+ if (data?.authLink) {
+ window.location.href = data.authLink
+ }
+ },
+ onError: (error, provider) => {
+ analytics.connectionAuthFailed()
+ toast.error(`Failed to connect ${provider}`, {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ // Delete connection mutation
+ const deleteConnectionMutation = useMutation({
+ mutationFn: async (connectionId: string) => {
+ await $fetch(`@delete/connections/${connectionId}`)
+ },
+ onSuccess: () => {
+ analytics.connectionDeleted()
+ toast.success(
+ "Connection removal has started. supermemory will permanently delete the documents in the next few minutes.",
+ )
+ queryClient.invalidateQueries({ queryKey: ["connections"] })
+ },
+ onError: (error) => {
+ toast.error("Failed to remove connection", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ const getProviderIcon = (provider: string) => {
+ const connector = CONNECTORS[provider as ConnectorProvider]
+ if (connector) {
+ const Icon = connector.icon
+ return <Icon className="h-10 w-10" />
+ }
+ return <span className="text-2xl">📎</span>
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="flex justify-between items-center mb-4">
+ <div>
+ <p className="text-sm text-white/70">
+ Connect your favorite services to import documents
+ </p>
+ {!isProUser && (
+ <p className="text-xs text-white/50 mt-1">
+ Connections require a Pro subscription
+ </p>
+ )}
+ </div>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={!canAddConnection && !isProUser}
+ onClick={() => setShowAddDialog(true)}
+ size="sm"
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ Add
+ </Button>
+ </motion.div>
+ </div>
+
+ {/* Show upgrade prompt for free users */}
+ {!isProUser && (
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
+ initial={{ opacity: 0, y: -10 }}
+ >
+ <p className="text-sm text-yellow-400 mb-2">
+ 🔌 Connections are a Pro feature
+ </p>
+ <p className="text-xs text-white/60 mb-3">
+ Connect Google Drive, Notion, OneDrive and more to automatically
+ sync your documents.
+ </p>
+ <Button
+ asChild
+ className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30"
+ onClick={handleUpgrade}
+ size="sm"
+ variant="secondary"
+ >
+ Upgrade to Pro
+ </Button>
+ </motion.div>
+ )}
+
+ {isLoading ? (
+ <div className="space-y-3">
+ {[...Array(2)].map((_, i) => (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="p-4 bg-white/5 rounded-lg"
+ initial={{ opacity: 0 }}
+ key={`skeleton-${Date.now()}-${i}`}
+ transition={{ delay: i * 0.1 }}
+ >
+ <Skeleton className="h-12 w-full bg-white/10" />
+ </motion.div>
+ ))}
+ </div>
+ ) : connections.length === 0 ? (
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="text-center py-8"
+ initial={{ opacity: 0, scale: 0.9 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <p className="text-white/50 mb-4">No connections yet</p>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => setShowAddDialog(true)}
+ size="sm"
+ variant="secondary"
+ >
+ Add Your First Connection
+ </Button>
+ </motion.div>
+ </motion.div>
+ ) : (
+ <motion.div className="space-y-2">
+ <AnimatePresence>
+ {connections.map((connection, index) => (
+ <motion.div
+ animate={{ opacity: 1, x: 0 }}
+ className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
+ exit={{ opacity: 0, x: 20 }}
+ initial={{ opacity: 0, x: -20 }}
+ key={connection.id}
+ layout
+ transition={{ delay: index * 0.05 }}
+ >
+ <div className="flex items-center gap-3">
+ <motion.div
+ animate={{ rotate: 0, opacity: 1 }}
+ initial={{ rotate: -180, opacity: 0 }}
+ transition={{ delay: index * 0.05 + 0.2 }}
+ >
+ {getProviderIcon(connection.provider)}
+ </motion.div>
+ <div>
+ <p className="font-medium text-white capitalize">
+ {connection.provider.replace("-", " ")}
+ </p>
+ {connection.email && (
+ <p className="text-sm text-white/60">
+ {connection.email}
+ </p>
+ )}
+ </div>
+ </div>
+ <motion.div
+ whileHover={{ scale: 1.1 }}
+ whileTap={{ scale: 0.9 }}
+ >
+ <Button
+ className="text-white/50 hover:text-red-400"
+ disabled={deleteConnectionMutation.isPending}
+ onClick={() =>
+ deleteConnectionMutation.mutate(connection.id)
+ }
+ size="icon"
+ variant="ghost"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </motion.div>
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </motion.div>
+ )}
+
+ {/* Add Connection Dialog */}
+ <AnimatePresence>
+ {showAddDialog && (
+ <Dialog onOpenChange={setShowAddDialog} open={showAddDialog}>
+ <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Add a Connection</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Choose a service to connect and import your documents
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-3 py-4">
+ {Object.entries(CONNECTORS).map(
+ ([provider, config], index) => {
+ const Icon = config.icon
+ return (
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ initial={{ opacity: 0, y: 20 }}
+ key={provider}
+ transition={{ delay: index * 0.05 }}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ <Button
+ className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full"
+ disabled={addConnectionMutation.isPending}
+ onClick={() => {
+ addConnectionMutation.mutate(
+ provider as ConnectorProvider,
+ )
+ setShowAddDialog(false)
+ // onClose?.()
+ }}
+ variant="outline"
+ >
+ <Icon className="h-8 w-8 mr-3" />
+ <div className="text-left">
+ <div className="font-medium">{config.title}</div>
+ <div className="text-sm text-white/60 mt-0.5">
+ {config.description}
+ </div>
+ </div>
+ </Button>
+ </motion.div>
+ )
+ },
+ )}
+ </div>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
diff --git a/apps/web/components/views/mcp/index.tsx b/apps/web/components/views/mcp/index.tsx
new file mode 100644
index 00000000..41ef97b8
--- /dev/null
+++ b/apps/web/components/views/mcp/index.tsx
@@ -0,0 +1,311 @@
+import { $fetch } from "@lib/api"
+import { authClient } from "@lib/auth"
+import { useAuth } from "@lib/auth-context"
+import { useForm } from "@tanstack/react-form"
+import { useMutation } from "@tanstack/react-query"
+import { Button } from "@ui/components/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@ui/components/dialog"
+import { Input } from "@ui/components/input"
+import { CopyableCell } from "@ui/copyable-cell"
+import { Loader2 } from "lucide-react"
+import { AnimatePresence, motion } from "motion/react"
+import Image from "next/image"
+import { generateSlug } from "random-word-slugs"
+import { useState, useEffect } from "react"
+import { toast } from "sonner"
+import { z } from "zod/v4"
+import { InstallationDialogContent } from "./installation-dialog-content"
+import { analytics } from "@/lib/analytics"
+
+// Validation schemas
+const mcpMigrationSchema = z.object({
+ url: z
+ .string()
+ .min(1, "MCP Link is required")
+ .regex(
+ /^https:\/\/mcp\.supermemory\.ai\/[^/]+\/sse$/,
+ "Link must be in format: https://mcp.supermemory.ai/userId/sse",
+ ),
+})
+
+export function MCPView() {
+ const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false)
+ const projectId = localStorage.getItem("selectedProject") ?? "default"
+ const { org } = useAuth()
+ const [apiKey, setApiKey] = useState<string>()
+ const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false)
+
+ useEffect(() => {
+ analytics.mcpViewOpened()
+ }, [])
+ const apiKeyMutation = useMutation({
+ mutationFn: async () => {
+ if (apiKey) return apiKey
+ const res = await authClient.apiKey.create({
+ metadata: {
+ organizationId: org?.id,
+ },
+ name: generateSlug(),
+ prefix: `sm_${org?.id}_`,
+ })
+ return res.key
+ },
+ onSuccess: (data) => {
+ setApiKey(data)
+ setIsInstallDialogOpen(true)
+ },
+ })
+
+ // Form for MCP migration
+ const mcpMigrationForm = useForm({
+ defaultValues: { url: "" },
+ onSubmit: async ({ value, formApi }) => {
+ const userId = extractUserIdFromMCPUrl(value.url)
+ if (userId) {
+ migrateMCPMutation.mutate({ userId, projectId })
+ formApi.reset()
+ }
+ },
+ validators: {
+ onChange: mcpMigrationSchema,
+ },
+ })
+
+ const extractUserIdFromMCPUrl = (url: string): string | null => {
+ const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/
+ const match = url.trim().match(regex)
+ return match?.[1] || null
+ }
+
+ // Migrate MCP mutation
+ const migrateMCPMutation = useMutation({
+ mutationFn: async ({
+ userId,
+ projectId,
+ }: {
+ userId: string
+ projectId: string
+ }) => {
+ const response = await $fetch("@post/memories/migrate-mcp", {
+ body: { userId, projectId },
+ })
+
+ if (response.error) {
+ throw new Error(
+ response.error?.message || "Failed to migrate documents",
+ )
+ }
+
+ return response.data
+ },
+ onSuccess: (data) => {
+ toast.success("Migration completed!", {
+ description: `Successfully migrated ${data?.migratedCount} documents`,
+ })
+ setIsMigrateDialogOpen(false)
+ },
+ onError: (error) => {
+ toast.error("Migration failed", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <p className="text-sm text-white/70">
+ Use MCP to create and access memories directly from your AI assistant.
+ Integrate supermemory with Claude Desktop, Cursor, and other AI tools.
+ </p>
+ </div>
+
+ <div className="space-y-4">
+ <div>
+ <label
+ className="text-sm font-medium text-white/80 block mb-2"
+ htmlFor="mcp-server-url"
+ >
+ MCP Server URL
+ </label>
+ <div className="p-3 bg-white/5 rounded border border-white/10">
+ <CopyableCell
+ className="font-mono text-sm text-blue-400"
+ value="https://api.supermemory.ai/mcp"
+ />
+ </div>
+ <p className="text-xs text-white/50 mt-2">
+ Use this URL to configure supermemory in your AI assistant
+ </p>
+ </div>
+
+ <div className="flex items-center gap-4">
+ <Dialog
+ onOpenChange={setIsInstallDialogOpen}
+ open={isInstallDialogOpen}
+ >
+ <DialogTrigger asChild>
+ <Button
+ disabled={apiKeyMutation.isPending}
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ apiKeyMutation.mutate()
+ }}
+ >
+ Install Now
+ </Button>
+ </DialogTrigger>
+ {apiKey && <InstallationDialogContent />}
+ </Dialog>
+ <motion.a
+ className="inline-block"
+ href="https://cursor.com/install-mcp?name=supermemory&config=JTdCJTIydXJsJTIyJTNBJTIyaHR0cHMlM0ElMkYlMkZhcGkuc3VwZXJtZW1vcnkuYWklMkZtY3AlMjIlN0Q%3D"
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Image
+ alt="Add supermemory MCP server to Cursor"
+ height="32"
+ src="https://cursor.com/deeplink/mcp-install-dark.svg"
+ width="128"
+ />
+ </motion.a>
+
+ <div className="h-8 w-px bg-white/10" />
+
+ <motion.div whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white h-8"
+ onClick={() => setIsMigrateDialogOpen(true)}
+ size="sm"
+ variant="outline"
+ >
+ Migrate from v1
+ </Button>
+ </motion.div>
+ </div>
+ </div>
+ <AnimatePresence>
+ {isMigrateDialogOpen && (
+ <Dialog
+ onOpenChange={setIsMigrateDialogOpen}
+ open={isMigrateDialogOpen}
+ >
+ <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Migrate from MCP v1</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Migrate your MCP documents from the legacy system.
+ </DialogDescription>
+ </DialogHeader>
+ <form
+ onSubmit={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ mcpMigrationForm.handleSubmit()
+ }}
+ >
+ <div className="grid gap-4">
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <label className="text-sm font-medium" htmlFor="mcpUrl">
+ MCP Link
+ </label>
+ <mcpMigrationForm.Field name="url">
+ {({ state, handleChange, handleBlur }) => (
+ <>
+ <Input
+ className="bg-white/5 border-white/10 text-white"
+ id="mcpUrl"
+ onBlur={handleBlur}
+ onChange={(e) => handleChange(e.target.value)}
+ placeholder="https://mcp.supermemory.ai/your-user-id/sse"
+ value={state.value}
+ />
+ {state.meta.errors.length > 0 && (
+ <motion.p
+ animate={{ opacity: 1, height: "auto" }}
+ className="text-sm text-red-400 mt-1"
+ exit={{ opacity: 0, height: 0 }}
+ initial={{ opacity: 0, height: 0 }}
+ >
+ {state.meta.errors.join(", ")}
+ </motion.p>
+ )}
+ </>
+ )}
+ </mcpMigrationForm.Field>
+ <p className="text-xs text-white/50">
+ Enter your old MCP Link in the format: <br />
+ <span className="font-mono">
+ https://mcp.supermemory.ai/userId/sse
+ </span>
+ </p>
+ </motion.div>
+ </div>
+ <div className="flex justify-end gap-3 mt-4">
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setIsMigrateDialogOpen(false)
+ mcpMigrationForm.reset()
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ migrateMCPMutation.isPending ||
+ !mcpMigrationForm.state.canSubmit
+ }
+ type="submit"
+ >
+ {migrateMCPMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Migrating...
+ </>
+ ) : (
+ "Migrate"
+ )}
+ </Button>
+ </motion.div>
+ </div>
+ </form>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
diff --git a/apps/web/components/views/mcp/installation-dialog-content.tsx b/apps/web/components/views/mcp/installation-dialog-content.tsx
new file mode 100644
index 00000000..3a6b2f3e
--- /dev/null
+++ b/apps/web/components/views/mcp/installation-dialog-content.tsx
@@ -0,0 +1,79 @@
+import { Button } from "@ui/components/button"
+import {
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@ui/components/dialog"
+import { Input } from "@ui/components/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import { CopyIcon } from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import { analytics } from "@/lib/analytics"
+
+const clients = {
+ cursor: "Cursor",
+ claude: "Claude Desktop",
+ vscode: "VSCode",
+ cline: "Cline",
+ "roo-cline": "Roo Cline",
+ witsy: "Witsy",
+ enconvo: "Enconvo",
+ "gemini-cli": "Gemini CLI",
+ "claude-code": "Claude Code",
+} as const
+
+export function InstallationDialogContent() {
+ const [client, setClient] = useState<keyof typeof clients>("cursor")
+ return (
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Install the supermemory MCP Server</DialogTitle>
+ <DialogDescription>
+ Select the app you want to install supermemory MCP to, then run the
+ following command:
+ </DialogDescription>
+ </DialogHeader>
+ <div className="flex gap-2 items-center">
+ <Input
+ className="font-mono text-xs!"
+ readOnly
+ value={`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`}
+ />
+ <Select
+ onValueChange={(value) => setClient(value as keyof typeof clients)}
+ value={client}
+ >
+ <SelectTrigger className="w-48">
+ <SelectValue placeholder="Theme" />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(clients).map(([key, value]) => (
+ <SelectItem key={key} value={key}>
+ {value}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <Button
+ onClick={() => {
+ navigator.clipboard.writeText(
+ `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`,
+ )
+ analytics.mcpInstallCmdCopied()
+ toast.success("Copied to clipboard!")
+ }}
+ >
+ <CopyIcon className="size-4" /> Copy Installation Command
+ </Button>
+ </DialogContent>
+ )
+}
diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx
new file mode 100644
index 00000000..fd7334fe
--- /dev/null
+++ b/apps/web/components/views/profile.tsx
@@ -0,0 +1,266 @@
+"use client"
+
+import { authClient } from "@lib/auth"
+import { useAuth } from "@lib/auth-context"
+import {
+ fetchConnectionsFeature,
+ fetchMemoriesFeature,
+ fetchSubscriptionStatus,
+} from "@lib/queries"
+import { Button } from "@repo/ui/components/button"
+import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
+import { useCustomer } from "autumn-js/react"
+import { CreditCard, LoaderIcon, LogOut, User, CheckCircle, X } from "lucide-react"
+import { motion } from "motion/react"
+import Link from "next/link"
+import { usePathname, useRouter } from "next/navigation"
+import { useState } from "react"
+import { analytics } from "@/lib/analytics"
+
+export function ProfileView() {
+ const router = useRouter()
+ const pathname = usePathname()
+ const { user: session, org } = useAuth()
+ const organizations = org
+ const autumn = useCustomer()
+ const [isLoading, setIsLoading] = useState(false)
+
+ const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
+ const memoriesUsed = memoriesCheck?.usage ?? 0
+ const memoriesLimit = memoriesCheck?.included_usage ?? 0
+
+ const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any)
+ const connectionsUsed = connectionsCheck?.usage ?? 0
+
+ // Fetch subscription status with React Query
+ const {
+ data: status = {
+ consumer_pro: null,
+ },
+ isLoading: isCheckingStatus,
+ } = fetchSubscriptionStatus(autumn as any)
+
+ const isPro = status.consumer_pro
+
+ const handleLogout = () => {
+ analytics.userSignedOut()
+ authClient.signOut()
+ router.push("/login")
+ }
+
+ const handleUpgrade = async () => {
+ setIsLoading(true)
+ try {
+ await autumn.attach({
+ productId: "consumer_pro",
+ successUrl: "https://app.supermemory.ai/",
+ })
+ window.location.reload()
+ } catch (error) {
+ console.error(error)
+ setIsLoading(false)
+ }
+ }
+
+ // Handle manage billing
+ const handleManageBilling = async () => {
+ await autumn.openBillingPortal({
+ returnUrl: "https://app.supermemory.ai",
+ })
+ }
+
+ if (session?.isAnonymous) {
+ return (
+ <div className="space-y-4">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="text-center py-8"
+ initial={{ opacity: 0, scale: 0.9 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <p className="text-white/70 mb-4">Sign in to access your profile and billing</p>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ asChild
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ size="sm"
+ >
+ <Link href="/login">Sign in</Link>
+ </Button>
+ </motion.div>
+ </motion.div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* Profile Section */}
+ <div className="bg-white/5 rounded-lg p-4 space-y-3">
+ <div className="flex items-center gap-3">
+ <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
+ <User className="w-5 h-5 text-white/80" />
+ </div>
+ <div className="flex-1">
+ <p className="text-white font-medium text-sm">{session?.email}</p>
+ <p className="text-white/60 text-xs">Logged in</p>
+ </div>
+ </div>
+ </div>
+
+ {/* Billing Section */}
+ <div className="bg-white/5 rounded-lg p-4 space-y-3">
+ <div className="flex items-center gap-3 mb-3">
+ <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
+ <CreditCard className="w-5 h-5 text-white/80" />
+ </div>
+ <div className="flex-1">
+ <HeadingH3Bold className="text-white text-sm">
+ {isPro ? "Pro Plan" : "Free Plan"}
+ {isPro && (
+ <span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
+ Active
+ </span>
+ )}
+ </HeadingH3Bold>
+ <p className="text-white/60 text-xs">
+ {isPro ? "Expanded memory capacity" : "Basic plan"}
+ </p>
+ </div>
+ </div>
+
+ {/* Usage Stats */}
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <span className="text-sm text-white/70">Memories</span>
+ <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}>
+ {memoriesUsed} / {memoriesLimit}
+ </span>
+ </div>
+ <div className="w-full bg-white/10 rounded-full h-2">
+ <div
+ className={`h-2 rounded-full transition-all ${
+ memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500"
+ }`}
+ style={{
+ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
+ }}
+ />
+ </div>
+ </div>
+
+ {isPro && (
+ <div className="flex justify-between items-center">
+ <span className="text-sm text-white/70">Connections</span>
+ <span className="text-sm text-white/90">
+ {connectionsUsed} / 10
+ </span>
+ </div>
+ )}
+
+ {/* Billing Actions */}
+ <div className="pt-2">
+ {isPro ? (
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
+ <Button
+ className="w-full bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={handleManageBilling}
+ size="sm"
+ variant="outline"
+ >
+ Manage Billing
+ </Button>
+ </motion.div>
+ ) : (
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
+ <Button
+ className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0"
+ disabled={isLoading || isCheckingStatus}
+ onClick={handleUpgrade}
+ size="sm"
+ >
+ {isLoading || isCheckingStatus ? (
+ <>
+ <LoaderIcon className="h-4 w-4 animate-spin mr-2" />
+ Upgrading...
+ </>
+ ) : (
+ "Upgrade to Pro - $15/month"
+ )}
+ </Button>
+ </motion.div>
+ )}
+ </div>
+ </div>
+
+ {/* Plan Comparison - Only show for free users */}
+ {!isPro && (
+ <div className="bg-white/5 rounded-lg p-4 space-y-4">
+ <HeadingH3Bold className="text-white text-sm">Upgrade to Pro</HeadingH3Bold>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* Free Plan */}
+ <div className="p-3 bg-white/5 rounded-lg border border-white/10">
+ <h4 className="font-medium text-white/90 mb-3 text-sm">Free Plan</h4>
+ <ul className="space-y-2">
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 200 memories
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <X className="h-4 w-4 text-red-400" />
+ No connections
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/70">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Basic search
+ </li>
+ </ul>
+ </div>
+
+ {/* Pro Plan */}
+ <div className="p-3 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
+ <h4 className="font-medium text-white mb-3 flex items-center gap-2 text-sm">
+ Pro Plan
+ <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full">
+ Recommended
+ </span>
+ </h4>
+ <ul className="space-y-2">
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 5000 memories
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ 10 connections
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Advanced search
+ </li>
+ <li className="flex items-center gap-2 text-sm text-white/90">
+ <CheckCircle className="h-4 w-4 text-green-400" />
+ Priority support
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <p className="text-xs text-white/50 text-center">
+ $15/month (only for first 100 users) • Cancel anytime. No questions asked.
+ </p>
+ </div>
+ )}
+
+ <Button
+ className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30"
+ onClick={handleLogout}
+ variant="destructive"
+ >
+ <LogOut className="w-4 h-4 mr-2" />
+ Sign Out
+ </Button>
+ </div>
+ )
+}
diff --git a/apps/web/components/views/projects.tsx b/apps/web/components/views/projects.tsx
new file mode 100644
index 00000000..faa9a317
--- /dev/null
+++ b/apps/web/components/views/projects.tsx
@@ -0,0 +1,742 @@
+"use client"
+
+import { $fetch } from "@lib/api"
+import { Button } from "@repo/ui/components/button"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@repo/ui/components/dialog"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@repo/ui/components/dropdown-menu"
+import { Input } from "@repo/ui/components/input"
+import { Label } from "@repo/ui/components/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@repo/ui/components/select"
+import { Skeleton } from "@repo/ui/components/skeleton"
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+
+import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react"
+import { AnimatePresence, motion } from "motion/react"
+
+import { useState } from "react"
+import { toast } from "sonner"
+import { useProject } from "@/stores"
+
+// Projects View Component
+export function ProjectsView() {
+ const queryClient = useQueryClient()
+ const { selectedProject, setSelectedProject } = useProject()
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
+ const [projectName, setProjectName] = useState("")
+ const [deleteDialog, setDeleteDialog] = useState<{
+ open: boolean
+ project: null | { id: string; name: string; containerTag: string }
+ action: "move" | "delete"
+ targetProjectId: string
+ }>({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: "",
+ })
+ const [expDialog, setExpDialog] = useState<{
+ open: boolean
+ projectId: string
+ }>({
+ open: false,
+ projectId: "",
+ })
+
+ // Fetch projects
+ const {
+ data: projects = [],
+ isLoading,
+ // error,
+ } = useQuery({
+ queryKey: ["projects"],
+ queryFn: async () => {
+ const response = await $fetch("@get/projects")
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load projects")
+ }
+
+ return response.data?.projects || []
+ },
+ staleTime: 30 * 1000,
+ })
+
+ // Create project mutation
+ const createProjectMutation = useMutation({
+ mutationFn: async (name: string) => {
+ const response = await $fetch("@post/projects", {
+ body: { name },
+ })
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to create project")
+ }
+
+ return response.data
+ },
+ onSuccess: () => {
+ toast.success("Project created successfully!")
+ setShowCreateDialog(false)
+ setProjectName("")
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ },
+ onError: (error) => {
+ toast.error("Failed to create project", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ // Delete project mutation
+ const deleteProjectMutation = useMutation({
+ mutationFn: async ({
+ projectId,
+ action,
+ targetProjectId,
+ }: {
+ projectId: string
+ action: "move" | "delete"
+ targetProjectId?: string
+ }) => {
+ const response = await $fetch(`@delete/projects/${projectId}`, {
+ body: { action, targetProjectId },
+ })
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to delete project")
+ }
+
+ return response.data
+ },
+ onSuccess: () => {
+ toast.success("Project deleted successfully")
+ setDeleteDialog({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: "",
+ })
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+
+ // If we deleted the selected project, switch to default
+ if (deleteDialog.project?.containerTag === selectedProject) {
+ setSelectedProject("sm_project_default")
+ }
+ },
+ onError: (error) => {
+ toast.error("Failed to delete project", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ // Enable experimental mode mutation
+ const enableExperimentalMutation = useMutation({
+ mutationFn: async (projectId: string) => {
+ const response = await $fetch(`@post/projects/${projectId}/enable-experimental`)
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to enable experimental mode")
+ }
+ return response.data
+ },
+ onSuccess: () => {
+ toast.success("Experimental mode enabled for project")
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ setExpDialog({ open: false, projectId: "" })
+ },
+ onError: (error) => {
+ toast.error("Failed to enable experimental mode", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
+ // Handle project selection
+ const handleProjectSelect = (containerTag: string) => {
+ setSelectedProject(containerTag)
+ toast.success("Project switched successfully")
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="mb-4">
+ <p className="text-sm text-white/70">
+ Organize your memories into separate projects
+ </p>
+ </div>
+
+ <div className="flex justify-between items-center mb-4">
+ <p className="text-sm text-white/50">Current project:</p>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => setShowCreateDialog(true)}
+ size="sm"
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ New Project
+ </Button>
+ </motion.div>
+ </div>
+
+ {isLoading ? (
+ <div className="space-y-3">
+ {[...Array(2)].map((_, i) => (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="p-4 bg-white/5 rounded-lg"
+ initial={{ opacity: 0 }}
+ key={`skeleton-project-${Date.now()}-${i}`}
+ transition={{ delay: i * 0.1 }}
+ >
+ <Skeleton className="h-12 w-full bg-white/10" />
+ </motion.div>
+ ))}
+ </div>
+ ) : projects.length === 0 ? (
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="text-center py-8"
+ initial={{ opacity: 0, scale: 0.9 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <p className="text-white/50 mb-4">No projects yet</p>
+ <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ onClick={() => setShowCreateDialog(true)}
+ size="sm"
+ variant="secondary"
+ >
+ Create Your First Project
+ </Button>
+ </motion.div>
+ </motion.div>
+ ) : (
+ <motion.div className="space-y-2">
+ <AnimatePresence>
+ {/* Default project */}
+ <motion.div
+ animate={{ opacity: 1, x: 0 }}
+ className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === "sm_project_default"
+ ? "bg-white/20 border border-white/30"
+ : "bg-white/5 hover:bg-white/10"
+ }`}
+ exit={{ opacity: 0, x: 20 }}
+ initial={{ opacity: 0, x: -20 }}
+ key="default-project"
+ layout
+ onClick={() => handleProjectSelect("sm_project_default")}
+ >
+ <div className="flex items-center gap-3">
+ <motion.div
+ animate={{ rotate: 0, opacity: 1 }}
+ initial={{ rotate: -180, opacity: 0 }}
+ transition={{ delay: 0.1 }}
+ >
+ <FolderIcon className="h-5 w-5 text-white/80" />
+ </motion.div>
+ <div>
+ <p className="font-medium text-white">Default Project</p>
+ <p className="text-sm text-white/60">
+ Your default memory storage
+ </p>
+ </div>
+ </div>
+ {selectedProject === "sm_project_default" && (
+ <motion.div
+ animate={{ scale: 1 }}
+ initial={{ scale: 0 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <div className="w-2 h-2 bg-green-400 rounded-full" />
+ </motion.div>
+ )}
+ </motion.div>
+
+ {/* User projects */}
+ {projects
+ .filter((p) => p.containerTag !== "sm_project_default")
+ .map((project, index) => (
+ <motion.div
+ animate={{ opacity: 1, x: 0 }}
+ className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === project.containerTag
+ ? "bg-white/20 border border-white/30"
+ : "bg-white/5 hover:bg-white/10"
+ }`}
+ exit={{ opacity: 0, x: 20 }}
+ initial={{ opacity: 0, x: -20 }}
+ key={project.id}
+ layout
+ onClick={() => handleProjectSelect(project.containerTag)}
+ transition={{ delay: (index + 1) * 0.05 }}
+ >
+ <div className="flex items-center gap-3">
+ <motion.div
+ animate={{ rotate: 0, opacity: 1 }}
+ initial={{ rotate: -180, opacity: 0 }}
+ transition={{ delay: (index + 1) * 0.05 + 0.2 }}
+ >
+ <FolderIcon className="h-5 w-5 text-white/80" />
+ </motion.div>
+ <div>
+ <p className="font-medium text-white">{project.name}</p>
+ <p className="text-sm text-white/60">
+ Created{" "}
+ {new Date(project.createdAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {selectedProject === project.containerTag && (
+ <motion.div
+ animate={{ scale: 1 }}
+ initial={{ scale: 0 }}
+ transition={{ type: "spring", damping: 20 }}
+ >
+ <div className="w-2 h-2 bg-green-400 rounded-full" />
+ </motion.div>
+ )}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ className="text-white/50 hover:text-white"
+ onClick={(e) => e.stopPropagation()}
+ size="icon"
+ variant="ghost"
+ >
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ className="bg-black/90 border-white/10"
+ >
+ {/* Show experimental toggle only if NOT experimental and NOT default project */}
+ {!project.isExperimental &&
+ project.containerTag !== "sm_project_default" && (
+ <DropdownMenuItem
+ className="text-blue-400 hover:text-blue-300 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation()
+ setExpDialog({
+ open: true,
+ projectId: project.id,
+ })
+ }}
+ >
+ <div className="h-4 w-4 mr-2 rounded border border-blue-400" />
+ Enable Experimental Mode
+ </DropdownMenuItem>
+ )}
+ {project.isExperimental && (
+ <DropdownMenuItem
+ className="text-blue-300/50"
+ disabled
+ >
+ <div className="h-4 w-4 mr-2 rounded bg-blue-400" />
+ Experimental Mode Active
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem
+ className="text-red-400 hover:text-red-300 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation()
+ setDeleteDialog({
+ open: true,
+ project: {
+ id: project.id,
+ name: project.name,
+ containerTag: project.containerTag,
+ },
+ action: "move",
+ targetProjectId: "",
+ })
+ }}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete Project
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </motion.div>
+ )}
+
+ {/* Create Project Dialog */}
+ <AnimatePresence>
+ {showCreateDialog && (
+ <Dialog onOpenChange={setShowCreateDialog} open={showCreateDialog}>
+ <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Create New Project</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Give your project a unique name
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <motion.div
+ animate={{ opacity: 1, y: 0 }}
+ className="flex flex-col gap-2"
+ initial={{ opacity: 0, y: 10 }}
+ transition={{ delay: 0.1 }}
+ >
+ <Label htmlFor="projectName">Project Name</Label>
+ <Input
+ className="bg-white/5 border-white/10 text-white"
+ id="projectName"
+ onChange={(e) => setProjectName(e.target.value)}
+ placeholder="My Awesome Project"
+ value={projectName}
+ />
+ <p className="text-xs text-white/50">
+ This will help you organize your memories
+ </p>
+ </motion.div>
+ </div>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() => {
+ setShowCreateDialog(false)
+ setProjectName("")
+ }}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/10 hover:bg-white/20 text-white border-white/20"
+ disabled={
+ createProjectMutation.isPending || !projectName.trim()
+ }
+ onClick={() => createProjectMutation.mutate(projectName)}
+ type="button"
+ >
+ {createProjectMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Creating...
+ </>
+ ) : (
+ "Create Project"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+
+ {/* Delete Project Dialog */}
+ <AnimatePresence>
+ {deleteDialog.open && deleteDialog.project && (
+ <Dialog
+ onOpenChange={(open) =>
+ setDeleteDialog((prev) => ({ ...prev, open }))
+ }
+ open={deleteDialog.open}
+ >
+ <DialogContent className="sm:max-w-3xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle>Delete Project</DialogTitle>
+ <DialogDescription className="text-white/60">
+ Are you sure you want to delete "{deleteDialog.project.name}
+ "? Choose what to do with the documents in this project.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <input
+ checked={deleteDialog.action === "move"}
+ className="w-4 h-4"
+ id="move"
+ name="action"
+ onChange={() =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ action: "move",
+ }))
+ }
+ type="radio"
+ />
+ <Label
+ className="text-white cursor-pointer"
+ htmlFor="move"
+ >
+ Move documents to another project
+ </Label>
+ </div>
+ {deleteDialog.action === "move" && (
+ <motion.div
+ animate={{ opacity: 1, height: "auto" }}
+ className="ml-6"
+ exit={{ opacity: 0, height: 0 }}
+ initial={{ opacity: 0, height: 0 }}
+ >
+ <Select
+ onValueChange={(value) =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ targetProjectId: value,
+ }))
+ }
+ value={deleteDialog.targetProjectId}
+ >
+ <SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
+ <SelectValue placeholder="Select target project..." />
+ </SelectTrigger>
+ <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ value="sm_project_default"
+ >
+ Default Project
+ </SelectItem>
+ {projects
+ .filter(
+ (p) =>
+ p.id !== deleteDialog.project?.id &&
+ p.containerTag !== "sm_project_default",
+ )
+ .map((project) => (
+ <SelectItem
+ className="text-white hover:bg-white/10"
+ key={project.id}
+ value={project.id}
+ >
+ {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </motion.div>
+ )}
+ <div className="flex items-center space-x-2">
+ <input
+ checked={deleteDialog.action === "delete"}
+ className="w-4 h-4"
+ id="delete"
+ name="action"
+ onChange={() =>
+ setDeleteDialog((prev) => ({
+ ...prev,
+ action: "delete",
+ }))
+ }
+ type="radio"
+ />
+ <Label
+ className="text-white cursor-pointer"
+ htmlFor="delete"
+ >
+ Delete all documents in this project
+ </Label>
+ </div>
+ {deleteDialog.action === "delete" && (
+ <motion.p
+ animate={{ opacity: 1 }}
+ className="text-sm text-red-400 ml-6"
+ initial={{ opacity: 0 }}
+ >
+ ⚠️ This action cannot be undone. All documents will be
+ permanently deleted.
+ </motion.p>
+ )}
+ </div>
+ </div>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() =>
+ setDeleteDialog({
+ open: false,
+ project: null,
+ action: "move",
+ targetProjectId: "",
+ })
+ }
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className={`${deleteDialog.action === "delete"
+ ? "bg-red-600 hover:bg-red-700"
+ : "bg-white/10 hover:bg-white/20"
+ } text-white border-white/20`}
+ disabled={
+ deleteProjectMutation.isPending ||
+ (deleteDialog.action === "move" &&
+ !deleteDialog.targetProjectId)
+ }
+ onClick={() => {
+ if (deleteDialog.project) {
+ deleteProjectMutation.mutate({
+ projectId: deleteDialog.project.id,
+ action: deleteDialog.action,
+ targetProjectId:
+ deleteDialog.action === "move"
+ ? deleteDialog.targetProjectId
+ : undefined,
+ })
+ }
+ }}
+ type="button"
+ >
+ {deleteProjectMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ {deleteDialog.action === "move"
+ ? "Moving..."
+ : "Deleting..."}
+ </>
+ ) : deleteDialog.action === "move" ? (
+ "Move & Delete Project"
+ ) : (
+ "Delete Everything"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+
+ {/* Experimental Mode Confirmation Dialog */}
+ <AnimatePresence>
+ {expDialog.open && (
+ <Dialog
+ onOpenChange={(open) => setExpDialog({ ...expDialog, open })}
+ open={expDialog.open}
+ >
+ <DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white">
+ <motion.div
+ animate={{ opacity: 1, scale: 1 }}
+ className="flex flex-col gap-4"
+ exit={{ opacity: 0, scale: 0.95 }}
+ initial={{ opacity: 0, scale: 0.95 }}
+ >
+ <DialogHeader>
+ <DialogTitle className="text-white">
+ Enable Experimental Mode?
+ </DialogTitle>
+ <DialogDescription className="text-white/60">
+ Experimental mode enables beta features and advanced memory
+ relationships for this project.
+ <br />
+ <br />
+ <span className="text-yellow-400 font-medium">
+ Warning:
+ </span>{" "}
+ This action is{" "}
+ <span className="text-red-400 font-bold">irreversible</span>
+ . Once enabled, you cannot return to regular mode for this
+ project.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
+ onClick={() =>
+ setExpDialog({ open: false, projectId: "" })
+ }
+ type="button"
+ variant="outline"
+ >
+ Cancel
+ </Button>
+ </motion.div>
+ <motion.div
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ >
+ <Button
+ className="bg-blue-600 hover:bg-blue-700 text-white"
+ disabled={enableExperimentalMutation.isPending}
+ onClick={() =>
+ enableExperimentalMutation.mutate(expDialog.projectId)
+ }
+ type="button"
+ >
+ {enableExperimentalMutation.isPending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ Enabling...
+ </>
+ ) : (
+ "Enable Experimental Mode"
+ )}
+ </Button>
+ </motion.div>
+ </DialogFooter>
+ </motion.div>
+ </DialogContent>
+ </Dialog>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}