minor fixes

This commit is contained in:
Dhravya Shah 2026-02-10 08:36:48 -08:00
parent e1af1d0b59
commit a843edde5e
100 changed files with 772 additions and 15434 deletions

View file

@ -0,0 +1,12 @@
"use client"
import { MobileBanner } from "@/components/new/mobile-banner"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<MobileBanner />
{children}
</>
)
}

View file

@ -7,7 +7,7 @@ export default function OnboardingPage() {
const router = useRouter()
useEffect(() => {
router.replace("/new/onboarding/welcome?step=input")
router.replace("/onboarding/welcome?step=input")
}, [router])
return (

View file

@ -47,21 +47,21 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
const goToStep = useCallback(
(step: SetupStep) => {
analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/new/onboarding/setup?step=${step}`)
router.push(`/onboarding/setup?step=${step}`)
},
[router],
)
const goToWelcome = useCallback(
(step = "input") => {
router.push(`/new/onboarding/welcome?step=${step}`)
router.push(`/onboarding/welcome?step=${step}`)
},
[router],
)
const finishOnboarding = useCallback(() => {
resetOnboarding()
router.push("/new")
router.push("/")
}, [router, resetOnboarding])
useEffect(() => {

View file

@ -92,7 +92,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
setTimeout(() => {
if (isMountedRef.current) {
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
router.replace("/new/onboarding/welcome?step=welcome")
router.replace("/onboarding/welcome?step=welcome")
}
}, 2000),
)
@ -104,7 +104,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
step: "username",
trigger: "auto",
})
router.replace("/new/onboarding/welcome?step=username")
router.replace("/onboarding/welcome?step=username")
}
}, 2000),
)
@ -128,14 +128,14 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
const goToStep = useCallback(
(step: WelcomeStep) => {
analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/new/onboarding/welcome?step=${step}`)
router.push(`/onboarding/welcome?step=${step}`)
},
[router],
)
const goToSetup = useCallback(
(step = "relatable") => {
router.push(`/new/onboarding/setup?step=${step}`)
router.push(`/onboarding/setup?step=${step}`)
},
[router],
)

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import { useQueryState } from "nuqs"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
@ -23,11 +24,20 @@ import {
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery } from "@tanstack/react-query"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useViewMode } from "@/lib/view-mode-context"
import { cn } from "@lib/utils"
import {
addDocumentParam,
mcpParam,
searchParam,
qParam,
docParam,
fullscreenParam,
chatParam,
} from "@/lib/search-params"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -36,17 +46,56 @@ export default function NewPage() {
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const { viewMode } = useViewMode()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
const queryClient = useQueryClient()
const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false)
// URL-driven modal states
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const [isMCPOpen, setIsMCPOpen] = useQueryState("mcp", mcpParam)
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
const [docId, setDocId] = useQueryState("doc", docParam)
const [isFullscreen, setIsFullscreen] = useQueryState("fullscreen", fullscreenParam)
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
// Ephemeral local state (not worth URL-encoding)
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [searchPrefill, setSearchPrefill] = useState("")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
// Clear document when docId is removed (e.g. back button)
useEffect(() => {
if (!docId) setSelectedDocument(null)
}, [docId])
// Resolve document from cache when loading with ?doc=<id> (deep link / refresh)
useEffect(() => {
if (!docId || selectedDocument) return
const tryResolve = () => {
const queries = queryClient.getQueriesData<{
pages: DocumentsResponse[]
}>({ queryKey: ["documents-with-memories"] })
for (const [, data] of queries) {
if (!data?.pages) continue
for (const page of data.pages) {
const doc = page.documents?.find((d) => d.id === docId)
if (doc) {
setSelectedDocument(doc)
return true
}
}
}
return false
}
if (tryResolve()) return
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
if (tryResolve()) unsubscribe()
})
return unsubscribe
}, [docId, selectedDocument, queryClient])
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
@ -54,7 +103,7 @@ export default function NewPage() {
const { noteMutation } = useDocumentMutations({
onClose: () => {
resetDraft()
setIsFullScreenNoteOpen(false)
setIsFullscreen(false)
},
})
@ -74,7 +123,6 @@ export default function NewPage() {
const spaceId = selectedProject || "sm_project_default"
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
// Check Cache API for a fresh response
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const cached = await cache.match(cacheKey)
if (cached) {
@ -107,7 +155,6 @@ export default function NewPage() {
const data = await response.json()
// Store in Cache API with timestamp
const cacheResponse = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
@ -124,26 +171,24 @@ export default function NewPage() {
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
setAddDoc("note")
})
useHotkeys("mod+k", (e) => {
e.preventDefault()
analytics.searchOpened({ source: "hotkey" })
setIsSearchOpen(true)
})
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
useEffect(() => {
setIsChatOpen(!isMobile)
}, [isMobile])
const handleOpenDocument = useCallback((document: DocumentWithMemories) => {
if (document.id) {
analytics.documentModalOpened({ document_id: document.id })
}
setSelectedDocument(document)
setIsDocumentModalOpen(true)
}, [])
const handleOpenDocument = useCallback(
(document: DocumentWithMemories) => {
if (document.id) {
analytics.documentModalOpened({ document_id: document.id })
setSelectedDocument(document)
setDocId(document.id)
}
},
[setDocId],
)
const handleQuickNoteSave = useCallback(
(content: string) => {
@ -187,23 +232,33 @@ export default function NewPage() {
[selectedProject, noteMutation, fullscreenInitialContent],
)
const handleMaximize = useCallback((content: string) => {
analytics.fullscreenNoteModalOpened()
setFullscreenInitialContent(content)
setIsFullScreenNoteOpen(true)
}, [])
const handleMaximize = useCallback(
(content: string) => {
analytics.fullscreenNoteModalOpened()
setFullscreenInitialContent(content)
setIsFullscreen(true)
},
[setIsFullscreen],
)
const handleHighlightsChat = useCallback((seed: string) => {
setQueuedChatSeed(seed)
setIsChatOpen(true)
}, [])
const handleHighlightsChat = useCallback(
(seed: string) => {
setQueuedChatSeed(seed)
setIsChatOpen(true)
},
[setIsChatOpen],
)
const handleHighlightsShowRelated = useCallback((query: string) => {
analytics.searchOpened({ source: "highlight_related" })
setSearchPrefill(query)
setIsSearchOpen(true)
}, [])
const handleHighlightsShowRelated = useCallback(
(query: string) => {
analytics.searchOpened({ source: "highlight_related" })
setSearchPrefill(query)
setIsSearchOpen(true)
},
[setSearchPrefill, setIsSearchOpen],
)
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
const isGraphMode = viewMode === "graph" && !isMobile
return (
@ -227,11 +282,11 @@ export default function NewPage() {
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
setAddDoc("note")
}}
onOpenMCP={() => {
analytics.mcpModalOpened()
setIsMCPModalOpen(true)
setIsMCPOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
onOpenSearch={() => {
@ -240,7 +295,7 @@ export default function NewPage() {
}}
/>
<main
key={`main-container-${isChatOpen}-${viewMode}`}
key={`main-container-${chatOpen}-${viewMode}`}
className={cn(
"z-10 relative",
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
@ -249,12 +304,12 @@ export default function NewPage() {
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
{viewMode === "graph" && !isMobile ? (
<div className="flex-1">
<GraphLayoutView isChatOpen={isChatOpen} />
<GraphLayoutView isChatOpen={chatOpen} />
</div>
) : (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={isChatOpen}
isChatOpen={chatOpen}
onOpenDocument={handleOpenDocument}
quickNoteProps={{
onSave: handleQuickNoteSave,
@ -273,8 +328,8 @@ export default function NewPage() {
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
@ -286,8 +341,8 @@ export default function NewPage() {
{isMobile && (
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
@ -295,12 +350,13 @@ export default function NewPage() {
)}
<AddDocumentModal
isOpen={isAddDocumentOpen}
onClose={() => setIsAddDocumentOpen(false)}
isOpen={addDoc !== null}
onClose={() => setAddDoc(null)}
defaultTab={addDoc ?? undefined}
/>
<MCPModal
isOpen={isMCPModalOpen}
onClose={() => setIsMCPModalOpen(false)}
isOpen={isMCPOpen}
onClose={() => setIsMCPOpen(false)}
/>
<DocumentsCommandPalette
open={isSearchOpen}
@ -310,16 +366,24 @@ export default function NewPage() {
}}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenMCP={() => {
analytics.mcpModalOpened()
setIsMCPOpen(true)
}}
initialSearch={searchPrefill}
/>
<DocumentModal
document={selectedDocument}
isOpen={isDocumentModalOpen}
onClose={() => setIsDocumentModalOpen(false)}
isOpen={docId !== null}
onClose={() => setDocId(null)}
/>
<FullscreenNoteModal
isOpen={isFullScreenNoteOpen}
onClose={() => setIsFullScreenNoteOpen(false)}
isOpen={isFullscreen}
onClose={() => setIsFullscreen(false)}
initialContent={fullscreenInitialContent}
onSave={handleFullScreenSave}
isSaving={noteMutation.isPending}

View file

@ -182,7 +182,7 @@ export default function SettingsPage() {
<header className="flex justify-between items-center px-4 md:px-6 py-3 shrink-0">
<button
type="button"
onClick={() => router.push("/new")}
onClick={() => router.push("/")}
className="cursor-pointer"
>
<Logo className="h-7" />

View file

@ -1,5 +1,5 @@
import { LoginPage } from "@repo/ui/pages/login"
import { redirect } from "next/navigation"
export default function Page() {
return <LoginPage />
redirect("/login/new")
}

View file

@ -1,25 +0,0 @@
"use client"
import { useEffect } from "react"
import { useParams } from "next/navigation"
import { usePersistentChat } from "@/stores"
import { ChatMessages } from "@/components/views/chat/chat-messages"
export default function ChatPage() {
const params = useParams()
const { setCurrentChatId } = usePersistentChat()
const chatId = params.id as string
useEffect(() => {
if (chatId) {
setCurrentChatId(chatId)
}
}, [chatId, setCurrentChatId])
return (
<div className="h-full overflow-hidden">
<ChatMessages />
</div>
)
}

View file

@ -1,72 +0,0 @@
"use client"
import { GraphDialog } from "@/components/graph-dialog"
import { Header } from "@/components/header"
import { AddMemoryView } from "@/components/views/add-memory"
import { usePathname, useRouter } from "next/navigation"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useEffect, useState } from "react"
export default function NavigationLayout({
children,
}: {
children: React.ReactNode
}) {
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const pathname = usePathname()
const router = useRouter()
const flagEnabled = true
useEffect(() => {
if (flagEnabled && !pathname.includes("/new")) {
router.replace("/new")
}
}, [flagEnabled, router, pathname])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.closest('[contenteditable="true"]')
if (isInputField) return
// add memory shortcut
if (
event.key === "c" &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
) {
event.preventDefault()
setShowAddMemoryView(true)
}
}
document.addEventListener("keydown", handleKeydown)
return () => {
document.removeEventListener("keydown", handleKeydown)
}
}, [])
return (
<div className="relative h-screen flex flex-col">
<div className="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-white/10">
<Header onAddMemory={() => setShowAddMemoryView(true)} />
</div>
<div className="flex-1">{children}</div>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
<GraphDialog />
</div>
)
}

View file

@ -1,119 +0,0 @@
"use client"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useAuth } from "@lib/auth-context"
import { ChevronsDown, LoaderIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useEffect, useMemo } from "react"
import { InstallPrompt } from "@/components/install-prompt"
import { ChromeExtensionButton } from "@/components/chrome-extension-button"
import { ChatInput } from "@/components/chat-input"
import { BackgroundPlus } from "@ui/components/grid-plus"
import { Memories } from "@/components/memories"
import { useFeatureFlagEnabled } from "posthog-js/react"
export default function Page() {
const { user, session } = useAuth()
const router = useRouter()
const flagEnabled = true
// TODO: remove this flow after the feature flag is removed
// Old app: localStorage-backed onboarding
const {
shouldShowOnboarding: shouldShowOldOnboarding,
isLoading: oldOnboardingLoading,
} = useOnboardingStorage()
// New app: DB-backed onboarding (org.metadata.isOnboarded)
const {
shouldShowOnboarding: shouldShowNewOnboarding,
isLoading: newOnboardingLoading,
} = useOrgOnboarding()
// Select the appropriate onboarding state based on feature flag
const isOnboardingLoading = useMemo(() => {
if (flagEnabled) {
return newOnboardingLoading
}
return oldOnboardingLoading
}, [flagEnabled, newOnboardingLoading, oldOnboardingLoading])
const shouldShowOnboarding = useMemo(() => {
if (flagEnabled) {
return shouldShowNewOnboarding()
}
return shouldShowOldOnboarding()
}, [flagEnabled, shouldShowNewOnboarding, shouldShowOldOnboarding])
useEffect(() => {
const url = new URL(window.location.href)
const authenticateChromeExtension = url.searchParams.get(
"extension-auth-success",
)
if (authenticateChromeExtension) {
const sessionToken = session?.token
const userData = {
email: user?.email,
name: user?.name,
userId: user?.id,
}
if (sessionToken && userData?.email) {
const encodedToken = encodeURIComponent(sessionToken)
window.postMessage(
{ token: encodedToken, userData },
window.location.origin,
)
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}
}, [user, session])
useEffect(() => {
if (user && !isOnboardingLoading && shouldShowOnboarding) {
if (flagEnabled) {
router.push("/new/onboarding?step=input&flow=welcome")
} else {
router.push("/onboarding")
}
}
}, [user, shouldShowOnboarding, isOnboardingLoading, router, flagEnabled])
if (!user || isOnboardingLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1419]">
<div className="flex flex-col items-center gap-4">
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
<p className="text-white/60">Loading...</p>
</div>
</div>
)
}
if (shouldShowOnboarding) {
return null
}
return (
<div>
<div className="flex flex-col h-[80vh] rounded-lg overflow-hidden relative">
<BackgroundPlus />
<div className="p-4 flex-1 flex items-center justify-center">
<ChatInput />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground justify-center py-2 opacity-75">
<ChevronsDown className="size-4" />
<p>Scroll down to see memories</p>
</div>
</div>
<Memories />
<InstallPrompt />
<ChromeExtensionButton />
</div>
)
}

View file

@ -1,12 +0,0 @@
"use client"
import { BillingView } from "@/components/views/billing"
export default function BillingPage() {
return (
<div className="py-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6">
Billing & Subscription
</h1>
<BillingView />
</div>
)
}

View file

@ -1,10 +0,0 @@
"use client"
import { IntegrationsView } from "@/components/views/integrations"
export default function IntegrationsPage() {
return (
<div className="py-6 max-w-4xl">
<h1 className="text-2xl font-bold text-foreground mb-6">Integrations</h1>
<IntegrationsView />
</div>
)
}

View file

@ -1,52 +0,0 @@
"use client"
import { Button } from "@ui/components/button"
import { useRouter, usePathname } from "next/navigation"
import { cn } from "@repo/lib/utils"
export default function SettingsPageLayout({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter()
const pathname = usePathname()
const navItems = [
{ label: "Profile", path: "/settings" },
{ label: "Integrations", path: "/settings/integrations" },
{ label: "Billing", path: "/settings/billing" },
{ label: "Support", path: "/settings/support" },
]
return (
<div className="flex-1 overflow-hidden max-w-screen-lg mx-auto mt-4">
<div className="flex flex-col items-center">
<div className="w-full max-w-2xl">
<nav className="flex gap-[2px] px-1 py-1 text-sm rounded-[8px] bg-muted-foreground/10 text-foreground max-w-fit">
{navItems.map((item) => {
const isActive = pathname === item.path
return (
<Button
key={item.path}
onClick={() => router.push(item.path)}
variant="settingsNav"
size="sm"
className={cn(
"transition-all duration-200",
isActive
? "opacity-100 bg-card"
: "opacity-60 hover:opacity-100 hover:bg-card ",
)}
>
{item.label}
</Button>
)
})}
</nav>
{children}
</div>
</div>
</div>
)
}

View file

@ -1,12 +0,0 @@
"use client"
import { ProfileView } from "@/components/views/profile"
export default function ProfilePage() {
return (
<div className="py-6 max-w-xl">
<h1 className="text-2xl font-bold text-foreground mb-2">
Profile Settings
</h1>
<ProfileView />
</div>
)
}

View file

@ -1,123 +0,0 @@
"use client"
import { Button } from "@repo/ui/components/button"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { ExternalLink, Mail, MessageCircle } from "lucide-react"
export default function SupportPage() {
return (
<div className="py-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6">
Support & Help
</h1>
<div className="space-y-6">
{/* Contact Options */}
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
<HeadingH3Bold className="text-foreground">Get Help</HeadingH3Bold>
<p className="text-muted-foreground text-sm">
Need assistance? We're here to help! Choose the best way to reach
us.
</p>
<div className="space-y-3">
<Button
className="w-full justify-start bg-blue-500/20 hover:bg-blue-500/30 text-blue-600 dark:text-blue-400 border-blue-500/30"
onClick={() => window.open("https://x.com/supermemory", "_blank")}
variant="outline"
>
<MessageCircle className="w-4 h-4 mr-2" />
Message us on X (Twitter)
<ExternalLink className="w-4 h-4 ml-auto" />
</Button>
<Button
className="w-full justify-start bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 border-green-500/30"
onClick={() =>
window.open("mailto:dhravya@supermemory.ai", "_blank")
}
variant="outline"
>
<Mail className="w-4 h-4 mr-2" />
Email us at dhravya@supermemory.ai
<ExternalLink className="w-4 h-4 ml-auto" />
</Button>
</div>
</div>
{/* FAQ Section */}
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
<HeadingH3Bold className="text-foreground">
Frequently Asked Questions
</HeadingH3Bold>
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-foreground font-medium text-sm">
How do I upgrade to Pro?
</h4>
<p className="text-muted-foreground text-sm">
Go to the Billing tab in settings and click "Upgrade to Pro".
You'll be redirected to our secure payment processor.
</p>
</div>
<div className="space-y-2">
<h4 className="text-foreground font-medium text-sm">
What's included in the Pro plan?
</h4>
<p className="text-muted-foreground text-sm">
Pro includes unlimited memories (vs 200 in free), 10 connections
to external services like Google Drive and Notion, advanced
search features, and priority support.
</p>
</div>
<div className="space-y-2">
<h4 className="text-foreground font-medium text-sm">
How do connections work?
</h4>
<p className="text-muted-foreground text-sm">
Connections let you sync documents from Google Drive, Notion,
and OneDrive automatically. supermemory will index and make them
searchable.
</p>
</div>
<div className="space-y-2">
<h4 className="text-foreground font-medium text-sm">
Can I cancel my subscription anytime?
</h4>
<p className="text-muted-foreground text-sm">
Yes! You can cancel anytime from the Billing tab. Your Pro
features will remain active until the end of your billing
period.
</p>
</div>
</div>
</div>
{/* Feedback Section */}
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
<HeadingH3Bold className="text-foreground">
Feedback & Feature Requests
</HeadingH3Bold>
<p className="text-muted-foreground text-sm">
Have ideas for new features or improvements? We'd love to hear from
you!
</p>
<Button
className="w-full justify-start bg-purple-500/20 hover:bg-purple-500/30 text-purple-600 dark:text-purple-400 border-purple-500/30"
onClick={() => window.open("https://x.com/supermemory", "_blank")}
variant="outline"
>
<MessageCircle className="w-4 h-4 mr-2" />
Share your feedback on X
<ExternalLink className="w-4 h-4 ml-auto" />
</Button>
</div>
</div>
</div>
)
}

View file

@ -9,12 +9,9 @@ import { QueryProvider } from "../components/query-client"
import { AutumnProvider } from "autumn-js/react"
import { Suspense } from "react"
import { Toaster } from "@ui/components/sonner"
import { MobilePanelProvider } from "@/lib/mobile-panel-context"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ThemeProvider } from "@/lib/theme-provider"
import { ViewModeProvider } from "@/lib/view-mode-context"
const font = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
@ -60,18 +57,14 @@ export default function RootLayout({
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>

View file

@ -1,28 +0,0 @@
"use client"
import { useEffect } from "react"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useRouter } from "next/navigation"
import { MobileBanner } from "@/components/new/mobile-banner"
export default function NewLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const flagEnabled = true
useEffect(() => {
if (!flagEnabled) {
router.push("/")
}
}, [flagEnabled, router])
if (!flagEnabled) {
return null
}
return (
<>
<MobileBanner />
{children}
</>
)
}

View file

@ -1,62 +0,0 @@
"use client"
import { useEffect } from "react"
import { TextEffect } from "@/components/text-effect"
export function AnimatedText({
children,
trigger,
delay,
}: {
children: string
trigger: boolean
delay: number
}) {
const blurSlideVariants = {
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.01 },
},
exit: {
transition: { staggerChildren: 0.01, staggerDirection: 1 },
},
},
item: {
hidden: {
opacity: 0,
filter: "blur(10px) brightness(0%)",
y: 0,
},
visible: {
opacity: 1,
y: 0,
filter: "blur(0px) brightness(100%)",
transition: {
duration: 0.4,
},
},
exit: {
opacity: 0,
y: -30,
filter: "blur(10px) brightness(0%)",
transition: {
duration: 0.3,
},
},
},
}
return (
<TextEffect
className="inline-flex font-medium"
style={{ letterSpacing: "-3px" }}
per="char"
variants={blurSlideVariants}
trigger={trigger}
delay={delay}
>
{children}
</TextEffect>
)
}

View file

@ -1,98 +0,0 @@
"use client"
import { Textarea } from "@ui/components/textarea"
import { useOnboarding } from "./onboarding-context"
import { useState } from "react"
import { Button } from "@ui/components/button"
import { AnimatePresence, motion } from "motion/react"
import { NavMenu } from "./nav-menu"
import { $fetch } from "@lib/api"
export function BioForm() {
const [bio, setBio] = useState("")
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
function handleNext() {
const trimmed = bio.trim()
if (!trimmed) {
nextStep()
return
}
nextStep()
void $fetch("@post/documents", {
body: {
content: trimmed,
containerTags: ["sm_project_default"],
metadata: { sm_source: "consumer" },
},
}).catch((error) => {
console.error("Failed to save onboarding bio memory:", error)
})
}
return (
<div className="relative w-full">
<div className="space-y-4 relative">
<div className="absolute top-0 right-0">
<AnimatePresence mode="sync">
{bio ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Button
variant="link"
size="lg"
className="text-white/60 font-medium! text-base md:text-lg w-fit px-0! cursor-pointer"
onClick={handleNext}
>
Save & Continue
</Button>
</motion.div>
) : (
<motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(5px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Button
variant="link"
size="lg"
className="text-white/60 font-medium! text-base md:text-lg w-fit px-0! cursor-pointer"
onClick={handleNext}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("bio")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-2xl md:text-4xl text-white font-medium">
Tell Supermemory about yourself
</h1>
<p className="text-lg md:text-xl text-white/80">
share with Supermemory what you do, who you are, and what you're
interested in
</p>
</div>
<Textarea
autoFocus
className="font-sans mt-6 text-base! placeholder:text-white/80 text-white tracking-normal font-medium border bg-white/30 border-zinc-200 rounded-lg !field-sizing-normal !min-h-[calc(3*1.5rem+1rem)] w-full"
placeholder="I'm a software engineer from San Francisco..."
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
</div>
)
}

View file

@ -1,904 +0,0 @@
"use client"
import {
ArrowUpIcon,
MicIcon,
PlusIcon,
MousePointer2,
LoaderIcon,
CheckIcon,
XIcon,
ChevronRightIcon,
} from "lucide-react"
import { NavMenu } from "./nav-menu"
import { useOnboarding } from "./onboarding-context"
import { motion, AnimatePresence, type ResolvedValues } from "motion/react"
import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"
import React from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
type CursorAction =
| { type: "startAt"; target: React.RefObject<HTMLElement | null> }
| { type: "startAtPercent"; xPercent: number; yPercent: number }
| {
type: "move"
target: React.RefObject<HTMLElement | null>
duration: number
}
| { type: "move"; xPercent: number; yPercent: number; duration: number }
| { type: "pause"; duration: number }
| { type: "click" }
| { type: "call"; fn: () => void }
interface CursorProps {
actions: CursorAction[]
className?: string
onPositionChange?: (clientX: number, clientY: number) => void
}
function useContainerRect(ref: React.RefObject<HTMLDivElement | null>) {
const rectRef = React.useRef<DOMRect | null>(null)
useLayoutEffect(
function setup() {
if (!ref.current) return
function measure() {
if (ref.current) {
rectRef.current = ref.current.getBoundingClientRect()
}
}
measure()
let resizeObserver: ResizeObserver | null = null
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(function onResize() {
measure()
})
if (ref.current) {
resizeObserver.observe(ref.current)
}
}
function onScroll() {
measure()
}
window.addEventListener("resize", measure)
window.addEventListener("scroll", onScroll, true)
return function cleanup() {
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener("resize", measure)
window.removeEventListener("scroll", onScroll, true)
}
},
[ref],
)
return rectRef
}
function Cursor({ actions, className, onPositionChange }: CursorProps) {
const [position, setPosition] = useState({ x: "0px", y: "0px" })
const [scale, setScale] = useState(1)
const [currentMoveDuration, setCurrentMoveDuration] = useState(0.7) // Default move duration
const containerRef = useRef<HTMLDivElement>(null)
const timeoutsRef = useRef<number[]>([])
const containerRectRef = useContainerRect(containerRef)
const lastUpdateRef = useRef(0)
function moveToElement(
elementRef: React.RefObject<HTMLElement | null>,
duration: number,
) {
if (!containerRef.current || !elementRef.current) return
setCurrentMoveDuration(duration / 1000) // Convert to seconds for Framer Motion
const containerRect = containerRef.current.getBoundingClientRect()
const elementRect = elementRef.current.getBoundingClientRect()
// Position the TOP-LEFT of the cursor at the center of the target element
const x = elementRect.left - containerRect.left + elementRect.width / 2
const y = elementRect.top - containerRect.top + elementRect.height / 2
setPosition({ x: `${x}px`, y: `${y}px` })
}
function setPositionByPercent(xPercent: number, yPercent: number) {
if (!containerRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
// Percentages indicate where the TOP-LEFT of the cursor should be placed
const x = (containerRect.width * xPercent) / 100
const y = (containerRect.height * yPercent) / 100
setCurrentMoveDuration(0) // snap without animating
setPosition({ x: `${x}px`, y: `${y}px` })
}
function moveToPercent(xPercent: number, yPercent: number, duration: number) {
if (!containerRef.current) return
setCurrentMoveDuration(duration / 1000)
const containerRect = containerRef.current.getBoundingClientRect()
const x = (containerRect.width * xPercent) / 100
const y = (containerRect.height * yPercent) / 100
setPosition({ x: `${x}px`, y: `${y}px` })
}
useEffect(() => {
// Clear any existing timeouts before scheduling new ones
timeoutsRef.current.forEach((id) => {
clearTimeout(id)
})
timeoutsRef.current = []
let timeAccumulator = 0
function schedule(callback: () => void, delay: number): number {
const id = window.setTimeout(callback, delay)
timeoutsRef.current.push(id)
return id
}
function executeActions(): void {
actions.forEach((action) => {
// startAt should apply immediately at its place in the sequence and not advance time
if (action.type === "startAt") {
moveToElement(action.target, 0)
return
}
if (action.type === "startAtPercent") {
setPositionByPercent(action.xPercent, action.yPercent)
return
}
schedule(() => {
switch (action.type) {
case "move":
if ("target" in action) {
moveToElement(action.target, action.duration)
} else {
moveToPercent(action.xPercent, action.yPercent, action.duration)
}
break
case "click":
setScale(0.9)
schedule(function resetClickScale() {
setScale(1)
}, 100) // Fixed 100ms click duration
break
case "call":
try {
action.fn()
} catch (_) {
// no-op on errors to avoid breaking demo
}
break
case "pause":
// Pause doesn't require any action, just time passing
break
}
}, timeAccumulator)
// Add this action's duration to the accumulator for the next action
if (action.type === "click") {
timeAccumulator += 100
} else if (action.type === "pause") {
timeAccumulator += action.duration
} else if (action.type === "move") {
timeAccumulator += action.duration
} else {
// 'call' and startAt/startAtPercent don't consume time
}
})
}
// make sure refs are ready
schedule(executeActions, 100)
return function cleanup(): void {
timeoutsRef.current.forEach((id) => {
clearTimeout(id)
})
timeoutsRef.current = []
}
}, [actions])
return (
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none ${className || ""}`}
>
<motion.div
animate={{
x: position.x,
y: position.y,
scale: scale,
}}
transition={{
x: { duration: currentMoveDuration, ease: "easeInOut" },
y: { duration: currentMoveDuration, ease: "easeInOut" },
scale: { duration: 0.1, ease: "easeInOut" },
}}
className="absolute top-0 left-0"
style={{ zIndex: 10 }}
onUpdate={(latest: ResolvedValues) => {
if (!onPositionChange) return
const containerRect = containerRectRef.current
if (!containerRect) return
const now = performance.now()
if (now - lastUpdateRef.current < 50) return // ~20fps throttle
lastUpdateRef.current = now
const latestX =
typeof latest.x === "number"
? latest.x
: Number.parseFloat(String(latest.x || 0))
const latestY =
typeof latest.y === "number"
? latest.y
: Number.parseFloat(String(latest.y || 0))
const clientX = containerRect.left + latestX
const clientY = containerRect.top + latestY
onPositionChange(clientX, clientY)
}}
>
<MousePointer2
className="size-6 drop-shadow"
strokeWidth={1.5}
fill="white"
/>
</motion.div>
</div>
)
}
function SnippetDemo() {
const snippetRootRef = useRef<HTMLDivElement>(null)
const sentenceRef = useRef<HTMLSpanElement>(null)
const [currentEndIndex, setCurrentEndIndex] = useState<number>(0)
const lastStableIndexRef = useRef<number>(0)
const [cursorActions, setCursorActions] = useState<CursorAction[]>([])
const [menuOpen, setMenuOpen] = useState<boolean>(false)
const [hoveredMenuIndex, setHoveredMenuIndex] = useState<number | null>(null)
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([])
const menuItem6Ref = useRef<HTMLElement | null>(null)
const charRectsRef = useRef<DOMRect[]>([])
const targetText =
'There\'s an Italian dish called saltimbocca, which means "leap into the mouth."'
function getIndexFromClientPoint(clientX: number, clientY: number): number {
const rects = charRectsRef.current
if (!sentenceRef.current || rects.length === 0) return 0
let bestIdx = 0
let bestDist = Number.POSITIVE_INFINITY
for (let i = 0; i < rects.length; i++) {
const r = rects[i]
if (!r) continue
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const dx = clientX - cx
const dy = clientY - cy
const d = dx * dx + dy * dy
if (d < bestDist) {
bestDist = d
bestIdx = i
}
}
return bestIdx
}
function getMenuItemIndexFromPoint(
clientX: number,
clientY: number,
): number | null {
const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null
if (!el) return null
const menuItem = el.closest("[data-menu-idx]") as HTMLElement | null
if (menuItem) {
const idx = Number.parseInt(menuItem.dataset.menuIdx || "", 10)
return Number.isFinite(idx) ? idx : null
}
return null
}
useLayoutEffect(
function setupCharRectsMeasurement() {
function measureCharRects(): void {
if (!sentenceRef.current) {
charRectsRef.current = []
return
}
const spans = sentenceRef.current.querySelectorAll("span[data-idx]")
const rects: DOMRect[] = []
spans.forEach(function collect(node) {
rects.push((node as HTMLElement).getBoundingClientRect())
})
charRectsRef.current = rects
}
measureCharRects()
let ro1: ResizeObserver | null = null
let ro2: ResizeObserver | null = null
if (typeof ResizeObserver !== "undefined") {
ro1 = new ResizeObserver(function onResize() {
measureCharRects()
})
ro2 = new ResizeObserver(function onResize() {
measureCharRects()
})
if (snippetRootRef.current) ro1.observe(snippetRootRef.current)
if (sentenceRef.current) ro2.observe(sentenceRef.current)
}
function onScroll(): void {
measureCharRects()
}
window.addEventListener("resize", measureCharRects)
window.addEventListener("scroll", onScroll, true)
return function cleanup(): void {
if (ro1) ro1.disconnect()
if (ro2) ro2.disconnect()
window.removeEventListener("resize", measureCharRects)
window.removeEventListener("scroll", onScroll, true)
}
},
[targetText],
)
useEffect(function setupActionsOnce() {
lastStableIndexRef.current = 0
setCurrentEndIndex(0)
if (!sentenceRef.current) return
const total = targetText.length
const firstSpan = sentenceRef.current.querySelector(
'span[data-idx="0"]',
) as HTMLSpanElement | null
const lastSpan = sentenceRef.current.querySelector(
`span[data-idx="${Math.max(0, total - 1)}"]`,
) as HTMLSpanElement | null
if (!firstSpan || !lastSpan) return
const firstRef = { current: firstSpan } as React.RefObject<HTMLElement>
const lastRef = { current: lastSpan } as React.RefObject<HTMLElement>
setCursorActions([
{
type: "call",
fn: function reset() {
lastStableIndexRef.current = 0
setCurrentEndIndex(0)
setHoveredMenuIndex(null)
},
},
{ type: "startAt", target: firstRef },
{ type: "pause", duration: 200 },
{ type: "move", target: lastRef, duration: 1800 },
{ type: "pause", duration: 1200 },
{ type: "click" },
{
type: "call",
fn: () => {
setMenuOpen(true)
},
},
{ type: "pause", duration: 1000 },
{ type: "move", target: menuItem6Ref, duration: 1000 },
{ type: "pause", duration: 500 },
{ type: "click" },
])
}, [])
return (
<div
ref={snippetRootRef}
className="size-full select-none text-xs relative overflow-hidden"
>
<Cursor
actions={cursorActions}
onPositionChange={function onPositionChange(
clientX: number,
clientY: number,
) {
// Handle text highlighting
const textIdx = getIndexFromClientPoint(clientX, clientY)
const next =
textIdx < lastStableIndexRef.current
? lastStableIndexRef.current
: textIdx
if (next !== lastStableIndexRef.current) {
lastStableIndexRef.current = next
setCurrentEndIndex(next)
}
// Handle menu item hovering
if (menuOpen) {
const menuIdx = getMenuItemIndexFromPoint(clientX, clientY)
if (menuIdx !== hoveredMenuIndex) {
setHoveredMenuIndex(menuIdx)
}
}
}}
/>
<div className="h-[125%] w-full bg-white text-justify p-4 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
writing is easier to read, and the easier something is to read, the more
deeply readers will engage with it. The less energy they expend on your
prose, the more they'll have left for your ideas. And the further
they'll read. Most readers' energy tends to flag part way through an
article or essay. If the friction of reading is low enough, more keep
going till the end.{" "}
<span ref={sentenceRef}>
{targetText.split("").map(function renderChar(ch, idx) {
const highlighted = idx <= currentEndIndex
return (
<span
key={`char-${idx}-${ch}`}
data-idx={idx}
className={highlighted ? "bg-blue-200/70" : undefined}
>
{ch}
</span>
)
})}
</span>
<span className="size-0 relative">
{menuOpen && (
<div className="flex flex-col w-48 text-left absolute top-0 right-0 text-white bg-zinc-800/70 text-xs border-0.5 backdrop-blur-sm border-zinc-900/80 rounded-md px-1.5 py-1.5">
{[
"Back",
"Forward",
"Reload",
"Save As...",
"Print...",
"Translate to English",
"Save to Supermemory",
"View Page Source",
"Inspect",
].map((item, idx) => (
<React.Fragment key={item}>
<div
ref={(el) => {
menuItemRefs.current[idx] = el
if (idx === 6) {
menuItem6Ref.current = el
}
}}
data-menu-idx={idx}
className={cn(
"px-2 py-0.5 flex items-center gap-1.5 rounded-sm transition-colors",
(idx === 0 || idx === 1) && "text-white/30",
hoveredMenuIndex === idx && "bg-blue-500",
)}
>
{idx === 6 && (
<img
src="/images/icon-16.png"
alt="Supermemory"
className="size-3.5"
/>
)}
{item}
</div>
{[2, 5, 6].includes(idx) && (
<div className="h-px rounded-full my-1 mx-2 bg-zinc-300/20" />
)}
</React.Fragment>
))}
</div>
)}
</span>{" "}
My goal when writing might be called saltintesta: the ideas leap into
your head and you barely notice the words that got them there. It's too
much to hope that writing could ever be pure ideas. You might not even
want it to be. But for most writers, most of the time, that's the goal
to aim for. The gap between most writing and pure ideas is not filled
with poetry. Plus it's more considerate to write simply. When you write
in a fancy way to impress people, you're making them do extra work just
so you can seem cool. It's like trailing a long train behind you that
readers have to carry.
</div>
</div>
)
}
function ChatGPTDemo() {
const iconRef = useRef<HTMLImageElement>(null)
const submitButtonRef = useRef<HTMLDivElement>(null)
const [enhancementStatus, setEnhancementStatus] = useState<
"notStarted" | "enhancing" | "done"
>("notStarted")
const [memoriesExpanded, setMemoriesExpanded] = useState(false)
const cursorActions: CursorAction[] = useMemo(
() => [
{
type: "call",
fn: function resetStates() {
setEnhancementStatus("notStarted")
setMemoriesExpanded(false)
},
},
{ type: "startAtPercent", xPercent: 80, yPercent: 80 },
{ type: "pause", duration: 1000 },
{ type: "move", target: iconRef, duration: 1000 },
{ type: "pause", duration: 1000 },
{ type: "click" },
{
type: "call",
fn: function startEnhancing() {
setEnhancementStatus("enhancing")
},
},
{ type: "pause", duration: 1000 },
{ type: "move", xPercent: 10, yPercent: 80, duration: 1000 },
{
type: "call",
fn: function finishEnhancing() {
setEnhancementStatus("done")
},
},
{ type: "pause", duration: 500 },
{ type: "move", target: iconRef, duration: 1000 },
{
type: "call",
fn: function expandMemories() {
setMemoriesExpanded(true)
},
},
{ type: "pause", duration: 1000 },
{ type: "move", xPercent: 80, yPercent: 80, duration: 1000 },
],
[],
)
return (
<div
className="size-full relative overflow-hidden select-none pointer-events-none text-white flex flex-col gap-6 items-center justify-center"
style={{
backgroundColor: "#212121",
fontFamily: "ui-sans-serif, -apple-system, system-ui",
}}
>
<Cursor actions={cursorActions} />
<div className="text-xl">What's on your mind today?</div>
<div
className="w-[85%] text-sm rounded-3xl p-2 flex flex-col gap-2"
style={{ backgroundColor: "#303030" }}
>
<div className="p-2">what are my card's benefits?</div>
<div className="flex justify-between items-center p-1 pt-0">
<PlusIcon className="size-5" strokeWidth={1.5} />
<div className="flex items-center gap-2">
<div className="h-4.5 flex items-center">
<motion.div
ref={iconRef}
layout
initial={false}
animate={{
backgroundColor:
enhancementStatus === "notStarted"
? "transparent"
: "#1e1b4b",
borderColor:
enhancementStatus === "notStarted"
? "transparent"
: "#4338ca",
borderWidth: enhancementStatus === "notStarted" ? 0 : 1,
paddingLeft:
enhancementStatus === "notStarted"
? 0
: enhancementStatus === "enhancing"
? 4
: 8,
paddingRight:
enhancementStatus === "notStarted"
? 0
: enhancementStatus === "enhancing"
? 6
: 8,
paddingTop: enhancementStatus === "notStarted" ? 0 : 4,
paddingBottom: enhancementStatus === "notStarted" ? 0 : 4,
marginTop: enhancementStatus === "notStarted" ? 0 : 4,
marginBottom: enhancementStatus === "notStarted" ? 0 : 4,
}}
transition={{
duration: 0.2,
ease: "easeInOut",
layout: { duration: 0.2, ease: "easeInOut" },
}}
className="rounded-full text-xs w-fit flex items-center relative"
style={{ border: "solid" }}
>
{enhancementStatus === "notStarted" && (
<img
src="/images/icon-16.png"
alt="Enhance with Supermemory"
className="size-5"
/>
)}
{enhancementStatus === "enhancing" && (
<>
<LoaderIcon className="size-3 animate-spin" />
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: "auto" }}
transition={{ delay: 0.1, duration: 0.2 }}
className="ml-2 whitespace-nowrap overflow-hidden"
>
Searching...
</motion.span>
</>
)}
{enhancementStatus === "done" && (
<>
<CheckIcon className="size-3 text-green-400" />
<motion.span
initial={{ opacity: 0, width: "auto" }}
animate={{ opacity: 1, width: "auto" }}
transition={{ delay: 0.1, duration: 0.2 }}
className="ml-2"
>
Including 1 memory
</motion.span>
</>
)}
<AnimatePresence>
{memoriesExpanded && (
<motion.div
initial={{
opacity: 0,
scale: 0.95,
y: -8,
}}
animate={{
opacity: 1,
scale: 1,
y: 0,
}}
exit={{
opacity: 0,
scale: 0.95,
y: -8,
}}
transition={{
duration: 0.15,
ease: [0.16, 1, 0.3, 1],
}}
className="absolute left-1/2 -translate-x-1/2 top-8 bg-[#1e1b4b] border border-[#4338ca] w-56 rounded-lg p-2"
style={{ transformOrigin: "top center" }}
>
<div className="flex items-center gap-2">
<img
src="/images/icon-16.png"
alt="Enhance with Supermemory"
className="size-5"
/>
<span className="text-xs">
User possesses an American Express Platinum card
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
<MicIcon className="size-4" strokeWidth={1.5} />
<div
ref={submitButtonRef}
className="rounded-full bg-white size-6 flex items-center justify-center"
>
<ArrowUpIcon className="size-4 text-black" />
</div>
</div>
</div>
</div>
</div>
)
}
function TwitterDemo() {
const importButtonRef = useRef<HTMLButtonElement>(null)
const [importStatus, setImportStatus] = useState<
"notStarted" | "importing" | "done"
>("notStarted")
const cursorActions: CursorAction[] = useMemo(
() => [
{
type: "call",
fn: function resetStates() {
setImportStatus("notStarted")
},
},
{ type: "startAtPercent", xPercent: 10, yPercent: 80 },
{ type: "pause", duration: 1300 },
{ type: "move", target: importButtonRef, duration: 1100 },
{ type: "pause", duration: 800 },
{ type: "click" },
{
type: "call",
fn: function startImporting() {
setImportStatus("importing")
},
},
{ type: "pause", duration: 700 },
{ type: "move", xPercent: 80, yPercent: 80, duration: 1200 },
{
type: "call",
fn: function finishImporting() {
setImportStatus("done")
},
},
],
[],
)
return (
<div className="size-full relative overflow-hidden select-none flex flex-col items-center justify-center">
<div className="bg-white text-black px-5 py-3 text-sm rounded-2xl w-9/10">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-xl font-bold">𝕏</span>
<span className="text-base font-medium">
Import Twitter Bookmarks
</span>
</div>
<XIcon className="size-4" />
</div>
<div className="mt-3">
<p className="text-sm text-zinc-600">
This will import all your Twitter bookmarks to Supermemory
</p>
</div>
<div className="mt-3">
<motion.button
ref={importButtonRef}
animate={{
backgroundColor:
importStatus === "importing"
? "#f59e0b"
: importStatus === "done"
? "#10b981"
: "#3b82f6",
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="text-white px-4 py-2 rounded-lg flex items-center gap-2 min-w-[180px] justify-center"
>
<AnimatePresence mode="wait">
{importStatus === "importing" && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2 }}
>
<LoaderIcon className="size-4 animate-spin" />
</motion.div>
)}
{importStatus === "done" && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2 }}
>
<CheckIcon className="size-4" />
</motion.div>
)}
</AnimatePresence>
<motion.span
key={importStatus}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{importStatus === "importing"
? "Importing bookmarks..."
: importStatus === "done"
? "Import successful"
: "Import All Bookmarks"}
</motion.span>
</motion.button>
</div>
</div>
<Cursor actions={cursorActions} />
</div>
)
}
export function ExtensionForm() {
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
return (
<div className="relative flex items-start flex-col gap-6 w-full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between w-full gap-6 relative">
<div className="flex flex-col items-start text-left gap-4 flex-1">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("extension")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-white font-medium text-2xl md:text-4xl">
Install the Chrome extension
</h1>
<p className="text-white/80 text-lg md:text-2xl">
Bring Supermemory everywhere
</p>
</div>
<div className="flex flex-col items-end text-center gap-3 w-full md:w-auto">
<Button
variant="link"
size="lg"
className="text-white/80 hover:text-white font-medium! text-lg w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue
<ChevronRightIcon className="size-4" />
</Button>
<a
href="https://chromewebstore.google.com/detail/afpgkkipfdpeaflnpoaffkcankadgjfc?utm_source=item-share-cb"
rel="noopener noreferrer"
target="_blank"
className="bg-zinc-50/80 backdrop-blur-lg border-2 hover:bg-zinc-100/80 transition-colors duration-100 border-zinc-200/80 shadow-xs rounded-full pl-3.5 pr-4 py-2.5 text-base font-sans tracking-tight font-medium flex items-center gap-3 w-full md:w-auto justify-center"
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Google_Chrome_icon_%28February_2022%29.svg/2048px-Google_Chrome_icon_%28February_2022%29.svg.png"
alt="Chrome"
className="size-5"
/>
Add to Chrome
</a>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 w-full max-w-6xl gap-4 text-base font-sans font-medium tracking-normal">
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Remember anything, anywhere</h2>
<p className="text-zinc-600 text-sm">
Just right-click to save instantly.
</p>
</div>
<div className="aspect-square bg-blue-500">
<SnippetDemo />
</div>
</div>
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Integrate with your AI</h2>
{/* Supercharge your AI with memory */}
{/* Supercharge any AI with Supermemory. */}
{/* ChatGPT is better with Supermemory. */}
{/* Seamless integration with your workflow */}
<p className="text-zinc-600 text-sm">
{/* Integrates with ChatGPT and Claude. */}
{/* Integrates with your chat apps */}
Enhance any prompt with Supermemory.
{/* Seamlessly */}
</p>
</div>
<div className="aspect-square bg-blue-500">
<ChatGPTDemo />
</div>
</div>
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Import Twitter bookmarks</h2>
<p className="text-zinc-600 text-sm">
Search semantically and effortlessly.
{/* Import instantly and search effortlessly. */}
</p>
</div>
<div className="aspect-square bg-blue-500">
<TwitterDemo />
</div>
</div>
</div>
</div>
)
}

View file

@ -1,246 +0,0 @@
"use client"
import { motion, useReducedMotion } from "motion/react"
import { useEffect, useMemo, useState, memo } from "react"
import { useOnboarding } from "./onboarding-context"
interface OrbProps {
size: number
initialX: number
initialY: number
duration: number
delay: number
revealDelay: number
shouldReveal: boolean
color: {
primary: string
secondary: string
tertiary: string
}
}
function FloatingOrb({
size,
initialX,
initialY,
duration,
delay,
revealDelay,
shouldReveal,
color,
}: OrbProps) {
const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08)))
const gradient = useMemo(() => {
return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`
}, [color.primary, color.secondary, color.tertiary])
const style = useMemo(() => {
return {
width: size,
height: size,
background: gradient,
filter: `blur(${blurPixels}px)`,
willChange: "transform, opacity",
mixBlendMode: "plus-lighter",
} as any
}, [size, gradient, blurPixels])
const initial = useMemo(() => {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
}
}, [initialX, initialY])
const animate = useMemo(() => {
if (!shouldReveal) {
return {
x: initialX,
y: initialY,
scale: 0,
opacity: 0,
}
}
return {
x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX],
y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY],
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
opacity: 0.7,
}
}, [shouldReveal, initialX, initialY])
const transition = useMemo(() => {
return {
x: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
y: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: [0.42, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : 0,
},
scale: {
duration: shouldReveal ? duration : 0.8,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : revealDelay,
},
opacity: {
duration: 1.2,
ease: [0, 0, 0.58, 1],
delay: shouldReveal ? revealDelay : 0,
},
} as any
}, [shouldReveal, duration, delay, revealDelay])
return (
<motion.div
className="absolute rounded-full"
style={style}
initial={initial}
animate={animate}
transition={transition}
/>
)
}
const MemoFloatingOrb = memo(FloatingOrb)
export function FloatingOrbs() {
const { orbsRevealed } = useOnboarding()
const reduceMotion = useReducedMotion()
const [mounted, setMounted] = useState(false)
const [orbs, setOrbs] = useState<
Array<{
id: number
size: number
initialX: number
initialY: number
duration: number
delay: number
revealDelay: number
color: {
primary: string
secondary: string
tertiary: string
}
}>
>([])
useEffect(() => {
setMounted(true)
const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200
const screenHeight =
typeof window !== "undefined" ? window.innerHeight : 800
// Define edge zones (avoiding center)
const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25 // 25% of smaller dimension
// Define rainbow color palette
const colorPalette = [
{
// Magenta
primary: "rgba(255, 0, 150, 0.6)",
secondary: "rgba(255, 100, 200, 0.4)",
tertiary: "rgba(255, 150, 220, 0.1)",
},
{
// Yellow
primary: "rgba(255, 235, 59, 0.6)",
secondary: "rgba(255, 245, 120, 0.4)",
tertiary: "rgba(255, 250, 180, 0.1)",
},
{
// Light Blue
primary: "rgba(100, 181, 246, 0.6)",
secondary: "rgba(144, 202, 249, 0.4)",
tertiary: "rgba(187, 222, 251, 0.1)",
},
{
// Orange (keeping original)
primary: "rgba(255, 154, 0, 0.6)",
secondary: "rgba(255, 206, 84, 0.4)",
tertiary: "rgba(255, 154, 0, 0.1)",
},
{
// Very Light Red/Pink
primary: "rgba(255, 138, 128, 0.6)",
secondary: "rgba(255, 171, 145, 0.4)",
tertiary: "rgba(255, 205, 210, 0.1)",
},
]
// Generate orb configurations positioned along edges
const newOrbs = Array.from({ length: 8 }, (_, i) => {
let x: number
let y: number
const zone = i % 4 // Rotate through 4 zones: top, right, bottom, left
switch (zone) {
case 0: // Top edge
x = Math.random() * screenWidth
y = Math.random() * edgeThickness
break
case 1: // Right edge
x = screenWidth - edgeThickness + Math.random() * edgeThickness
y = Math.random() * screenHeight
break
case 2: // Bottom edge
x = Math.random() * screenWidth
y = screenHeight - edgeThickness + Math.random() * edgeThickness
break
case 3: // Left edge
x = Math.random() * edgeThickness
y = Math.random() * screenHeight
break
default:
x = Math.random() * screenWidth
y = Math.random() * screenHeight
}
return {
id: i,
size: Math.random() * 300 + 200, // 200px to 500px
initialX: x,
initialY: y,
duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement)
delay: i * 0.4, // Staggered start for floating animation
revealDelay: i * 0.2, // Faster staggered reveal
color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors
}
})
setOrbs(newOrbs)
}, [])
if (!mounted || orbs.length === 0) return null
return (
<div
className="fixed inset-0 pointer-events-none overflow-hidden"
style={{ isolation: "isolate", contain: "paint" }}
>
{orbs.map((orb) => (
<MemoFloatingOrb
key={orb.id}
size={orb.size}
initialX={orb.initialX}
initialY={orb.initialY}
duration={reduceMotion ? 0 : orb.duration}
delay={orb.delay}
revealDelay={orb.revealDelay}
shouldReveal={reduceMotion ? false : orbsRevealed}
color={orb.color}
/>
))}
</div>
)
}

View file

@ -1,85 +0,0 @@
"use client"
import { AnimatedText } from "./animated-text"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@repo/ui/components/button"
import { cn } from "@lib/utils"
import { useOnboarding } from "./onboarding-context"
export function Intro() {
const { nextStep, introTriggers: triggers } = useOnboarding()
return (
<motion.div
className="flex flex-col gap-4 relative text-2xl md:text-4xl w-full text-white text-center"
layout
transition={{
layout: { duration: 0.8, ease: "anticipate" },
}}
>
<AnimatePresence mode="popLayout">
<p className="font-medium text-base">Hey there!</p>
{triggers.first && (
<motion.div
key="first"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
opacity: { duration: 0.3 },
layout: { duration: 0.8, ease: "easeInOut" },
}}
>
<AnimatedText trigger={triggers.first} delay={0}>
Intelligence without memory
</AnimatedText>
</motion.div>
)}
{triggers.second && (
<motion.div
key="second"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
opacity: { duration: 0.3 },
layout: { duration: 0.8, ease: "easeInOut" },
}}
>
<AnimatedText trigger={triggers.second} delay={0.4}>
is just sophisticated randomness.
</AnimatedText>
</motion.div>
)}
</AnimatePresence>
<motion.div
key="fourth"
className="justify-center flex mt-6"
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{
opacity: triggers.fourth ? 1 : 0,
filter: triggers.fourth ? "blur(0px)" : "blur(5px)",
}}
transition={{
opacity: { duration: 0.6, ease: "easeOut" },
filter: { duration: 0.4, ease: "easeOut" },
}}
>
<Button
variant={"default"}
size={"sm"}
className={cn(
"bg-[#1e3a5f] hover:bg-[#2a4a75] border-2 border-[#4a7ba7]/50 text-white font-medium px-6 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300",
!triggers.fourth && "pointer-events-none opacity-50",
)}
style={{
transform: triggers.fourth ? "scale(1)" : "scale(0.95)",
}}
onClick={nextStep}
>
Get Started
</Button>
</motion.div>
</motion.div>
)
}

View file

@ -1,251 +0,0 @@
"use client"
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
} from "@ui/components/select"
import { useOnboarding } from "./onboarding-context"
import { useEffect, useState } from "react"
import { Button } from "@ui/components/button"
import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react"
import { TextMorph } from "@/components/text-morph"
import { NavMenu } from "./nav-menu"
import { cn } from "@lib/utils"
import { motion, AnimatePresence } from "motion/react"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
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 MCPForm() {
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
const [client, setClient] = useState<keyof typeof clients>("cursor")
const [isCopied, setIsCopied] = useState(false)
const [isInstalling, setIsInstalling] = useState(true)
const hasLoginQuery = useQuery({
queryKey: ["mcp", "has-login"],
queryFn: async (): Promise<{ previousLogin: boolean }> => {
const response = await $fetch("@get/mcp/has-login")
if (response.error) {
throw new Error(response.error?.message || "Failed to check MCP login")
}
return response.data as { previousLogin: boolean }
},
enabled: isInstalling,
refetchInterval: isInstalling ? 1000 : false,
staleTime: 0,
})
useEffect(() => {
if (hasLoginQuery.data?.previousLogin) {
setIsInstalling(false)
}
}, [hasLoginQuery.data?.previousLogin])
return (
<div className="relative flex flex-col gap-6">
<div className="space-y-4 relative">
<div className="absolute top-0 right-0">
<AnimatePresence mode="sync">
{!isInstalling ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Button
variant="link"
size="lg"
className="text-white/80 not-odd:font-medium! text-lg w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue
</Button>
</motion.div>
) : (
<motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(5px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("mcp")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl text-white font-medium">
Install the MCP server
</h1>
<p className="text-2xl max-sm:text-lg text-white/80">
Bring Supermemory to all your favourite tools
</p>
</div>
<div className="flex flex-col gap-4 font-sans text-base tracking-normal font-normal">
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div
style={{
height: "calc(100% - 0.5rem)",
}}
className="absolute -z-10 left-1/2 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
/>
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
1
</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p className="text-white/80">
Select the app you want to install Supermemory MCP to
</p>
<Select
onValueChange={(value) =>
setClient(value as keyof typeof clients)
}
value={client}
>
<SelectTrigger
id="client-select"
className="w-full bg-white/10! text-white"
>
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{Object.entries(clients).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
2
</div>
<div
style={{
height: "calc(100% - 0.5rem)",
}}
className="absolute left-1/2 -z-10 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
/>
</div>
<div className="mt-2 space-y-2">
<p className="text-white/80">Copy the installation command</p>
<div className="bg-white/10 relative shadow-xs rounded-lg max-w-md text-balance py-4 px-5 align-middle justify-center">
<p className="text-white font-mono text-xs w-4/5 text-nowrap overflow-x-hidden text-ellipsis">
npx -y install-mcp@latest https://mcp.supermemory.ai/mcp
--client {client} --oauth=yes
</p>
<Button
className="absolute right-2 top-[6px]"
onClick={() => {
navigator.clipboard.writeText(
`npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${client} --oauth=yes`,
)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2000)
}}
>
{isCopied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
<TextMorph>{isCopied ? "Copied!" : "Copy"}</TextMorph>
</Button>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
3
</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p className="text-white/80">
Run the command in your terminal of choice
</p>
<motion.div
className={cn(
"px-5 py-4 bg-black/10 text-white shadow-xs rounded-lg flex items-center gap-3 font-mono text-sm",
)}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<AnimatePresence mode="wait">
{isInstalling ? (
<motion.div
key="loading"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<LoaderIcon className="size-4 animate-spin" />
</motion.div>
) : (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<CircleCheckIcon className="size-4 text-white" />
</motion.div>
)}
</AnimatePresence>
<motion.span
key={isInstalling ? "installing" : "complete"}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{isInstalling
? "Waiting for installation..."
: "Installation complete!"}
</motion.span>
</motion.div>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,100 +0,0 @@
"use client"
import { useOnboarding } from "./onboarding-context"
import { useAuth } from "@lib/auth-context"
import Link from "next/link"
import { useEffect, useState } from "react"
import { CheckIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { NavMenu } from "./nav-menu"
import { authClient } from "@lib/auth"
export function NameForm() {
const { nextStep, totalSteps, getStepNumberFor } = useOnboarding()
const { user } = useAuth()
const [name, setName] = useState(user?.name ?? "")
useEffect(() => {
if (!name && user?.name) {
setName(user.name)
}
}, [name, user?.name])
function handleNext(): void {
const trimmed = name.trim()
if (!trimmed) {
nextStep()
return
}
nextStep()
void authClient.updateUser({ name: trimmed }).catch((error: unknown) => {
console.error("Failed to update user name during onboarding:", error)
})
}
function handleSubmit(e: React.FormEvent): void {
e.preventDefault()
handleNext()
}
if (!user) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-4xl">You need to sign in to continue</h1>
<Link href="/login">Login</Link>
</div>
)
}
return (
<div className="flex flex-col gap-4 w-full">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("name")} of {totalSteps}
</p>
</NavMenu>
<p className="text-2xl md:text-4xl text-white font-medium">
What should we call you?
</p>
<form onSubmit={handleSubmit} className="flex flex-col group text-white">
<div className="relative flex flex-col">
<input
type="text"
name="name"
autoComplete="name"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
className="outline-0 text-2xl h-12 font-normal p-0"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<AnimatePresence mode="popLayout">
{name && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="next"
transition={{ duration: 0.15 }}
className="absolute pointer-events-none inset-0 flex items-center justify-end"
>
<button
type="submit"
className="cursor-pointer transition-colors duration-150 gap-2 pointer-events-auto flex items-center p-2 hover:bg-zinc-100 hover:text-black/90 rounded-lg"
>
<CheckIcon className="w-4 h-4" />
{/* <span className="text-sm">Next</span> */}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full rounded-full group-focus-within:bg-zinc-400 transition-colors h-px bg-zinc-200" />
</form>
</div>
)
}

View file

@ -1,61 +0,0 @@
"use client"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@ui/components/hover-card"
import { useOnboarding, type OnboardingStep } from "./onboarding-context"
import { useState } from "react"
import { cn } from "@lib/utils"
export function NavMenu({ children }: { children: React.ReactNode }) {
const { setStep, currentStep, visibleSteps, getStepNumberFor } =
useOnboarding()
const [open, setOpen] = useState(false)
const LABELS: Record<OnboardingStep, string> = {
intro: "Intro",
name: "Name",
bio: "About you",
// connections: "Connections",
mcp: "MCP",
extension: "Extension",
welcome: "Welcome",
}
const navigableSteps = visibleSteps.filter(
(step) => step !== "intro" && step !== "welcome",
)
return (
<HoverCard openDelay={100} open={open} onOpenChange={setOpen}>
<HoverCardTrigger className="w-fit" asChild>
{children}
</HoverCardTrigger>
<HoverCardContent
align="start"
side="left"
sideOffset={24}
className="origin-top-right bg-white border border-zinc-200 text-zinc-900"
>
<h2 className="text-zinc-900 text-sm font-medium">Go to step</h2>
<ul className="text-sm mt-2">
{navigableSteps.map((step) => (
<li key={step}>
<button
type="button"
className={cn(
"py-1.5 px-2 rounded-md hover:bg-zinc-100 w-full text-left",
currentStep === step && "bg-zinc-100",
)}
onClick={() => {
setStep(step)
setOpen(false)
}}
>
{getStepNumberFor(step)}. {LABELS[step]}
</button>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
)
}

View file

@ -1,34 +0,0 @@
"use client"
import { useOnboarding } from "./onboarding-context"
interface OnboardingBackgroundProps {
children: React.ReactNode
}
export function OnboardingBackground({ children }: OnboardingBackgroundProps) {
const { currentStep, visibleSteps } = useOnboarding()
const backgroundImage = "url(/onboarding.png)"
const currentZoomStepIndex = visibleSteps.indexOf(currentStep)
const zoomScale =
currentZoomStepIndex >= 0 ? 1.0 + currentZoomStepIndex * 0.1 : 1.0
return (
<div className="min-h-screen w-full overflow-x-hidden text-zinc-900 flex items-center justify-center relative px-4 md:px-0">
<div
className="absolute inset-0 transition-transform duration-700 ease-in-out"
style={{
backgroundImage,
backgroundSize: "cover",
backgroundPosition: "bottom",
backgroundRepeat: "no-repeat",
transform: `scale(${zoomScale})`,
}}
/>
<div className="relative z-10 w-full max-w-4xl mx-auto">{children}</div>
</div>
)
}

View file

@ -1,232 +0,0 @@
"use client"
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
} from "react"
import { useQueryState } from "nuqs"
import { useIsMobile } from "@hooks/use-mobile"
// Define the context interface
interface OnboardingContextType {
currentStep: OnboardingStep
setStep: (step: OnboardingStep) => void
nextStep: () => void
previousStep: () => void
totalSteps: number
currentStepIndex: number
// Visible-step aware helpers
visibleSteps: OnboardingStep[]
currentVisibleStepIndex: number
currentVisibleStepNumber: number
getStepNumberFor: (step: OnboardingStep) => number
introTriggers: {
first: boolean
second: boolean
third: boolean
fourth: boolean
}
orbsRevealed: boolean
resetIntroTriggers: () => void
}
// Create the context
const OnboardingContext = createContext<OnboardingContextType | undefined>(
undefined,
)
// Define the base step order
const BASE_STEP_ORDER = [
"intro",
"name",
"bio",
"mcp",
"extension",
"welcome",
] as const
export type OnboardingStep = (typeof BASE_STEP_ORDER)[number]
interface OnboardingProviderProps {
children: ReactNode
initialStep?: OnboardingStep
}
export function OnboardingProvider({
children,
initialStep = "intro",
}: OnboardingProviderProps) {
// Helper function to validate if a step is valid
const isValidStep = (step: string): step is OnboardingStep => {
return BASE_STEP_ORDER.includes(step as OnboardingStep)
}
const [currentStep, setCurrentStep] = useQueryState("step", {
defaultValue: initialStep,
parse: (value: string) => {
// Validate the step from URL - if invalid, use the initial step
return isValidStep(value) ? value : initialStep
},
serialize: (value: OnboardingStep) => value,
})
const [orbsRevealed, setOrbsRevealed] = useState(false)
const [introTriggers, setIntroTriggers] = useState({
first: false,
second: false,
third: false,
fourth: false,
})
const isMobile = useIsMobile()
// Compute visible steps based on device
const visibleSteps = useMemo(() => {
if (isMobile) {
// On mobile, hide MCP and Extension steps
return BASE_STEP_ORDER.filter((s) => s !== "mcp" && s !== "extension")
}
return [...BASE_STEP_ORDER]
}, [isMobile])
// Setup intro trigger timings when on intro step
useEffect(() => {
if (currentStep !== "intro") return
const cleanups = [
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, first: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, second: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, third: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, fourth: true }))
}, 400),
]
return () => cleanups.forEach(clearTimeout)
}, [currentStep])
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
useEffect(() => {
if (currentStep !== "intro") {
// If we're not on the intro step, orbs should always be visible
// (user has either completed intro or navigated directly to another step)
if (!orbsRevealed) {
setOrbsRevealed(true)
}
} else if (introTriggers.fourth && !orbsRevealed) {
// On intro step, reveal orbs only after the fourth trigger
setOrbsRevealed(true)
}
}, [introTriggers.fourth, orbsRevealed, currentStep])
// Ensure current step is always part of visible steps; if not, advance to the next visible step
useEffect(() => {
if (!visibleSteps.includes(currentStep)) {
if (visibleSteps.length === 0) return
const baseIndex = BASE_STEP_ORDER.indexOf(currentStep)
// Find the next visible step after the current base index
const nextAfterBase = visibleSteps.find(
(step) => BASE_STEP_ORDER.indexOf(step) > baseIndex,
)
const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!
setCurrentStep(targetStep)
}
}, [visibleSteps, currentStep])
function setStep(step: OnboardingStep) {
setCurrentStep(step)
}
function nextStep() {
const currentIndex = visibleSteps.indexOf(currentStep)
const nextIndex = currentIndex + 1
if (nextIndex < visibleSteps.length) {
setStep(visibleSteps[nextIndex]!)
}
}
function previousStep() {
const currentIndex = visibleSteps.indexOf(currentStep)
const previousIndex = currentIndex - 1
if (previousIndex >= 0) {
setStep(visibleSteps[previousIndex]!)
}
}
function resetIntroTriggers() {
setIntroTriggers({
first: false,
second: false,
third: false,
fourth: false,
})
}
const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep)
// Visible-step aware helpers
const stepsForNumbering = useMemo(
() => visibleSteps.filter((s) => s !== "intro" && s !== "welcome"),
[visibleSteps],
)
function getStepNumberFor(step: OnboardingStep): number {
if (step === "intro" || step === "welcome") {
return 0
}
const idx = stepsForNumbering.indexOf(step)
return idx === -1 ? 0 : idx + 1
}
const currentVisibleStepIndex = useMemo(
() => visibleSteps.indexOf(currentStep),
[visibleSteps, currentStep],
)
const currentVisibleStepNumber = useMemo(
() => getStepNumberFor(currentStep),
[currentStep, stepsForNumbering],
)
const totalSteps = stepsForNumbering.length
const contextValue: OnboardingContextType = {
currentStep,
setStep,
nextStep,
previousStep,
totalSteps,
currentStepIndex,
visibleSteps,
currentVisibleStepIndex,
currentVisibleStepNumber,
getStepNumberFor,
introTriggers,
orbsRevealed,
resetIntroTriggers,
}
return (
<OnboardingContext.Provider value={contextValue}>
{children}
</OnboardingContext.Provider>
)
}
export function useOnboarding() {
const context = useContext(OnboardingContext)
if (context === undefined) {
throw new Error("useOnboarding must be used within an OnboardingProvider")
}
return context
}

View file

@ -1,110 +0,0 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { NameForm } from "./name-form"
import { Intro } from "./intro"
import { useOnboarding } from "./onboarding-context"
import { BioForm } from "./bio-form"
import { ExtensionForm } from "./extension-form"
import { MCPForm } from "./mcp-form"
import { Welcome } from "./welcome"
import { Space_Grotesk } from "next/font/google"
import { cn } from "@lib/utils"
const sans = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
})
export function OnboardingForm() {
const { currentStep, resetIntroTriggers } = useOnboarding()
return (
<div
className={cn(
"text-2xl md:text-4xl px-4 md:px-6 py-6 md:py-8 flex flex-col justify-center w-full max-w-4xl mx-auto",
sans.variable,
)}
>
<AnimatePresence mode="wait" onExitComplete={resetIntroTriggers}>
{currentStep === "intro" && (
<motion.div
key="intro"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
transition={{ duration: 0.28, ease: "easeInOut" }}
>
<Intro />
</motion.div>
)}
{currentStep === "name" && (
<motion.div
key="name"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<NameForm />
</motion.div>
)}
{currentStep === "bio" && (
<motion.div
key="bio"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<BioForm />
</motion.div>
)}
{/*{currentStep === "connections" && (
<motion.div
key="connections"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<ConnectionsForm />
</motion.div>
)}*/}
{currentStep === "mcp" && (
<motion.div
key="mcp"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<MCPForm />
</motion.div>
)}
{currentStep === "extension" && (
<motion.div
key="extension"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<ExtensionForm />
</motion.div>
)}
{currentStep === "welcome" && (
<motion.div
key="welcome"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<Welcome />
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -1,29 +0,0 @@
import { getSession } from "@lib/auth"
import { OnboardingForm } from "./onboarding-form"
import { OnboardingProvider } from "./onboarding-context"
import { OnboardingProgressBar } from "./progress-bar"
import { redirect } from "next/navigation"
import { OnboardingBackground } from "./onboarding-background"
import { OnboardingWrapper } from "@/components/onboarding/onboarding-wrapper"
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Welcome to Supermemory",
description: "We're excited to have you on board.",
}
export default function OnboardingPage() {
const session = getSession()
if (!session) redirect("/login")
return (
<OnboardingWrapper>
<OnboardingProvider>
<OnboardingProgressBar />
<OnboardingBackground>
<OnboardingForm />
</OnboardingBackground>
</OnboardingProvider>
</OnboardingWrapper>
)
}

View file

@ -1,25 +0,0 @@
"use client"
import { motion } from "motion/react"
import { useOnboarding } from "./onboarding-context"
export function OnboardingProgressBar() {
const { currentVisibleStepNumber, totalSteps } = useOnboarding()
const progress =
totalSteps === 0 ? 0 : (currentVisibleStepNumber / totalSteps) * 100
return (
<div className="fixed top-0 left-0 right-0 z-50 h-[2px] bg-zinc-200">
<motion.div
className="h-full bg-gradient-to-r from-[#06245B] via-[#1A5EA7] to-[#DDF5FF]"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
transition={{
duration: 0.8,
ease: "easeInOut",
}}
/>
</div>
)
}

View file

@ -1,35 +0,0 @@
"use client"
import { ArrowRightIcon } from "lucide-react"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useRouter } from "next/navigation"
export function Welcome() {
const { markOnboardingCompleted } = useOnboardingStorage()
const router = useRouter()
const handleGetStarted = () => {
markOnboardingCompleted()
router.push("/")
}
return (
<div className="flex flex-col gap-4 items-center text-center w-full">
<h1 className="text-white font-medium text-2xl md:text-4xl">
Welcome to Supermemory
</h1>
<p className="text-white/80 text-lg md:text-2xl">
We're excited to have you on board.
</p>
<button
type="button"
onClick={handleGetStarted}
className="tracking-normal w-fit flex items-center justify-center text-lg md:text-2xl underline cursor-pointer font-medium text-white/80 hover:text-white transition-colors"
>
Get started
<ArrowRightIcon className="size-4 ml-2" />
</button>
</div>
)
}

View file

@ -1,112 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { generateId } from "@lib/generate-id"
import { usePersistentChat } from "@/stores/chat"
import { ArrowUp } from "lucide-react"
import { Button } from "@ui/components/button"
import { ProjectSelector } from "./project-selector"
import { ModelSelector } from "./model-selector"
import { useAuth } from "@lib/auth-context"
export function ChatInput() {
const [message, setMessage] = useState("")
const [selectedModel, setSelectedModel] = useState<
"gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro"
>("gemini-2.5-pro")
const router = useRouter()
const { setCurrentChatId } = usePersistentChat()
const { user } = useAuth()
useEffect(() => {
const savedModel = localStorage.getItem("selectedModel") as
| "gpt-5"
| "claude-sonnet-4.5"
| "gemini-2.5-pro"
if (
savedModel &&
["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel)
) {
setSelectedModel(savedModel)
}
}, [])
const handleModelChange = (
modelId: "gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro",
) => {
setSelectedModel(modelId)
localStorage.setItem("selectedModel", modelId)
}
const handleSend = () => {
if (!message.trim()) return
const newChatId = generateId()
setCurrentChatId(newChatId)
sessionStorage.setItem(`chat-initial-${newChatId}`, message.trim())
sessionStorage.setItem(`chat-model-${newChatId}`, selectedModel)
router.push(`/chat/${newChatId}`)
setMessage("")
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="flex-1 flex items-center justify-center px-4">
<div className="w-full max-w-4xl">
<div className="text-start mb-4">
<h2 className="text-3xl font-bold text-foreground">
Welcome, <span className="text-primary">{user?.name}</span>
</h2>
</div>
<div className="relative">
<form
className="flex flex-col items-end bg-card border border-border rounded-[14px] shadow-lg"
onSubmit={(e) => {
e.preventDefault()
if (!message.trim()) return
handleSend()
}}
>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask your supermemory..."
className="w-full text-foreground placeholder-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-6 py-4 bg-transparent"
rows={2}
/>
<div className="flex items-center gap-2 w-full justify-between py-2 px-3 rounded-b-[14px]">
<ProjectSelector />
<div className="flex items-center gap-2">
<ModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
<Button
onClick={handleSend}
disabled={!message.trim()}
className="text-primary-foreground border-0 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed !bg-primary h-8 w-8"
variant="outline"
size="icon"
>
<ArrowUp className="size-3.5" />
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
)
}

View file

@ -1,228 +0,0 @@
"use client"
import {
motion,
useMotionValue,
useTransform,
animate,
useReducedMotion,
} from "motion/react"
import { useEffect, useMemo } from "react"
import * as flubber from "flubber"
type ChatLoaderProps = {
size?: number
colorClassName?: string
label?: string
className?: string
}
const LEFT_PATHS = [
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 0
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 1
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 2
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 3
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 4
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 5
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 6
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 7
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 8
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 9
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 10
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 11
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 12
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 13
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 14
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 15
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 16
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 17
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 18
]
const MIDDLE_PATHS = [
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 0
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 1
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 2
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 3
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 4
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 5
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 6
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 7
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 8
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 9
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 10
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 11
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 12
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 13
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 14
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 15
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 16
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 17
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 18
]
const RIGHT_PATHS = [
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 0
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 1
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 2
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 3
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 4
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 5
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 6
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 7
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 8
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 9
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 10
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 11
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 12
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 13
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 14
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 15
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 16
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 17
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 18
]
export function ChatLoader({
size = 80,
colorClassName = "text-white",
label = "",
className = "",
}: ChatLoaderProps) {
const prefersReducedMotion = useReducedMotion()
const t = useMotionValue(0)
const loopDuration = 3.6 // full cycle
const makeMultiInterp = (paths: string[]) => {
if (!paths || paths.length === 0) {
return (_t: number) => ""
}
if (paths.length === 1) {
const only = paths[0]
return (_t: number) => only
}
const options: { maxSegmentLength?: number } = { maxSegmentLength: 0.5 }
const interpolateFn = (flubber as any).interpolate as (
from: string,
to: string,
options?: { maxSegmentLength?: number },
) => (t: number) => string
const segmentInterpolators: Array<(t: number) => string> = []
for (let i = 0; i < paths.length - 1; i++) {
segmentInterpolators.push(
interpolateFn(paths[i]!, paths[i + 1]!, options),
)
}
const segmentCount = segmentInterpolators.length
return (t: number) => {
if (t <= 0) return paths[0] || ""
if (t >= 1) return paths[paths.length - 1] || ""
const scaled = t * segmentCount
const segIndex = Math.min(Math.floor(scaled), segmentCount - 1)
const localT = scaled - segIndex
return segmentInterpolators[segIndex]!(localT)
}
}
const leftInterp = useMemo(
() => (LEFT_PATHS.length ? makeMultiInterp(LEFT_PATHS) : null),
[],
)
const middleInterp = useMemo(
() => (MIDDLE_PATHS.length ? makeMultiInterp(MIDDLE_PATHS) : null),
[],
)
const rightInterp = useMemo(
() => (RIGHT_PATHS.length ? makeMultiInterp(RIGHT_PATHS) : null),
[],
)
// Turn scalar t into d strings
const leftD = useTransform(t, (v) =>
leftInterp ? leftInterp(v) : LEFT_PATHS[0] || "",
)
const middleD = useTransform(t, (v) =>
middleInterp ? middleInterp(v) : MIDDLE_PATHS[0] || "",
)
const rightD = useTransform(t, (v) =>
rightInterp ? rightInterp(v) : RIGHT_PATHS[0] || "",
)
const middleOpacity = useTransform(t, (v) => {
if (v < 0.2) return 0
if (v < 0.3) return (v - 0.2) / 0.1 // fade in
if (v < 0.8) return 1
if (v < 0.9) return 1 - (v - 0.8) / 0.1 // fade out
return 0
})
useEffect(() => {
if (prefersReducedMotion) {
t.set(0)
return
}
const controls = animate(t, [0, 1], {
duration: loopDuration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
repeatDelay: 0.4, // ⬅️ wait 2 seconds at the end before restarting
})
return () => controls.stop()
}, [t, prefersReducedMotion, loopDuration])
return (
<div
role="status"
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
style={{ width: size }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36 20"
width={size}
height={(size * 20) / 36}
className={colorClassName}
>
{leftInterp && <motion.path d={leftD as any} fill="currentColor" />}
{rightInterp && <motion.path d={rightD as any} fill="currentColor" />}
{middleInterp && (
<motion.path
d={middleD as any}
fill="currentColor"
style={{ opacity: middleOpacity as any }}
/>
)}
</svg>
{label && (
<span
className="text-xs font-medium text-slate-400"
style={{ fontSize: size * 0.18 }}
>
{label}
</span>
)}
</div>
)
}

View file

@ -1,242 +0,0 @@
"use client"
import { Button } from "@ui/components/button"
import {
Bookmark,
Zap,
CircleX,
Users,
Lock,
ChromeIcon,
TwitterIcon,
} from "lucide-react"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import Image from "next/image"
import { analytics } from "@/lib/analytics"
import { useIsMobile } from "@hooks/use-mobile"
export function ChromeExtensionButton() {
const [isExtensionInstalled, setIsExtensionInstalled] = useState(false)
const [isChecking, setIsChecking] = useState(true)
const [isDismissed, setIsDismissed] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const isMobile = useIsMobile()
useEffect(() => {
const dismissed =
localStorage.getItem("chrome-extension-dismissed") === "true"
const minimized =
localStorage.getItem("chrome-extension-minimized") === "true"
setIsDismissed(dismissed)
setIsMinimized(minimized)
const checkExtension = () => {
const message = { action: "check-extension" }
const timeout = setTimeout(() => {
setIsExtensionInstalled(false)
setIsChecking(false)
// Auto-minimize after 3 seconds if extension is not installed and not dismissed
if (!dismissed && !minimized) {
setTimeout(() => {
setIsMinimized(true)
localStorage.setItem("chrome-extension-minimized", "true")
}, 3000)
}
}, 1000)
const handleMessage = (event: MessageEvent) => {
if (event.data?.action === "extension-detected") {
clearTimeout(timeout)
setIsExtensionInstalled(true)
setIsChecking(false)
window.removeEventListener("message", handleMessage)
}
}
window.addEventListener("message", handleMessage)
window.postMessage(message, "*")
return () => {
clearTimeout(timeout)
window.removeEventListener("message", handleMessage)
}
}
if (!dismissed) {
checkExtension()
} else {
setIsChecking(false)
}
}, [])
const handleInstall = () => {
analytics.extensionInstallClicked()
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
"noopener,noreferrer",
)
}
const handleDismiss = () => {
localStorage.setItem("chrome-extension-dismissed", "true")
localStorage.removeItem("chrome-extension-minimized")
setIsDismissed(true)
}
// Don't show if extension is installed, checking, dismissed, or on mobile
if (isExtensionInstalled || isChecking || isDismissed || isMobile) {
return null
}
return (
<motion.div
className="fixed bottom-4 right-4 z-50"
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div
className={`bg-background/95 backdrop-blur-md shadow-xl ${
isMinimized
? "flex items-center gap-1 rounded-full"
: "max-w-md w-90 rounded-2xl"
}`}
>
{!isMinimized && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="p-4 text-white bg-cover bg-center">
<div
className="p-4 rounded-lg"
style={{
backgroundImage: "url('/images/extension-bg.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<div className="relative">
<h1 className="text-2xl font-bold mb-1">
supermemory extension
</h1>
<p className="text-sm opacity-90">
your second brain for the web.
</p>
</div>
</div>
</div>
<div className="px-6 py-2 pb-4 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg flex items-center justify-center flex-shrink-0">
<TwitterIcon className="fill-blue-500 dark:fill-blue-400 text-blue-500 dark:text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-sm text-foreground">
Twitter Imports
</h3>
<p className="text-xs text-muted-foreground">
Import your twitter timeline & save tweets.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg flex items-center justify-center flex-shrink-0">
<Bookmark className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h3 className="font-semibold text-sm text-foreground">
Save All Bookmarks
</h3>
<p className="text-xs text-muted-foreground">
Instantly save any webpage to your memory.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg flex items-center justify-center flex-shrink-0">
<Zap className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 className="font-semibold text-sm text-foreground">
Charge Empty Memory
</h3>
<p className="text-xs text-muted-foreground">
Automatically capture & organize your browsing history.
</p>
</div>
</div>
</div>
<div className="px-6 pb-4">
<Button
onClick={handleInstall}
className="w-full bg-background border border-primary text-foreground hover:bg-accent font-semibold rounded-lg h-10 flex items-center justify-center gap-3"
>
<div className="w-6 h-6 bg-[#686CFD] rounded-full flex items-center justify-center">
<Image
src="/images/extension-logo.png"
alt="Extension Logo"
width={24}
height={24}
/>
</div>
Add to Chrome - It's Free
</Button>
</div>
<div className="px-6 pb-4 flex items-center justify-center gap-6 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span>4K+ users</span>
</div>
<div className="flex items-center gap-1">
<Lock className="w-3 h-3" />
<span>Privacy first</span>
</div>
</div>
</motion.div>
)}
{isMinimized && (
<div className="relative flex items-center w-full group">
<Button
size={"lg"}
onClick={handleInstall}
className="text-xs rounded-full"
style={{
backgroundImage: "url('/images/extension-bg.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<ChromeIcon className="h-3 w-3 mr-1" />
Get Extension
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="absolute top-[-16px] right-[-12px] h-6 w-6 p-0 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-75 transition-opacity duration-200"
>
<CircleX className="w-4 h-4" />
</Button>
</div>
)}
</div>
</motion.div>
)
}

View file

@ -1,229 +0,0 @@
"use client"
import { useState } from "react"
import { Card, CardContent } from "@repo/ui/components/card"
import { Badge } from "@repo/ui/components/badge"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { ExternalLink, FileText, Brain, Trash2 } from "lucide-react"
import { cn } from "@lib/utils"
import { colors } from "@repo/ui/memory-graph/constants"
import { getPastelBackgroundColor } from "../memories-utils"
interface GoogleDocsCardProps {
title: string
url: string | null | undefined
description?: string | null
className?: string
onClick?: () => void
onDelete?: () => void
showExternalLink?: boolean
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
lastModified?: string | Date
}
export const GoogleDocsCard = ({
title,
url,
description,
className,
onClick,
onDelete,
showExternalLink = true,
activeMemories,
lastModified,
}: GoogleDocsCardProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleCardClick = () => {
if (!isDialogOpen) {
if (onClick) {
onClick()
} else if (url) {
window.open(url, "_blank", "noopener,noreferrer")
}
}
}
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (url) {
window.open(url, "_blank", "noopener,noreferrer")
}
}
return (
<Card
className={cn(
"cursor-pointer transition-all hover:shadow-md group overflow-hidden relative py-4",
className,
)}
onClick={handleCardClick}
style={{
backgroundColor: getPastelBackgroundColor(url || title || "googledocs"),
}}
>
{onDelete && (
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<button
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
}}
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(4px)",
}}
type="button"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document and all its
related memories? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<CardContent className="p-0">
<div className="px-4 border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 87.3 78"
aria-label="Google Docs"
>
<title>Google Docs</title>
<path
fill="#0066da"
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
/>
<path
fill="#00ac47"
d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
/>
<path
fill="#ea4335"
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
/>
<path
fill="#00832d"
d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
/>
<path
fill="#2684fc"
d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
/>
<path
fill="#ffba00"
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
/>
</svg>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Google Docs
</span>
</div>
</div>
<div className="flex items-center gap-1">
{showExternalLink && (
<button
onClick={handleExternalLinkClick}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-white/10 flex-shrink-0"
type="button"
aria-label="Open in Google Docs"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
<div className="px-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm line-clamp-2 leading-tight flex-1">
{title || "Untitled Document"}
</h3>
</div>
{description && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
{description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="w-3 h-3" />
<span>Google Workspace</span>
</div>
{lastModified && (
<span className="truncate">
Modified{" "}
{lastModified instanceof Date
? lastModified.toLocaleDateString()
: new Date(lastModified).toLocaleDateString()}
</span>
)}
</div>
{activeMemories && activeMemories.length > 0 && (
<div>
<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>
</div>
)}
</div>
</CardContent>
</Card>
)
}
GoogleDocsCard.displayName = "GoogleDocsCard"

View file

@ -1,198 +0,0 @@
import { Badge } from "@repo/ui/components/badge"
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { colors } from "@repo/ui/memory-graph/constants"
import { Brain, ExternalLink, Trash2 } from "lucide-react"
import { cn } from "@lib/utils"
import { useState } from "react"
import {
formatDate,
getPastelBackgroundColor,
getSourceUrl,
} from "../memories-utils"
import { MCPIcon } from "../menu"
import { analytics } from "@/lib/analytics"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface NoteCardProps {
document: DocumentWithMemories
width: number
activeMemories: Array<{ id: string; isForgotten?: boolean }>
forgottenMemories: Array<{ id: string; isForgotten?: boolean }>
onOpenDetails: (document: DocumentWithMemories) => void
onDelete: (document: DocumentWithMemories) => void
}
export const NoteCard = ({
document,
width,
activeMemories,
forgottenMemories,
onOpenDetails,
onDelete,
}: NoteCardProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false)
return (
<Card
className="w-full p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 shadow-xs"
onClick={() => {
if (!isDialogOpen) {
analytics.documentCardClicked()
onOpenDetails(document)
}
}}
style={{
backgroundColor: getPastelBackgroundColor(
document.id || document.title || "note",
),
width: width,
}}
>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<button
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 group-hover:cursor-pointer transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
}}
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(4px)",
}}
type="button"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document and all its related
memories? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete(document)
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CardHeader className="relative z-10 px-0 pb-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<p
className={cn(
"text-sm font-medium line-clamp-1",
document.url ? "max-w-[190px]" : "max-w-[200px]",
)}
>
{document.title || "Untitled Document"}
</p>
</div>
<div className="flex items-center gap-1">
{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>
<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.content && (
<p
className="text-xs line-clamp-6"
style={{ color: colors.text.muted }}
>
{document.content}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
{activeMemories.length > 0 && (
<Badge
className="text-xs text-accent-foreground mt-2"
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 mt-2"
style={{
borderColor: "rgba(255, 255, 255, 0.2)",
color: colors.text.muted,
}}
variant="outline"
>
{forgottenMemories.length} forgotten
</Badge>
)}
{document.source === "mcp" && (
<Badge variant="outline" className="mt-2">
<MCPIcon className="w-3 h-3 mr-1" />
MCP
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
)
}
NoteCard.displayName = "NoteCard"

View file

@ -1,169 +0,0 @@
import { Suspense, useState } from "react"
import type { Tweet } from "react-tweet/api"
import {
type TwitterComponents,
TweetContainer,
TweetHeader,
TweetInReplyTo,
TweetBody,
TweetMedia,
TweetInfo,
QuotedTweet,
TweetNotFound,
TweetSkeleton,
enrichTweet,
} from "react-tweet"
import { Badge } from "@repo/ui/components/badge"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { Brain, Trash2 } from "lucide-react"
import { colors } from "@repo/ui/memory-graph/constants"
import { getPastelBackgroundColor } from "../memories-utils"
type MyTweetProps = {
tweet: Tweet
components?: TwitterComponents
}
const MyTweet = ({ tweet: t, components }: MyTweetProps) => {
const parsedTweet = typeof t === "string" ? JSON.parse(t) : t
const tweet = enrichTweet(parsedTweet)
return (
<TweetContainer className="pb-5">
<TweetHeader tweet={tweet} components={components} />
{tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
<TweetBody tweet={tweet} />
{tweet.mediaDetails?.length ? (
<TweetMedia tweet={tweet} components={components} />
) : null}
{tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
<TweetInfo tweet={tweet} />
</TweetContainer>
)
}
const TweetContent = ({
components,
tweet,
}: {
components: TwitterComponents
tweet: Tweet
}) => {
if (!tweet) {
const NotFound = components?.TweetNotFound || TweetNotFound
return <NotFound />
}
return <MyTweet tweet={tweet} components={components} />
}
const CustomTweet = ({
fallback = <TweetSkeleton />,
...props
}: {
components: TwitterComponents
tweet: Tweet
fallback?: React.ReactNode
}) => (
<Suspense fallback={fallback}>
<TweetContent {...props} />
</Suspense>
)
export const TweetCard = ({
data,
activeMemories,
onDelete,
}: {
data: Tweet
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
onDelete?: () => void
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false)
return (
<div
className="relative transition-all group"
style={{
backgroundColor: getPastelBackgroundColor(data.id_str || "tweet"),
}}
>
<CustomTweet components={{}} tweet={data} />
{onDelete && (
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<button
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
}}
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(4px)",
}}
type="button"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document and all its
related memories? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{activeMemories && activeMemories.length > 0 && (
<div className="absolute bottom-2 left-4 z-10">
<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>
</div>
)}
</div>
)
}
TweetCard.displayName = "TweetCard"

View file

@ -1,174 +0,0 @@
"use client"
import { Card, CardContent } from "@repo/ui/components/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { ExternalLink, Trash2 } from "lucide-react"
import { useState } from "react"
import { cn } from "@lib/utils"
import { getPastelBackgroundColor } from "../memories-utils"
import { colors } from "@repo/ui/memory-graph/constants"
interface WebsiteCardProps {
title: string
url: string
image?: string
description?: string
className?: string
onClick?: () => void
onOpenDetails?: () => void
onDelete?: () => void
showExternalLink?: boolean
}
export const WebsiteCard = ({
title,
url,
image,
description,
className,
onClick,
onOpenDetails,
onDelete,
showExternalLink = true,
}: WebsiteCardProps) => {
const [imageError, setImageError] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleCardClick = () => {
if (!isDialogOpen) {
if (onClick) {
onClick()
} else if (onOpenDetails) {
onOpenDetails()
} else {
window.open(url, "_blank", "noopener,noreferrer")
}
}
}
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(url, "_blank", "noopener,noreferrer")
}
const hostname = (() => {
try {
return new URL(url).hostname
} catch {
return url
}
})()
return (
<Card
className={cn(
"cursor-pointer transition-all hover:shadow-md group overflow-hidden py-0 relative",
className,
)}
onClick={handleCardClick}
style={{
backgroundColor: getPastelBackgroundColor(url || title || "website"),
}}
>
{onDelete && (
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<button
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
}}
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(4px)",
}}
type="button"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document and all its
related memories? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<CardContent className="p-0">
{image && !imageError && (
<div className="relative h-38 bg-gray-100 overflow-hidden">
<img
src={image}
alt={title || "Website preview"}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
)}
<div className="px-4 py-2 space-y-2">
<div className="font-semibold text-sm line-clamp-2 leading-tight flex items-center justify-between">
{title}
<div className="flex items-center gap-1">
{showExternalLink && (
<button
onClick={handleExternalLinkClick}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-100 flex-shrink-0"
type="button"
aria-label="Open in new tab"
>
<ExternalLink className="w-3 h-3" />
</button>
)}
</div>
</div>
{description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{description}
</p>
)}
<p className="text-xs text-muted-foreground truncate">{hostname}</p>
</div>
</CardContent>
</Card>
)
}
WebsiteCard.displayName = "WebsiteCard"

View file

@ -1,114 +0,0 @@
"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 { Loader2 } from "lucide-react"
import { AnimatePresence, motion } 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(
{ name: projectName },
{
onSuccess: () => {
handleClose()
},
},
)
}
}
return (
<AnimatePresence>
{open && (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-2xl backdrop-blur-xl">
<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>
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 }}
>
<Input
id="projectName"
onChange={(e) => setProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && projectName.trim()) {
handleCreate()
}
}}
placeholder="My Awesome Project"
value={projectName}
/>
<p className="text-xs">
This will help you organize your memories
</p>
</motion.div>
</div>
<DialogFooter>
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button
disabled={
createProjectMutation.isPending || !projectName.trim()
}
onClick={handleCreate}
type="button"
>
{createProjectMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
"Create Project"
)}
</Button>
</DialogFooter>
</motion.div>
</DialogContent>
</Dialog>
)}
</AnimatePresence>
)
}

View file

@ -1,37 +0,0 @@
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>
)
}

View file

@ -1,103 +0,0 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { useState } from "react"
import { MemoryGraph } from "@/components/new/memory-graph/memory-graph"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddMemoryView } from "@/components/views/add-memory"
import { useChatOpen, useProject, useGraphModal } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { useIsMobile } from "@hooks/use-mobile"
/**
* Graph Dialog component
*/
export function GraphDialog() {
const { user } = useAuth()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const { selectedProject } = useProject()
const { isOpen: isChatOpen } = useChatOpen()
const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
useGraphModal()
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
if (!user) return null
// Convert selectedProject to containerTags array
const containerTags = selectedProject ? [selectedProject] : undefined
return (
<>
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
<DialogContent
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
showCloseButton={true}
>
<DialogTitle className="sr-only">Memory Graph</DialogTitle>
<div className="w-full h-full">
<MemoryGraph
containerTags={containerTags}
variant="console"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isChatOpen}
>
<div className="absolute inset-0 flex items-center justify-center">
{!isMobile ? (
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<div className="flex flex-col gap-3">
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
</ConnectAIModal>
) : (
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<div className="flex flex-col gap-3">
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
)}
</div>
</MemoryGraph>
</div>
</DialogContent>
</Dialog>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</>
)
}

View file

@ -1,446 +0,0 @@
import { Button } from "@ui/components/button"
import { Logo, LogoFull } from "@ui/assets/Logo"
import Link from "next/link"
import {
MoonIcon,
Plus,
SunIcon,
MonitorIcon,
User,
CreditCard,
Chrome,
LogOut,
WaypointsIcon,
Gauge,
HistoryIcon,
Trash2,
X,
Check,
} from "lucide-react"
import {
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@ui/components/dropdown-menu"
import { DropdownMenuItem } from "@ui/components/dropdown-menu"
import { DropdownMenu } from "@ui/components/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useAuth } from "@lib/auth-context"
import { ConnectAIModal } from "./connect-ai-modal"
import { useTheme } from "next-themes"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { MCPIcon } from "./menu"
import { authClient } from "@lib/auth"
import { analytics } from "@/lib/analytics"
import { useGraphModal, usePersistentChat, useProject } from "@/stores"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/components/dialog"
import { ScrollArea } from "@ui/components/scroll-area"
import { formatDistanceToNow } from "date-fns"
import { cn } from "@lib/utils"
import { useEffect, useMemo, useState } from "react"
import { generateId } from "@lib/generate-id"
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
const { user } = useAuth()
const searchParams = useSearchParams()
const { theme, setTheme } = useTheme()
const router = useRouter()
const { setIsOpen: setGraphModalOpen } = useGraphModal()
const {
getCurrentChat,
conversations,
currentChatId,
setCurrentChatId,
deleteConversation,
} = usePersistentChat()
const { selectedProject } = useProject()
const pathname = usePathname()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(
null,
)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [mcpInitialClient, setMcpInitialClient] = useState<"mcp-url" | null>(
null,
)
const [mcpInitialTab, setMcpInitialTab] = useState<
"oneClick" | "manual" | null
>(null)
const sorted = useMemo(() => {
return [...conversations].sort((a, b) =>
a.lastUpdated < b.lastUpdated ? 1 : -1,
)
}, [conversations])
useEffect(() => {
const mcpParam = searchParams.get("mcp")
if (mcpParam === "manual") {
setMcpInitialClient("mcp-url")
setMcpInitialTab("manual")
setMcpModalOpen(true)
const newSearchParams = new URLSearchParams(searchParams.toString())
newSearchParams.delete("mcp")
const newUrl = `${
window.location.pathname
}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
window.history.replaceState({}, "", newUrl)
}
}, [searchParams])
function handleNewChat() {
analytics.newChatStarted()
const newId = generateId()
setCurrentChatId(newId)
router.push(`/chat/${newId}`)
setIsDialogOpen(false)
}
function formatRelativeTime(isoString: string): string {
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
}
const handleSignOut = () => {
analytics.userSignedOut()
authClient.signOut()
router.push("/login")
}
return (
<div className="flex items-center justify-between w-full p-3 md:p-4">
<div className="flex items-center gap-2 md:gap-3 justify-between w-full">
<div className="flex items-center gap-1.5 md:gap-2">
<Link
className="pointer-events-auto"
href={
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.supermemory.ai"
}
rel="noopener noreferrer"
>
{getCurrentChat()?.title && pathname.includes("/chat") ? (
<div className="flex items-center gap-2 md:gap-4 min-w-0 max-w-[200px] md:max-w-md">
<Logo className="h-6 block text-foreground shrink-0" />
<span className="truncate text-sm md:text-base">
{getCurrentChat()?.title}
</span>
</div>
) : (
<>
<LogoFull className="h-8 hidden md:block" />
<Logo className="h-8 md:hidden text-foreground" />
</>
)}
</Link>
</div>
<div className="flex items-center gap-1.5 md:gap-2">
<Button
variant="secondary"
size="sm"
onClick={onAddMemory}
className="gap-1.5"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Add Memory</span>
<span className="hidden md:inline bg-secondary-foreground/10 rounded-md px-2 py-[2px] text-xs">
c
</span>
</Button>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open)
if (open) {
analytics.chatHistoryViewed()
}
if (!open) {
setConfirmingDeleteId(null)
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<HistoryIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Chat History</p>
</TooltipContent>
</Tooltip>
<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 (
<button
key={c.id}
type="button"
onClick={() => {
setCurrentChatId(c.id)
router.push(`/chat/${c.id}`)
setIsDialogOpen(false)
setConfirmingDeleteId(null)
}}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 outline-none w-full text-left",
"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>
{confirmingDeleteId === c.id ? (
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
onClick={(e) => {
e.stopPropagation()
analytics.chatDeleted()
deleteConversation(c.id)
setConfirmingDeleteId(null)
}}
className="bg-red-500 text-white hover:bg-red-600 hover:text-white"
aria-label="Confirm delete"
>
<Check className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(null)
}}
aria-label="Cancel delete"
>
<X className="size-4 text-muted-foreground" />
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(c.id)
}}
aria-label="Delete conversation"
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
)}
</button>
)
})}
{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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setGraphModalOpen(true)}
>
<WaypointsIcon className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Graph View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<ConnectAIModal
open={mcpModalOpen}
onOpenChange={setMcpModalOpen}
openInitialClient={mcpInitialClient}
openInitialTab={mcpInitialTab}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 hidden md:block"
onClick={() => setMcpModalOpen(true)}
>
<MCPIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ConnectAIModal>
<TooltipContent>
<p>Connect to AI (MCP)</p>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="mr-2 md:mr-4 px-2 w-56">
<DropdownMenuLabel>
<div>
<p className="text-sm font-medium">{user?.name}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/settings")}>
<User className="h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings/billing")}
>
<CreditCard className="h-4 w-4" />
Billing
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings/integrations")}
>
<Gauge className="h-4 w-4" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
"noopener,noreferrer",
)
}}
>
<Chrome className="h-4 w-4" />
Chrome Extension
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between p-2 cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(e) => e.preventDefault()}
>
<span className="text-sm font-medium">Theme</span>
<div className="flex items-center gap-1 bg-accent rounded-full">
<Button
variant={theme === "system" ? "default" : "ghost"}
size="sm"
className={cn(
"h-6 w-6 rounded-full group hover:cursor-pointer",
)}
onClick={() => setTheme("system")}
title="System"
>
<MonitorIcon
className={cn(
theme === "system"
? "text-primary-foreground"
: "text-muted-foreground",
"h-3 w-3 group-hover:text-foreground",
)}
/>
</Button>
<Button
variant={theme === "light" ? "default" : "ghost"}
size="sm"
className={cn(
"h-6 w-6 rounded-full group hover:cursor-pointer",
)}
onClick={() => setTheme("light")}
title="Light"
>
<SunIcon
className={cn(
theme === "light"
? "text-primary-foreground"
: "text-muted-foreground",
"h-3 w-3 group-hover:text-foreground",
)}
/>
</Button>
<Button
variant={theme === "dark" ? "default" : "ghost"}
size="sm"
className={cn(
"h-6 w-6 rounded-full group hover:cursor-pointer",
)}
onClick={() => setTheme("dark")}
title="Dark"
>
<MoonIcon
className={cn(
theme === "dark"
? "text-primary-foreground"
: "text-muted-foreground",
"h-3 w-3 group-hover:text-foreground",
)}
/>
</Button>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleSignOut()}>
<LogOut className="h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}

View file

@ -1,132 +0,0 @@
import { Button } from "@repo/ui/components/button"
import { Download, Share, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
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>
)
}

View file

@ -1,303 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { colors } from "@repo/ui/memory-graph/constants"
import { Sparkles } from "lucide-react"
import { Masonry, useInfiniteLoader } from "masonic"
import { memo, useCallback, useMemo, useState } from "react"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
import { MemoryDetail } from "./memories-utils/memory-detail"
import { TweetCard } from "./content-cards/tweet"
import { WebsiteCard } from "./content-cards/website"
import { NoteCard } from "./content-cards/note"
import { GoogleDocsCard } from "./content-cards/google-docs"
import type { Tweet } from "react-tweet/api"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface MasonryMemoryListProps {
children?: React.ReactNode
documents: DocumentWithMemories[]
isLoading: boolean
isLoadingMore: boolean
error: Error | null
totalLoaded: number
hasMore: boolean
loadMoreDocuments: () => Promise<void>
}
const DocumentCard = memo(
({
index: _index,
data: document,
width,
onOpenDetails,
onDelete,
}: {
index: number
data: DocumentWithMemories & { ogImage?: string }
width: number
onOpenDetails: (document: DocumentWithMemories) => void
onDelete: (document: DocumentWithMemories) => void
}) => {
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten,
)
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
document.url?.includes("docs.google.com/document") ||
document.type === "google_doc"
) {
return (
<GoogleDocsCard
url={document.url}
title={document.title || "Untitled Document"}
description={document.content}
activeMemories={activeMemories}
lastModified={document.updatedAt || document.createdAt}
onDelete={() => onDelete(document)}
/>
)
}
if (
document.url?.includes("x.com/") &&
document.metadata?.sm_internal_twitter_metadata
) {
return (
<TweetCard
data={
document.metadata?.sm_internal_twitter_metadata as unknown as Tweet
}
activeMemories={activeMemories}
onDelete={() => onDelete(document)}
/>
)
}
// Check if this is a website document saved from the Chrome extension
const websiteUrl =
(document.metadata?.website_url as string | undefined) ||
(document.url?.includes("https://") ? document.url : undefined)
if (websiteUrl) {
return (
<WebsiteCard
url={websiteUrl}
title={
(document.metadata?.website_title as string | undefined) ||
document.title ||
"Untitled Document"
}
image={
(document.metadata?.website_og_image as string | undefined) ||
document.ogImage
}
description={
document.content && typeof document.content === "string"
? document.content
: undefined
}
onOpenDetails={() => onOpenDetails(document)}
onDelete={() => onDelete(document)}
/>
)
}
return (
<NoteCard
document={document}
width={width}
activeMemories={activeMemories}
forgottenMemories={forgottenMemories}
onOpenDetails={onOpenDetails}
onDelete={onDelete}
/>
)
},
)
DocumentCard.displayName = "DocumentCard"
export const MasonryMemoryList = ({
children,
documents,
isLoading,
isLoadingMore,
error,
hasMore,
loadMoreDocuments,
}: MasonryMemoryListProps) => {
const [selectedSpace, _] = useState<string>("all")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const deleteDocumentMutation = useDeleteDocument(selectedProject)
const handleDeleteDocument = useCallback(
(document: DocumentWithMemories) => {
deleteDocumentMutation.mutate(document.id)
},
[deleteDocumentMutation],
)
// 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,
),
}))
}, [documents, selectedSpace])
const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
analytics.memoryDetailOpened()
setSelectedDocument(document)
setIsDetailOpen(true)
}, [])
const handleCloseDetails = useCallback(() => {
setIsDetailOpen(false)
setTimeout(() => setSelectedDocument(null), 300)
}, [])
// Infinite loading with Masonic
const maybeLoadMore = useInfiniteLoader(
async (_startIndex, _stopIndex, _currentItems) => {
if (hasMore && !isLoadingMore) {
await loadMoreDocuments()
}
},
{
isItemLoaded: (index, items) => !!items[index],
minimumBatchSize: 10,
threshold: 5,
},
)
const renderDocumentCard = useCallback(
({
index,
data,
width,
}: {
index: number
data: DocumentWithMemories
width: number
}) => (
<DocumentCard
index={index}
data={data}
width={width}
onOpenDetails={handleOpenDetails}
onDelete={handleDeleteDocument}
/>
),
[handleOpenDetails, handleDeleteDocument],
)
return (
<>
<div className="h-full relative pt-10">
{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 overflow-auto px-4 pt-4">
<div
className={`grid gap-4 ${isMobile ? "grid-cols-1" : "grid-cols-2 md:grid-cols-3 lg:grid-cols-4"}`}
>
{Array.from({ length: 8 }, (_, i) => ({
id: `skeleton-${Math.random()}-${i}`,
height: 100 + (i % 3),
})).map((item) => (
<div
key={item.id}
className="rounded-xl border border-gray-200 dark:border-gray-800 p-4 animate-pulse"
style={{ height: `${item.height}px` }}
>
<div className="flex flex-col gap-3 h-full">
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
<div className="h-3 bg-gray-200 dark:bg-gray-800 rounded w-full" />
<div className="h-3 bg-gray-200 dark:bg-gray-800 rounded w-5/6" />
<div className="mt-auto flex gap-2">
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-800 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-800 rounded-full" />
</div>
</div>
</div>
))}
</div>
</div>
) : filteredDocuments.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center p-4">
{children}
</div>
) : (
<div
className="h-full overflow-auto custom-scrollbar sm-tweet-theme"
data-theme="light"
>
<Masonry
key={`masonry-${filteredDocuments.length}-${filteredDocuments.map((d) => d.id).join(",")}`}
items={filteredDocuments}
render={renderDocumentCard}
columnGutter={16}
rowGutter={16}
columnWidth={280}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
className="px-4"
/>
{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>
<MemoryDetail
document={selectedDocument}
isOpen={isDetailOpen}
onClose={handleCloseDetails}
isMobile={isMobile}
/>
</>
)
}

View file

@ -1,205 +0,0 @@
import { memo, useMemo } from "react"
import DOMPurify from "dompurify"
import ReactMarkdown from "react-markdown"
import type { Components } from "react-markdown"
interface HTMLContentRendererProps {
content: string
className?: string
}
/**
* Detects if content is likely HTML based on common HTML patterns
*/
const isHTMLContent = (content: string): boolean => {
// Check for HTML tags, entities, and DOCTYPE
const htmlPatterns = [
/<[a-z][\s\S]*>/i, // HTML tags
/&[a-z]+;/i, // HTML entities
/<!doctype\s+html/i, // DOCTYPE declaration
/<\/[a-z]+>/i, // Closing tags
]
return htmlPatterns.some((pattern) => pattern.test(content))
}
export const HTMLContentRenderer = memo(
({ content, className = "" }: HTMLContentRendererProps) => {
const { isHTML, isMarkdown, processedContent } = useMemo(() => {
const contentIsHTML = isHTMLContent(content)
if (contentIsHTML) {
return {
isHTML: true,
isMarkdown: false,
processedContent: DOMPurify.sanitize(content),
}
}
let processed = content
if (content.includes("\n$ ")) {
processed = content.replace(/^\$ (.*$)/gm, "```bash\n$ $1\n```")
}
if (
content.trim().startsWith("{") &&
content.includes('"') &&
content.includes(":")
) {
const lines = content.split("\n")
let inJsonBlock = false
const jsonLines: string[] = []
const otherLines: string[] = []
for (const line of lines) {
if (line.trim() === "{" || line.trim() === "[") {
inJsonBlock = true
}
if (inJsonBlock) {
jsonLines.push(line)
if (line.trim() === "}" || line.trim() === "]") {
inJsonBlock = false
}
} else {
otherLines.push(line)
}
}
if (jsonLines.length > 0 && jsonLines.join("\n").trim()) {
const jsonBlock = jsonLines.join("\n")
const otherContent = otherLines.join("\n")
processed =
otherContent +
(otherContent ? "\n\n" : "") +
"```json\n" +
jsonBlock +
"\n```"
}
}
return {
isHTML: false,
isMarkdown: true,
processedContent: processed,
}
}, [content])
if (isHTML) {
return (
<div
className={`${className} bg-background`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
)
}
if (isMarkdown) {
try {
const components: Components = {
h1: ({ children }) => (
<h1 className="text-foreground text-lg font-semibold mb-1.5">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-foreground text-base font-semibold mb-1.5">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-foreground text-sm font-semibold mb-1">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-foreground text-sm font-medium mb-1">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="text-foreground text-sm font-medium mb-1">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="text-foreground text-sm font-medium mb-1">
{children}
</h6>
),
p: ({ children }) => (
<p className="text-foreground text-sm leading-relaxed mb-1.5">
{children}
</p>
),
strong: ({ children }) => (
<strong className="text-foreground font-semibold">
{children}
</strong>
),
em: ({ children }) => (
<em className="text-foreground italic">{children}</em>
),
code: ({ children, className }) => (
<code
className={`text-foreground bg-muted px-1.5 py-0.5 rounded text-xs font-mono ${className || ""}`}
>
{children}
</code>
),
pre: ({ children }) => (
<pre className="text-foreground bg-muted border border-border p-2 rounded text-xs overflow-x-auto mb-2 whitespace-pre font-mono leading-tight">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="text-muted-foreground border-l-4 border-muted-foreground pl-3 italic mb-2">
{children}
</blockquote>
),
a: ({ children, href }) => (
<a
href={href}
className="text-primary hover:text-primary/80 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="text-foreground text-sm mb-2 ml-4 list-disc">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-foreground text-sm mb-2 ml-4 list-decimal">
{children}
</ol>
),
li: ({ children }) => <li className="mb-1">{children}</li>,
}
return (
<div className={`${className} bg-background`}>
<ReactMarkdown components={components}>
{processedContent}
</ReactMarkdown>
</div>
)
} catch {
return (
<p
className={`text-sm leading-relaxed whitespace-pre-wrap text-foreground ${className}`}
>
{processedContent}
</p>
)
}
}
},
)
HTMLContentRenderer.displayName = "HTMLContentRenderer"

View file

@ -1,112 +0,0 @@
import type { DocumentWithMemories } from "@ui/memory-graph/types"
export 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] || "th")
}
const formattedDay = getOrdinalSuffix(day)
if (dateYear !== currentYear) {
return `${month} ${formattedDay}, ${dateYear}`
}
return `${month} ${formattedDay}`
}
export 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}`
}
if (document.metadata?.website_url) {
return document.metadata?.website_url as string
}
// Fallback to existing URL for all other document types
return document.url
}
// Simple hash function for consistent color generation
const hashString = (str: string): number => {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32-bit integer
}
return Math.abs(hash)
}
// Generate consistent pastel background color based on document ID
export const getPastelBackgroundColor = (
documentId: string | undefined | null,
): string => {
// Handle null/undefined cases
if (!documentId) {
return "rgba(255, 255, 255, 0.06)" // Default fallback color
}
const hash = hashString(documentId)
// Define pastel color palette with good contrast against dark backgrounds
const pastelColors = [
// Soft pinks and roses
"rgba(255, 182, 193, 0.08)", // Light pink
"rgba(255, 218, 221, 0.08)", // Misty rose
"rgba(255, 192, 203, 0.08)", // Pink
// Soft blues and purples
"rgba(173, 216, 230, 0.08)", // Light blue
"rgba(221, 160, 221, 0.08)", // Plum
"rgba(218, 112, 214, 0.08)", // Orchid
"rgba(147, 197, 253, 0.08)", // Sky blue
// Soft greens
"rgba(152, 251, 152, 0.08)", // Pale green
"rgba(175, 238, 238, 0.08)", // Pale turquoise
"rgba(144, 238, 144, 0.08)", // Light green
// Soft oranges and yellows
"rgba(255, 218, 185, 0.08)", // Peach puff
"rgba(255, 239, 213, 0.08)", // Papaya whip
"rgba(255, 228, 196, 0.08)", // Bisque
// Soft corals and salmons
"rgba(250, 128, 114, 0.08)", // Salmon
"rgba(255, 127, 80, 0.08)", // Coral
"rgba(255, 160, 122, 0.08)", // Light salmon
]
// Use hash to consistently pick a color
const colorIndex = hash % pastelColors.length
return pastelColors[colorIndex] || "rgba(255, 255, 255, 0.06)"
}

View file

@ -1,387 +0,0 @@
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@repo/ui/components/drawer"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { Badge } from "@ui/components/badge"
import {
Brain,
Calendar,
ChevronDown,
ChevronUp,
CircleUserRound,
ExternalLink,
List,
Sparkles,
} from "lucide-react"
import { memo, useState } from "react"
import type { z } from "zod"
import { formatDate, getSourceUrl } from "."
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { HTMLContentRenderer } from "./html-content-renderer"
import { Button } from "@ui/components/button"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type MemoryEntry = DocumentWithMemories["memoryEntries"][0]
const formatDocumentType = (type: string) => {
if (type.toLowerCase() === "pdf") return "PDF"
return type
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ")
}
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<div
className={`p-2.5 md:p-4 rounded-lg md:rounded-xl border transition-all relative overflow-hidden group ${
memory.isLatest
? "bg-card shadow-sm hover:shadow-md border-primary/30"
: "bg-card/50 shadow-xs hover:shadow-sm border-border/60 hover:border-border"
}`}
>
<div className="flex items-start gap-2 md:gap-3 relative z-10">
<div className="flex-1 space-y-1.5 md:space-y-3">
<Label1Regular className="text-xs md:text-sm leading-relaxed text-left text-card-foreground">
{memory.memory}
</Label1Regular>
<div className="flex gap-1 md:gap-2 justify-between items-center flex-wrap">
<div className="flex items-center gap-2 md:gap-3 text-[10px] md:text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3 md:w-3.5 md:h-3.5" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono bg-muted/30 px-1 md:px-1.5 py-0.5 rounded text-[9px] md:text-[10px]">
v{memory.version}
</span>
{memory.sourceRelevanceScore && (
<span
className={`flex items-center gap-1 font-medium ${
memory.sourceRelevanceScore > 70
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
}`}
>
<Sparkles className="w-3.5 h-3.5" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-[9px] md:text-[10px] h-4 md:h-5"
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-[9px] md:text-[10px] h-4 md:h-5 bg-primary/15 text-primary border-primary/30"
variant="outline"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-[9px] md:text-[10px] h-4 md:h-5 text-amber-600 dark:text-amber-500 bg-amber-500/10 border-amber-500/30"
variant="outline"
>
<span className="hidden sm:inline">
Expires {formatDate(memory.forgetAfter)}
</span>
<span className="sm:hidden">Expires</span>
</Badge>
)}
</div>
</div>
</div>
</div>
</div>
)
})
export const MemoryDetail = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null
isOpen: boolean
onClose: () => void
isMobile: boolean
}) => {
if (!document) return null
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten,
)
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof DialogTitle | typeof DrawerTitle
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-end gap-2 md:gap-3 flex-1 min-w-0">
<div className="p-1.5 md:p-2 rounded-lg bg-muted/10 flex-shrink-0">
{getDocumentIcon(
document.type,
"w-4 h-4 md:w-5 md:h-5 text-foreground",
document.source ?? undefined,
document.url ?? undefined,
)}
</div>
<div className="flex-1 min-w-0">
<TitleComponent className="text-foreground text-sm md:text-base truncate text-left">
{document.title || "Untitled Document"}
</TitleComponent>
<div className="flex items-center gap-1.5 md:gap-2 mt-1 text-[10px] md:text-xs text-muted-foreground flex-wrap">
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
</div>
</div>
{(document.url || document.metadata?.website_url) && (
<div className="flex items-end">
<Button
onClick={() => {
const sourceUrl = getSourceUrl(document)
window.open(sourceUrl ?? undefined, "_blank")
}}
variant="secondary"
size="sm"
>
<span className="hidden sm:inline">visit source</span>
<span className="sm:hidden">Source</span>
<ExternalLink className="w-2.5 h-2.5 md:w-3 md:h-3" />
</Button>
</div>
)}
</div>
</div>
)
const ContentDisplaySection = () => {
const hasContent = document.content && document.content.trim().length > 0
if (!hasContent) {
return (
<div className="text-center py-12 rounded-lg bg-muted/5">
<CircleUserRound className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" />
<p className="text-muted-foreground">
No content available for this document
</p>
</div>
)
}
return (
<div className="p-3 md:p-4 rounded-lg bg-muted/5 border border-border h-full overflow-y-auto max-w-3xl">
<HTMLContentRenderer content={document.content || ""} />
</div>
)
}
const SummaryDisplaySection = () => {
const hasSummary = document.summary && document.summary.trim().length > 0
if (!hasSummary) {
return (
<div className="text-center py-6 rounded-lg bg-muted/5">
<List className="w-6 h-6 mx-auto mb-2 opacity-30 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
No summary available
</p>
</div>
)
}
return (
<div className="p-2.5 md:p-3 px-3 md:px-4 rounded-lg bg-primary/5 border border-primary/15">
<p className="text-xs md:text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{document.summary}
</p>
</div>
)
}
const MemoryContent = () => (
<div className="space-y-6">
{activeMemories.length > 0 && (
<div>
<div className="text-sm font-medium flex items-start gap-2 pb-2 text-muted-foreground">
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3 max-h-[80vh] overflow-y-auto custom-scrollbar">
{activeMemories.map((memory) => (
<div key={memory.id}>
<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 text-muted-foreground bg-muted/5">
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 bg-muted/5">
<Brain className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" />
<p className="text-muted-foreground">
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-[95vh] bg-background border-t border-border backdrop-blur-xl flex flex-col">
<div className="p-3 md:p-4 relative bg-muted/5 flex-shrink-0">
<DrawerHeader className="p-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
</div>
<div className="flex-1 overflow-y-auto">
<div className="border-b border-border">
<div className="p-2.5 md:p-3 bg-muted/5">
<h4 className="text-sm md:text-base font-medium text-foreground">
Content
</h4>
</div>
<div className="p-3 md:p-4 m-4">
<ContentDisplaySection />
</div>
</div>
<div className="border-b border-border">
<div className="p-2.5 md:p-3 bg-muted/5">
<h4 className="text-sm md:text-base font-medium text-foreground">
Summary
</h4>
</div>
<div className="p-3 md:p-4">
<SummaryDisplaySection />
</div>
</div>
<div>
<div className="px-2.5 pt-2.5 md:p-3 bg-muted/5">
<h4 className="text-sm md:text-base font-medium text-foreground">
Memories
</h4>
</div>
<div className="p-3 md:p-4">
<MemoryContent />
</div>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog onOpenChange={onClose} open={isOpen}>
<DialogContent className="w-[95vw] md:w-[90vw] lg:w-[85vw] h-[90vh] border-0 p-0 overflow-hidden flex flex-col bg-background !max-w-7xl gap-0">
<div className="p-4 md:p-6 relative flex-shrink-0 bg-muted/5">
<DialogHeader className="pb-0">
<HeaderContent TitleComponent={DialogTitle} />
</DialogHeader>
</div>
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
<div className="flex-1 flex flex-col h-full justify-between min-w-0">
<div className="p-2 px-3 md:pl-4 overflow-y-auto custom-scrollbar transition-all duration-300">
<h3 className="font-medium text-[10px] md:text-sm text-muted-foreground pb-2 px-1">
Content
</h3>
<ContentDisplaySection />
</div>
<div className="transition-all duration-300 mx-2 mb-3 md:mb-4 flex-shrink-0">
<div className="bg-card border border-border rounded-xl shadow-lg backdrop-blur-sm h-full flex flex-col">
<button
onClick={() => setIsSummaryOpen(!isSummaryOpen)}
className="flex-shrink-0 w-full flex items-center justify-between p-3 md:p-4 hover:bg-muted/5 transition-colors rounded-t-xl"
type="button"
>
<div className="flex items-center gap-1.5 md:gap-2">
<h3 className="font-semibold text-xs md:text-sm text-foreground">
Summary
</h3>
{document.summary &&
document.summary.trim().length > 0 && (
<Badge
className="text-[10px] h-5"
variant="secondary"
>
Available
</Badge>
)}
</div>
{isSummaryOpen ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isSummaryOpen && (
<div className="flex-1 px-3 md:px-4 pb-3 md:pb-4 overflow-hidden min-h-0">
<div className="h-full overflow-y-auto custom-scrollbar">
<SummaryDisplaySection />
</div>
</div>
)}
</div>
</div>
</div>
<div className="w-full lg:w-96 flex flex-col border-t lg:border-t-0 border-border">
<div className="flex-1 flex flex-col">
<div className="flex-1 memory-dialog-scroll overflow-y-auto p-2 md:p-3">
<MemoryContent />
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
},
)

View file

@ -1,246 +0,0 @@
// apps/web/components/memories.tsx
"use client"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { MasonryMemoryList } from "@/components/masonry-memory-list"
import { AddMemoryView } from "@/components/views/add-memory"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { useIsMobile } from "@hooks/use-mobile"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function Memories() {
const { user } = useAuth()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const { selectedProject } = useProject()
const { isOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data
},
getNextPageParam: (lastPage, allPages) => {
if (!lastPage || !lastPage.pagination) return undefined
if (!Array.isArray(allPages)) return undefined
const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
)
if (loaded >= MAX_TOTAL) return undefined
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 5 * 60 * 1000,
enabled: !!user, // Only run query if user is authenticated
})
const baseDocuments = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])
const allDocuments = useMemo(() => {
if (injectedDocs.length === 0) return baseDocuments
const byId = new Map<string, DocumentWithMemories>()
for (const d of injectedDocs) byId.set(d.id, d)
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
return Array.from(byId.values())
}, [baseDocuments, injectedDocs])
const totalLoaded = allDocuments.length
const hasMore = hasNextPage
const isLoadingMore = isFetchingNextPage
const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
return
}
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
// Handle highlighted documents injection for chat
useEffect(() => {
if (!isOpen) return
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
const present = new Set<string>()
for (const d of [...baseDocuments, ...injectedDocs]) {
if (d.id) present.add(d.id)
if (d.customId) present.add(d.customId as string)
}
const missing = allHighlightDocumentIds.filter(
(id: string) => !present.has(id),
)
if (missing.length === 0) return
let cancelled = false
const run = async () => {
try {
const resp = await $fetch("@post/documents/documents/by-ids", {
body: {
ids: missing,
by: "customId",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (cancelled || resp?.error) return
const extraDocs = resp?.data?.documents as
| DocumentWithMemories[]
| undefined
if (!extraDocs || extraDocs.length === 0) return
setInjectedDocs((prev) => {
const seen = new Set<string>([
...prev.map((d) => d.id),
...baseDocuments.map((d) => d.id),
])
const merged = [...prev]
for (const doc of extraDocs) {
if (!seen.has(doc.id)) {
merged.push(doc)
seen.add(doc.id)
}
}
return merged
})
} catch {}
}
void run()
return () => {
cancelled = true
}
}, [
isOpen,
allHighlightDocumentIds,
baseDocuments,
injectedDocs,
selectedProject,
])
// Show connect AI modal if no documents
useEffect(() => {
if (allDocuments.length === 0 && !isMobile) {
setShowConnectAIModal(true)
}
}, [allDocuments.length, isMobile])
if (!user) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<p>Please log in to view your memories</p>
</div>
</div>
)
}
return (
<div className="relative h-full mx-4 md:mx-24">
<MasonryMemoryList
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
>
<div className="absolute inset-0 flex items-center justify-center">
{!isMobile ? (
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<div className="flex flex-col gap-3">
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
</ConnectAIModal>
) : (
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<div className="flex flex-col gap-3">
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation()
setShowAddMemoryView(true)
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
)}
</div>
</MasonryMemoryList>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</div>
)
}

View file

@ -1,396 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { cn } from "@lib/utils"
import { Badge } from "@repo/ui/components/badge"
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { colors } from "@repo/ui/memory-graph/constants"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { z } from "zod"
import useResizeObserver from "@/hooks/use-resize-observer"
import { analytics } from "@/lib/analytics"
import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
import { MemoryDetail } from "./memories-utils/memory-detail"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
import { formatDate, getSourceUrl } from "./memories-utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface MemoryListViewProps {
children?: React.ReactNode
documents: DocumentWithMemories[]
isLoading: boolean
isLoadingMore: boolean
error: Error | null
totalLoaded: number
hasMore: boolean
loadMoreDocuments: () => Promise<void>
}
const DocumentCard = memo(
({
document,
onOpenDetails,
onDelete,
}: {
document: DocumentWithMemories
onOpenDetails: (document: DocumentWithMemories) => void
onDelete: (document: DocumentWithMemories) => void
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false)
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 gap-2 md:w-full shadow-xs"
onClick={() => {
if (!isDialogOpen) {
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.content && (
<p
className="text-xs line-clamp-2 mb-3"
style={{ color: colors.text.muted }}
>
{document.content}
</p>
)}
<div className="flex items-center justify-between">
<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>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation()
}}
style={{
color: colors.text.muted,
}}
type="button"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document and all its
related memories? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={(e) => {
e.stopPropagation()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={(e) => {
e.stopPropagation()
onDelete(document)
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
)
},
)
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 { selectedProject } = useProject()
const deleteDocumentMutation = useDeleteDocument(selectedProject)
const gap = 14
const handleDeleteDocument = useCallback(
(document: DocumentWithMemories) => {
deleteDocumentMutation.mutate(document.id)
},
[deleteDocumentMutation],
)
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,
),
}))
}, [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" 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"
>
<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 sm-tweet-theme"
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
>
<div
className="grid justify-center"
style={{
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
gap: `${gap}px`,
}}
>
{rowItems.map((document, columnIndex) => (
<DocumentCard
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
document={document}
onOpenDetails={handleOpenDetails}
onDelete={handleDeleteDocument}
/>
))}
</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>
<MemoryDetail
document={selectedDocument}
isOpen={isDetailOpen}
onClose={handleCloseDetails}
isMobile={isMobile}
/>
</>
)
}

View file

@ -1,681 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { fetchApiProProduct, fetchMemoriesFeature } from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
import { ConnectAIModal } from "./connect-ai-modal"
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { useCustomer } from "autumn-js/react"
import { Plus, Puzzle, User, X } from "lucide-react"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { Drawer } from "vaul"
import { useMobilePanel } from "@/lib/mobile-panel-context"
import { useChatOpen } from "@/stores"
import { ProjectSelector } from "./project-selector"
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
export 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 router = useRouter()
const searchParams = useSearchParams()
const openParam = searchParams.get("open")
// Valid view names that can be opened via URL parameter
const validViews = [
"addUrl",
"mcp",
"projects",
"profile",
"integrations",
] as const
type ValidView = (typeof validViews)[number]
const [isHovered, setIsHovered] = useState(false)
const [expandedView, setExpandedView] = useState<
"addUrl" | "mcp" | "projects" | "profile" | "integrations" | null
>(null)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isCollapsing, setIsCollapsing] = useState(false)
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const { activePanel, setActivePanel } = useMobilePanel()
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
const { data: memoriesCheck } = fetchMemoriesFeature(
autumn,
!autumn.isLoading,
)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: proCheck } = fetchApiProProduct(autumn)
useEffect(() => {
if (memoriesCheck) {
console.log({ memoriesCheck })
}
if (proCheck) {
console.log({ proCheck })
}
}, [memoriesCheck, proCheck])
// Function to clear the 'open' parameter from URL
const clearOpenParam = useCallback(() => {
const newSearchParams = new URLSearchParams(searchParams.toString())
newSearchParams.delete("open")
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
router.replace(newUrl)
}, [searchParams, router])
const isProUser = proCheck?.allowed ?? false
const shouldShowLimitWarning =
!isProUser && memoriesUsed >= memoriesLimit * 0.8
const menuItems = [
{
icon: Plus,
text: "Add Memory",
key: "addUrl" as const,
disabled: false,
},
{
icon: Puzzle,
text: "Integrations",
key: "integrations" 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" | "integrations",
) => {
if (key === "chat") {
setIsOpen(true)
setIsMobileMenuOpen(false)
if (isMobile) {
setActivePanel("chat")
}
} else if (key === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
} else {
if (expandedView === key) {
setIsCollapsing(true)
setExpandedView(null)
} else if (key === "addUrl") {
setShowAddMemoryView(true)
setExpandedView(null)
} else {
setExpandedView(key)
}
if (isMobile) {
setActivePanel("menu")
}
}
}
// Handle initial view opening based on URL parameter
useEffect(() => {
if (openParam) {
if (openParam === "chat") {
setIsOpen(true)
setIsMobileMenuOpen(false)
if (isMobile) {
setActivePanel("chat")
}
} else if (openParam === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
} else if (openParam === "addUrl") {
setShowAddMemoryView(true)
setExpandedView(null)
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
}
} else if (validViews.includes(openParam as ValidView)) {
// For other valid views like "profile", "integrations"
setExpandedView(openParam as ValidView)
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
}
}
// Clear the parameter from URL after performing any action
clearOpenParam()
}
}, [
openParam,
isMobile,
setIsOpen,
setActivePanel,
validViews,
clearOpenParam,
])
// 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])
// 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],
},
}}
>
{/* Menu content */}
<motion.div
className="relative z-20 flex flex-col gap-6 w-full bg-white"
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 transition-colors duration-100 cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
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 flex-shrink-0" />
</motion.div>
<motion.p
animate={{
opacity: isHovered ? 1 : 0,
x: isHovered ? 0 : -10,
}}
className="pl-3 whitespace-nowrap"
initial={{ opacity: 0, x: -10 }}
style={{
transform: "translateZ(0)",
}}
transition={{
duration: isHovered ? 0.2 : 0.1,
delay: isHovered ? index * 0.03 : 0,
ease: [0.4, 0, 0.2, 1],
}}
>
{item.text}
</motion.p>
</motion.button>
{index === 0 && (
<motion.div
animate={{
opacity: 1,
scaleX: 1,
}}
className="w-full h-px bg-black/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"}
{expandedView === "integrations" && "Integrations"}
</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 custom-scrollbar"
initial={{ opacity: 0, y: 10 }}
transition={{
delay: 0.1,
duration: 0.25,
ease: [0.4, 0, 0.2, 1],
}}
>
{expandedView === "profile" && <ProfileView />}
{expandedView === "integrations" && (
<IntegrationsView />
)}
</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">
<Drawer.Title className="sr-only">
{expandedView === "addUrl" && "Add Memory"}
{expandedView === "mcp" && "Model Context Protocol"}
{expandedView === "profile" && "Profile"}
{!expandedView && "Menu"}
</Drawer.Title>
<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-2 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"
initial={{ opacity: 0, y: 10 }}
layout
onClick={() => {
handleMenuItemClick(item.key)
if (
item.key !== "mcp" &&
item.key !== "profile" &&
item.key !== "integrations"
) {
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 px-2 flex flex-col"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="expanded-view-mobile"
layout
>
<div className="flex-1">
<motion.div
className="flex items-center justify-between"
layout
>
<HeadingH2Bold className="text-white">
{expandedView === "addUrl" && "Add Memory"}
{expandedView === "mcp" &&
"Model Context Protocol"}
{expandedView === "profile" && "Profile"}
{expandedView === "integrations" &&
"Integrations"}
</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 === "profile" && <ProfileView />}
{expandedView === "integrations" && (
<IntegrationsView />
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)}
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<Button className="hidden">Connect AI Assistant</Button>
</ConnectAIModal>
</>
)
}
export default Menu

View file

@ -1,92 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@repo/ui/components/button"
import { ChevronDown } from "lucide-react"
import { motion } from "motion/react"
import { models, type ModelId, ModelIcon } from "@/lib/models"
interface ModelSelectorProps {
selectedModel?: ModelId
onModelChange?: (modelId: ModelId) => void
disabled?: boolean
}
export function ModelSelector({
selectedModel = "gemini-2.5-pro",
onModelChange,
disabled = false,
}: ModelSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const currentModel = models.find((m) => m.id === selectedModel) || models[0]
const handleModelSelect = (modelId: ModelId) => {
onModelChange?.(modelId)
setIsOpen(false)
}
return (
<div className="relative">
<Button
type="button"
variant="ghost"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
<ModelIcon width={24} height={24} />
<span className="text-xs font-medium max-w-32 truncate">
{currentModel.name}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.25 }}
>
<ChevronDown className="h-3 w-3" />
</motion.div>
</Button>
{isOpen && (
<>
<button
type="button"
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
aria-label="Close model selector"
/>
<div className="absolute top-full left-0 mt-1 w-64 bg-background/95 backdrop-blur-xl border border-border rounded-md shadow-xl z-50 overflow-hidden space-y-1">
<div className="p-1.5 space-y-1">
{models.map((model) => (
<button
key={model.id}
type="button"
className={`flex items-center p-1 px-2 rounded-md transition-colors cursor-pointer w-full text-left ${
selectedModel === model.id
? "bg-accent"
: "hover:bg-accent/50"
}`}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{model.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{model.description}
</div>
</div>
</button>
))}
</div>
</div>
</>
)}
</div>
)
}

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useQueryState } from "nuqs"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
@ -17,6 +18,7 @@ import { useCustomer } from "autumn-js/react"
import { useMemoriesUsage } from "@/hooks/use-memories-usage"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
type TabType = "note" | "link" | "file" | "connect"
@ -91,7 +93,6 @@ const tabs = [
]
export function AddDocument({
defaultTab,
onClose,
isOpen,
}: {
@ -100,7 +101,12 @@ export function AddDocument({
isOpen?: boolean
}) {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState<TabType>(defaultTab ?? "note")
const [addParam, setAddParam] = useQueryState("add", addDocumentParam)
const activeTab: TabType = addParam ?? "note"
const setActiveTab = useCallback(
(tab: TabType) => { setAddParam(tab) },
[setAddParam],
)
const { selectedProject: globalSelectedProject } = useProject()
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
globalSelectedProject,
@ -136,12 +142,6 @@ export function AddDocument({
setLocalSelectedProject(globalSelectedProject)
}, [globalSelectedProject])
useEffect(() => {
if (defaultTab) {
setActiveTab(defaultTab)
}
}, [defaultTab])
// Submit handlers
const handleNoteSubmit = useCallback(
(content: string) => {

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
import { motion, AnimatePresence } from "motion/react"
import { useChat } from "@ai-sdk/react"
@ -40,9 +41,11 @@ import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
import { ChainOfThought } from "./input/chain-of-thought"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { generateId } from "@lib/generate-id"
import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
const DEFAULT_SUGGESTIONS = [
"Show me all content related to Supermemory.",
@ -137,7 +140,14 @@ export function ChatSidebar({
const messagesContainerRef = useRef<HTMLDivElement>(null)
const { selectedProject } = useProject()
const { viewMode } = useViewMode()
const [currentChatId, setCurrentChatId] = useState<string>(() => generateId())
const { user } = useAuth()
const [threadId, setThreadId] = useQueryState("thread", threadParam)
const fallbackChatId = useMemo(() => generateId(), [])
const currentChatId = threadId ?? fallbackChatId
const setCurrentChatId = useCallback(
(id: string) => setThreadId(id),
[setThreadId],
)
const [pendingThreadLoad, setPendingThreadLoad] = useState<{
id: string
messages: UIMessage[]
@ -167,13 +177,6 @@ export function ChatSidebar({
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
credentials: "include",
body: {
metadata: {
projectId: selectedProject,
model: selectedModel,
chatId: currentChatId,
},
},
}),
onFinish: async (result) => {
if (result.message.role !== "assistant") return
@ -373,11 +376,9 @@ export function ChatSidebar({
const handleNewChat = useCallback(() => {
analytics.newChatCreated()
const newId = generateId()
setCurrentChatId(newId)
setMessages([])
setThreadId(null)
setInput("")
}, [setMessages])
}, [setThreadId])
const fetchThreads = useCallback(async () => {
setIsLoadingThreads(true)
@ -397,37 +398,40 @@ export function ChatSidebar({
}
}, [selectedProject])
const loadThread = useCallback(async (threadId: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
{ credentials: "include" },
)
if (response.ok) {
const data = await response.json()
const uiMessages = data.messages.map(
(m: {
id: string
role: string
parts: unknown
createdAt: string
}) => ({
id: m.id,
role: m.role,
parts: m.parts || [],
createdAt: new Date(m.createdAt),
}),
const loadThread = useCallback(
async (id: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
{ credentials: "include" },
)
setCurrentChatId(threadId)
setPendingThreadLoad({ id: threadId, messages: uiMessages })
analytics.chatThreadLoaded({ thread_id: threadId })
setIsHistoryOpen(false)
setConfirmingDeleteId(null)
if (response.ok) {
const data = await response.json()
const uiMessages = data.messages.map(
(m: {
id: string
role: string
parts: unknown
createdAt: string
}) => ({
id: m.id,
role: m.role,
parts: m.parts || [],
createdAt: new Date(m.createdAt),
}),
)
setThreadId(id)
setPendingThreadLoad({ id, messages: uiMessages })
analytics.chatThreadLoaded({ thread_id: id })
setIsHistoryOpen(false)
setConfirmingDeleteId(null)
}
} catch (error) {
console.error("Failed to load thread:", error)
}
} catch (error) {
console.error("Failed to load thread:", error)
}
}, [])
},
[setThreadId],
)
const deleteThread = useCallback(
async (threadId: string) => {

View file

@ -1,11 +1,97 @@
"use client"
import { useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
ChevronDownIcon,
ChevronRightIcon,
Loader2,
SearchIcon,
PlusIcon,
BookOpenIcon,
ClockIcon,
ListIcon,
XCircleIcon,
WrenchIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
import { FollowUpQuestions } from "./follow-up-questions"
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
listSchedules: { label: "List Schedules", icon: ListIcon },
cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon },
}
function ToolCallDisplay({ part }: { part: { type: string; state: string; input?: unknown; output?: unknown; toolCallId?: string } }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
const meta = TOOL_META[toolName]
const Icon = meta?.icon ?? WrenchIcon
const label = meta?.label ?? toolName
const isLoading = part.state === "input-streaming" || part.state === "input-available"
const isDone = part.state === "output-available"
const isError = part.state === "error"
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<Icon className={cn("size-3 shrink-0", isDone ? "text-emerald-400" : isError ? "text-red-400" : "text-white/50")} />
)}
<span className={cn("font-medium", isDone ? "text-emerald-400" : isError ? "text-red-400" : "text-blue-400")}>
{label}
</span>
{isLoading && <span className="text-white/40 ml-auto">running...</span>}
{isDone && <span className="text-white/40 ml-auto">done</span>}
{isError && <span className="text-red-400/60 ml-auto">error</span>}
{expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
)}
</button>
{expanded && (
<div className="px-3 py-2 space-y-2">
{part.input !== undefined && (
<div>
<div className="text-white/40 mb-1">Input</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.input === "string" ? part.input : JSON.stringify(part.input, null, 2)}
</pre>
</div>
)}
{isDone && part.output !== undefined && (
<div>
<div className="text-white/40 mb-1">Output</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.output === "string" ? part.output : JSON.stringify(part.output, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
}
interface AgentMessageProps {
message: UIMessage
index: number
@ -68,20 +154,13 @@ export function AgentMessage({
</div>
)
}
if (part.type === "tool-searchMemories") {
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
return (
<div
key={`${message.id}-${partIndex}`}
className="text-xs text-white italic"
>
Searching memories...
</div>
)
}
if (part.type.startsWith("tool-")) {
return (
<ToolCallDisplay
key={`${message.id}-${partIndex}`}
part={part as any}
/>
)
}
return null
})}

View file

@ -2,7 +2,6 @@
import type React from "react"
import { useState } from "react"
import { MCPIcon } from "@/components/menu"
import {
GoogleDocs,
GoogleSheets,
@ -19,6 +18,22 @@ import {
import { Globe, FileText, Image } from "lucide-react"
import { cn } from "@lib/utils"
function 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>
)
}
const BRAND_COLORS: Record<string, string> = {
google_doc: "#4285F4",
google_sheet: "#0F9D58",

View file

@ -47,10 +47,10 @@ export function Summary({
<div
className={cn(
"flex items-center",
memoryEntries.length > 0 ? "justify-between" : "justify-end",
memoryEntries?.length > 0 ? "justify-between" : "justify-end",
)}
>
{memoryEntries.length > 0 && (
{memoryEntries?.length > 0 && (
<p
className={cn(
"text-[#369BFD] line-clamp-1 flex items-center gap-1.5",

View file

@ -1,24 +1,44 @@
"use client"
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { useState, useCallback, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type {
DocumentsWithMemoriesResponseSchema,
SearchResponseSchema,
} from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useIsMobile } from "@hooks/use-mobile"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { SearchIcon } from "lucide-react"
import {
SearchIcon,
Settings,
Home,
Plus,
Code2,
Loader2,
} from "lucide-react"
import { DocumentIcon } from "@/components/new/document-icon"
import { $fetch } from "@lib/api"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type SearchResult = z.infer<typeof SearchResponseSchema>["results"][number]
type PaletteItem =
| { kind: "action"; id: string; label: string; icon: React.ReactNode; action: () => void }
| { kind: "document"; doc: DocumentWithMemories }
| { kind: "search-result"; result: SearchResult }
interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
onOpenDocument: (document: DocumentWithMemories) => void
onAddMemory?: () => void
onOpenMCP?: () => void
initialSearch?: string
}
@ -27,17 +47,66 @@ export function DocumentsCommandPalette({
onOpenChange,
projectId,
onOpenDocument,
onAddMemory,
onOpenMCP,
initialSearch = "",
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
const router = useRouter()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
const [cachedDocs, setCachedDocs] = useState<DocumentWithMemories[]>([])
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortRef = useRef<AbortController | null>(null)
// Get documents from the existing query cache when dialog opens
const close = useCallback((then?: () => void) => {
onOpenChange(false)
setSearch("")
setSearchResults([])
if (then) setTimeout(then, 0)
}, [onOpenChange])
const actions: PaletteItem[] = [
{
kind: "action",
id: "home",
label: "Go to Home",
icon: <Home className="size-4 text-[#737373]" />,
action: () => close(() => router.push("/")),
},
{
kind: "action",
id: "settings",
label: "Go to Settings",
icon: <Settings className="size-4 text-[#737373]" />,
action: () => close(() => router.push("/settings")),
},
...(onAddMemory
? [{
kind: "action" as const,
id: "add-memory",
label: "Add Memory",
icon: <Plus className="size-4 text-[#737373]" />,
action: () => { close(); onAddMemory() },
}]
: []),
...(onOpenMCP
? [{
kind: "action" as const,
id: "mcp",
label: "Open MCP",
icon: <Code2 className="size-4 text-[#737373]" />,
action: () => { close(); onOpenMCP() },
}]
: []),
]
// Load cached docs when opening
useEffect(() => {
if (open) {
const queryData = queryClient.getQueryData<{
@ -46,63 +115,215 @@ export function DocumentsCommandPalette({
}>(["documents-with-memories", projectId])
if (queryData?.pages) {
setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? []))
}
setTimeout(() => inputRef.current?.focus(), 0)
setSearch(initialSearch)
setSelectedIndex(0)
setSearchResults([])
}
}, [open, queryClient, projectId, initialSearch])
const filteredDocuments = useMemo(() => {
if (!search.trim()) return documents
const searchLower = search.toLowerCase()
return documents.filter((doc) =>
doc.title?.toLowerCase().includes(searchLower),
)
}, [documents, search])
// Reset selection when filtered results change
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
setSelectedIndex(0)
}, [])
// Scroll selected item into view
// Debounced semantic search
useEffect(() => {
const selectedElement = listRef.current?.querySelector(
`[data-index="${selectedIndex}"]`,
)
selectedElement?.scrollIntoView({ block: "nearest" })
if (debounceRef.current) clearTimeout(debounceRef.current)
if (abortRef.current) abortRef.current.abort()
if (!search.trim()) {
setSearchResults([])
setIsSearching(false)
return
}
setIsSearching(true)
debounceRef.current = setTimeout(async () => {
const controller = new AbortController()
abortRef.current = controller
try {
const res = await $fetch("@post/search", {
body: {
q: search.trim(),
limit: 10,
containerTags: projectId ? [projectId] : undefined,
includeSummary: true,
},
signal: controller.signal,
})
if (!controller.signal.aborted && res.data) {
setSearchResults(res.data.results)
}
} catch {
// aborted or failed - ignore
} finally {
if (!controller.signal.aborted) setIsSearching(false)
}
}, 250)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [search, projectId])
// Build the item list
const hasQuery = search.trim().length > 0
const items: PaletteItem[] = []
if (hasQuery) {
for (const r of searchResults) {
items.push({ kind: "search-result", result: r })
}
const q = search.toLowerCase()
for (const a of actions) {
if (a.kind === "action" && a.label.toLowerCase().includes(q)) items.push(a)
}
} else {
for (const doc of cachedDocs.slice(0, 10)) items.push({ kind: "document", doc })
for (const a of actions) items.push(a)
}
// Reset selection on items change
useEffect(() => {
setSelectedIndex(0)
}, [search, searchResults.length])
// Scroll selected into view
useEffect(() => {
listRef.current
?.querySelector(`[data-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" })
}, [selectedIndex])
const handleSelect = useCallback(
(document: DocumentWithMemories) => {
if (!document.id) return
onOpenDocument(document)
onOpenChange(false)
setSearch("")
(item: PaletteItem) => {
if (item.kind === "action") {
item.action()
} else if (item.kind === "document") {
if (!item.doc.id) return
onOpenDocument(item.doc)
close()
} else {
// search result -> convert to DocumentWithMemories shape for the modal
onOpenDocument({
id: item.result.documentId,
title: item.result.title,
type: item.result.type,
createdAt: item.result.createdAt as unknown as string,
updatedAt: item.result.updatedAt as unknown as string,
url: (item.result.metadata?.url as string) ?? null,
content: item.result.content ?? item.result.chunks?.[0]?.content ?? null,
summary: item.result.summary ?? null,
} as unknown as DocumentWithMemories)
close()
}
},
[onOpenDocument, onOpenChange],
[onOpenDocument, close],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex((i) => (i < filteredDocuments.length - 1 ? i + 1 : i))
setSelectedIndex((i) => Math.min(i + 1, items.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex((i) => (i > 0 ? i - 1 : i))
setSelectedIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
const document = filteredDocuments[selectedIndex]
if (document) handleSelect(document)
const item = items[selectedIndex]
if (item) handleSelect(item)
}
},
[filteredDocuments, selectedIndex, handleSelect],
[items, selectedIndex, handleSelect],
)
function renderItem(item: PaletteItem, index: number) {
const isSelected = index === selectedIndex
const baseClass = cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
isSelected
? "bg-[#293952]/40"
: "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
)
if (item.kind === "action") {
return (
<button
key={item.id}
type="button"
data-index={index}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={baseClass}
>
<div className="flex items-center justify-center size-5 shrink-0">
{item.icon}
</div>
<p className="text-sm font-medium text-white">{item.label}</p>
</button>
)
}
const title =
item.kind === "document" ? item.doc.title : item.result.title
const type =
item.kind === "document" ? item.doc.type : item.result.type
const url =
item.kind === "document"
? item.doc.url
: (item.result.metadata?.url as string) ?? null
const date =
item.kind === "document"
? item.doc.createdAt
: item.result.createdAt
const key =
item.kind === "document" ? item.doc.id : item.result.documentId
const snippet =
item.kind === "search-result"
? item.result.chunks?.find((c) => c.isRelevant)?.content
: null
return (
<button
key={key}
type="button"
data-index={index}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={baseClass}
>
<div
className="flex items-center justify-center size-5 rounded-md shrink-0"
style={{
background: "linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
boxShadow:
"inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
}}
>
<DocumentIcon type={type} url={url} className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex gap-1 justify-between items-center">
<p className="text-sm font-medium text-white truncate">
{title || "Untitled"}
</p>
<p className="text-xs text-[#737373] text-nowrap">
{new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
</div>
{snippet && (
<p className="text-xs text-[#737373] truncate mt-0.5">
{snippet.slice(0, 120)}
</p>
)}
</div>
</button>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@ -120,19 +341,20 @@ export function DocumentsCommandPalette({
showCloseButton={false}
onKeyDown={handleKeyDown}
>
<DialogTitle className="sr-only">Search Documents</DialogTitle>
<DialogTitle className="sr-only">Search</DialogTitle>
<div
id="search-input-container"
className="flex items-center gap-3 px-4 py-3"
>
<SearchIcon className="size-4 text-[#737373] shrink-0" />
<div className="flex items-center gap-3 px-4 py-3">
{isSearching ? (
<Loader2 className="size-4 text-[#737373] shrink-0 animate-spin" />
) : (
<SearchIcon className="size-4 text-[#737373] shrink-0" />
)}
<input
ref={inputRef}
type="text"
placeholder="Search documents by title..."
placeholder="Type to search your memories..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
onChange={(e) => setSearch(e.target.value)}
className={cn(
"flex-1 bg-transparent text-white text-sm placeholder:text-[#737373] outline-none",
dmSansClassName(),
@ -142,66 +364,41 @@ export function DocumentsCommandPalette({
<div
ref={listRef}
id="search-results"
className="flex flex-col min-h-[300px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
className="flex flex-col min-h-[200px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
>
{filteredDocuments.length === 0 ? (
{!hasQuery && cachedDocs.length > 0 && (
<p className="px-3 pt-1 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
Recent
</p>
)}
{hasQuery && searchResults.length > 0 && (
<p className="px-3 pt-1 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
Results
</p>
)}
{items
.map((item, i) => ({ item, globalIndex: i }))
.filter(({ item }) => item.kind !== "action")
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
{items.some((i) => i.kind === "action") && (
<p className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
Actions
</p>
)}
{items
.map((item, i) => ({ item, globalIndex: i }))
.filter(({ item }) => item.kind === "action")
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
{hasQuery && !isSearching && searchResults.length === 0 && items.every((i) => i.kind === "action") && (
<div className="flex items-center justify-center py-12">
<p className="text-[#737373] text-sm">No documents found</p>
<p className="text-[#737373] text-sm">No results found</p>
</div>
) : (
filteredDocuments.map((doc, index) => {
const isSelected = index === selectedIndex
return (
<button
key={doc.id}
type="button"
data-index={index}
onClick={() => handleSelect(doc)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
isSelected
? "bg-[#293952]/40"
: "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<div
className="flex items-center justify-center size-5 rounded-md shrink-0"
style={{
background:
"linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
boxShadow:
"inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
}}
>
<DocumentIcon
type={doc.type}
url={doc.url}
className="size-4"
/>
</div>
<div className="flex-1 min-w-0 flex gap-1 justify-between items-center">
<p className="text-sm font-medium text-white truncate">
{doc.title || "Untitled"}
</p>
<p className="text-xs text-[#737373] text-nowrap">
{new Date(doc.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
</div>
</button>
)
})
)}
</div>
<div
id="search-footer"
className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]"
>
<div className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="flex gap-0.5">
@ -227,7 +424,9 @@ export function DocumentsCommandPalette({
<span>Close</span>
</span>
</div>
<span>{filteredDocuments.length} documents</span>
{hasQuery && searchResults.length > 0 && (
<span>{searchResults.length} results</span>
)}
</div>
</DialogContent>
</Dialog>

View file

@ -1,6 +1,7 @@
"use client"
import { memo, useState, useCallback, useRef } from "react"
import { memo, useCallback, useRef } from "react"
import { useQueryState } from "nuqs"
import Image from "next/image"
import { MemoryGraph } from "./memory-graph/memory-graph"
import { useProject } from "@/stores"
@ -9,6 +10,7 @@ import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { ShareModal } from "./share-modal"
import { shareParam } from "@/lib/search-params"
interface GraphLayoutViewProps {
isChatOpen: boolean
@ -17,18 +19,18 @@ interface GraphLayoutViewProps {
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
const { selectedProject } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useQueryState("share", shareParam)
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerTags = selectedProject ? [selectedProject] : undefined
const handleShare = useCallback(() => {
setIsShareModalOpen(true)
}, [])
}, [setIsShareModalOpen])
const handleCloseShareModal = useCallback(() => {
setIsShareModalOpen(false)
}, [])
}, [setIsShareModalOpen])
return (
<div className="relative w-full h-[calc(100vh-86px)]">

View file

@ -36,9 +36,10 @@ import Link from "next/link"
import { SpaceSelector } from "./space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useState } from "react"
import { FeedbackModal } from "./feedback-modal"
import { useViewMode } from "@/lib/view-mode-context"
import { useQueryState } from "nuqs"
import { feedbackParam } from "@/lib/search-params"
interface HeaderProps {
onAddMemory?: () => void
@ -59,12 +60,12 @@ export function Header({
const router = useRouter()
const isMobile = useIsMobile()
const { resetOrgOnboarded } = useOrgOnboarding()
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
const [isFeedbackOpen, setIsFeedbackOpen] = useQueryState("feedback", feedbackParam)
const { viewMode, setViewMode } = useViewMode()
const handleTryOnboarding = () => {
resetOrgOnboarded()
router.push("/new/onboarding?step=input&flow=welcome")
router.push("/onboarding?step=input&flow=welcome")
}
const handleFeedback = () => {
@ -113,7 +114,7 @@ export function Header({
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Link href="/new">
<Link href="/">
<Home className="h-4 w-4 text-[#737373]" />
Home
</Link>
@ -241,7 +242,7 @@ export function Header({
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/new/settings")}
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Settings className="h-4 w-4 text-[#737373]" />
@ -347,7 +348,7 @@ export function Header({
</div>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={() => router.push("/new/settings")}
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Settings className="h-4 w-4 text-[#737373]" />

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { useQueryState, parseAsString, parseAsStringLiteral, parseAsInteger } from "nuqs"
import { Button } from "@ui/components/button"
import {
Select,
@ -35,13 +36,20 @@ interface MCPStepsProps {
}
export function MCPSteps({ variant = "full" }: MCPStepsProps) {
const [selectedClient, setSelectedClient] = useState<
keyof typeof clients | null
>(null)
const [selectedClient, setSelectedClient] = useQueryState(
"mcpClient",
parseAsString,
)
const [selectedProject] = useState<string>("sm_project_default")
const [mcpUrlTab, setMcpUrlTab] = useState<"oneClick" | "manual">("oneClick")
const [mcpUrlTab, setMcpUrlTab] = useQueryState(
"mcpTab",
parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("oneClick"),
)
const [isCopied, setIsCopied] = useState(false)
const [activeStep, setActiveStep] = useState<1 | 2 | 3>(1)
const [activeStep, setActiveStep] = useQueryState(
"mcpStep",
parseAsInteger.withDefault(1),
)
useEffect(() => {
analytics.mcpViewOpened()
@ -170,7 +178,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
{selectedClient && (
<Select
onValueChange={(value) => {
setSelectedClient(value as keyof typeof clients)
setSelectedClient(value)
setActiveStep(2)
}}
value={selectedClient || undefined}
@ -185,7 +193,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
{selectedClient ? (
<div className="flex items-center gap-2">
<Image
alt={clients[selectedClient]}
alt={clients[selectedClient as keyof typeof clients]}
height={20}
width={20}
unoptimized
@ -195,7 +203,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
: `/mcp-supported-tools/${selectedClient === "claude-code" ? "claude" : selectedClient}.png`
}
/>
<span>{clients[selectedClient]}</span>
<span>{clients[selectedClient as keyof typeof clients]}</span>
</div>
) : (
<SelectValue placeholder="Select a client" />
@ -243,7 +251,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
key={key}
type="button"
onClick={() => {
setSelectedClient(key as keyof typeof clients)
setSelectedClient(key)
setActiveStep(2)
}}
className={`mcp-client-button-group py-[6px] pl-2 pr-3 rounded-full border transition-colors cursor-pointer duration-200 ${

View file

@ -5,6 +5,7 @@ import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import { useQueryState } from "nuqs"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
@ -28,6 +29,7 @@ import { QuickNoteCard } from "./quick-note-card"
import { HighlightsCard, type HighlightItem } from "./highlights-card"
import { GraphCard } from "./memory-graph"
import { Button } from "@ui/components/button"
import { categoriesParam } from "@/lib/search-params"
// Document category type
type DocumentCategory =
@ -95,9 +97,10 @@ export function MemoriesGrid({
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useState<
DocumentCategory[]
>([])
const [selectedCategories, setSelectedCategories] = useQueryState(
"categories",
categoriesParam,
)
const { data: facetsData } = useQuery({
queryKey: ["document-facets", selectedProject],
@ -166,18 +169,23 @@ export function MemoriesGrid({
enabled: !!user,
})
const handleCategoryToggle = useCallback((category: DocumentCategory) => {
setSelectedCategories((prev) => {
if (prev.includes(category)) {
return prev.filter((c) => c !== category)
}
return [...prev, category]
})
}, [])
const handleCategoryToggle = useCallback(
(category: DocumentCategory) => {
setSelectedCategories((prev) => {
const current = prev ?? []
if (current.includes(category)) {
const next = current.filter((c) => c !== category)
return next.length === 0 ? null : next
}
return [...current, category]
})
},
[setSelectedCategories],
)
const handleSelectAll = useCallback(() => {
setSelectedCategories([])
}, [])
setSelectedCategories(null)
}, [setSelectedCategories])
const documents = useMemo(() => {
return (
@ -420,10 +428,12 @@ const DocumentCard = memo(
.ogImage
const needsOgData =
document.url &&
document.type !== "notion_doc" &&
!document.url.includes("x.com") &&
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") &&
!document.url.includes("docs.googleapis.com") &&
!document.url.includes("notion.so") &&
(!document.title || !ogImage)
const hideURL = document.url?.includes("docs.googleapis.com")
@ -433,19 +443,17 @@ const DocumentCard = memo(
setIsLoadingOg(true)
fetch(`/api/og?url=${encodeURIComponent(document.url)}`)
.then((res) => {
if (!res.ok) return null
if (!res.ok) throw new Error("Failed")
return res.json()
})
.then((data) => {
if (data) {
setOgData({
title: data.title,
image: data.image,
})
}
setOgData({
title: data?.title,
image: data?.image,
})
})
.catch(() => {
// Silently fail if OG fetch fails
setOgData({})
})
.finally(() => {
setIsLoadingOg(false)

View file

@ -2,8 +2,8 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { useAgent } from "agents/react"
import { useAgentChat } from "@cloudflare/ai-chat/react"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import {
@ -76,21 +76,28 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
const isProcessingRef = useRef(false)
const draftRequestIdRef = useRef(0)
const backendUrl = new URL(process.env.NEXT_PUBLIC_BACKEND_URL!)
const agent = useAgent({
agent: "chat-agent",
name: user?.id ?? "anonymous",
host: backendUrl.host,
})
useEffect(() => {
agent.setState({
model: "gemini-2.5-pro" as const,
projectId: selectedProject,
})
}, [agent, selectedProject])
const {
messages: chatMessages,
sendMessage,
status,
} = useChat({
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
credentials: "include",
body: {
metadata: {
projectId: selectedProject,
model: "gemini-2.5-pro",
},
},
}),
} = useAgentChat({
agent,
getInitialMessages: null,
credentials: "include",
})
const buildOnboardingContext = useCallback(() => {

View file

@ -66,7 +66,7 @@ export function IntegrationsStep() {
const handleContinue = () => {
markOrgOnboarded()
analytics.onboardingCompleted()
router.push("/new")
router.push("/")
}
if (selectedCard === "Connect to AI") {
@ -177,7 +177,7 @@ export function IntegrationsStep() {
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={() => router.push("/new/onboarding/setup?step=relatable")}
onClick={() => router.push("/onboarding/setup?step=relatable")}
>
Back
</Button>

View file

@ -40,7 +40,7 @@ export function RelatableQuestion() {
(idx) => relatableOptions[idx]?.text || "",
)
analytics.onboardingRelatableSelected({ options: selectedTexts })
router.push("/new/onboarding/setup?step=integrations")
router.push("/onboarding/setup?step=integrations")
}
return (

View file

@ -42,11 +42,11 @@ export function OnboardingContentStep({
const router = useRouter()
const handleContinue = () => {
router.push("/new/onboarding/welcome?step=features")
router.push("/onboarding/welcome?step=features")
}
const handleAddMemories = () => {
router.push("/new/onboarding/welcome?step=memories")
router.push("/onboarding/welcome?step=memories")
}
const isContinue = currentView === "continue"

View file

@ -299,7 +299,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
description_length: description.trim().length,
})
onSubmit(formData)
router.push("/new/onboarding/setup?step=relatable")
router.push("/onboarding/setup?step=relatable")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}

View file

@ -133,7 +133,7 @@ export default function Account() {
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/new/settings#account",
successUrl: "https://app.supermemory.ai/settings#account",
})
window.location.reload()
} catch (error) {

View file

@ -416,7 +416,7 @@ export default function ConnectionsMCP() {
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/new/settings#connections",
successUrl: "https://app.supermemory.ai/settings#connections",
})
window.location.reload()
} catch (error) {

View file

@ -1,69 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useRouter } from "next/navigation"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@ui/components/dialog"
import { Button } from "@ui/components/button"
import { useIsMobile } from "@hooks/use-mobile"
export function NewOnboardingModal() {
const router = useRouter()
const flagEnabled = true
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)
useEffect(() => {
if (flagEnabled) {
setOpen(true)
}
}, [flagEnabled])
const handleContinue = () => {
setOpen(false)
router.push("/new/onboarding")
}
if (!flagEnabled) {
return null
}
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setOpen(false)
}
}}
>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Experience the new onboarding</DialogTitle>
<DialogDescription>
We've redesigned the onboarding experience. Would you like to try
it?
{isMobile && (
<span className="block mt-2 text-yellow-600 dark:text-yellow-500">
Desktop view is recommended for the best experience.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Stay here
</Button>
<Button onClick={handleContinue}>Continue to new onboarding</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -1,12 +0,0 @@
"use client"
import { NewOnboardingModal } from "./new-onboarding-modal"
export function OnboardingWrapper({ children }: { children: React.ReactNode }) {
return (
<>
<NewOnboardingModal />
{children}
</>
)
}

View file

@ -1,443 +0,0 @@
"use client"
import { $fetch } from "@repo/lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
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 { Label } from "@repo/ui/components/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/ui/components/select"
import { useQuery } from "@tanstack/react-query"
import {
ChevronDown,
FolderIcon,
Loader2,
MoreHorizontal,
Plus,
Trash2,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProjectName } from "@/hooks/use-project-name"
import { useProject } from "@/stores"
import type { Project } from "@repo/lib/types"
import { CreateProjectDialog } from "./create-project-dialog"
export function ProjectSelector() {
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 { 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 selectedProjectData = projects.find(
(p: Project) => p.containerTag === selectedProject,
)
const selectedEmoji = selectedProjectData?.emoji
const handleProjectSelect = (containerTag: string) => {
switchProject(containerTag)
setIsOpen(false)
}
const handleCreateNewProject = () => {
setIsOpen(false)
setShowCreateDialog(true)
}
return (
<div className="relative">
<Button
type="button"
variant="ghost"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
onClick={() => setIsOpen(!isOpen)}
>
{selectedEmoji ? (
<span className="text-sm">{selectedEmoji}</span>
) : (
<FolderIcon className="h-3.5 w-3.5" />
)}
<span className="text-xs font-medium max-w-32 truncate">
{isLoading ? "..." : projectName}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.25 }}
>
<ChevronDown className="h-3 w-3" />
</motion.div>
</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-background/95 backdrop-blur-xl border border-border 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">
<Button
variant="ghost"
className={`flex items-center w-full justify-between p-2 rounded-md transition-colors cursor-pointer ${
selectedProject === DEFAULT_PROJECT_ID
? "bg-accent"
: "hover:bg-accent/20"
}`}
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
>
<div className="flex items-center gap-2">
<FolderIcon className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Default Project</span>
</div>
</Button>
{/* User Projects */}
{projects
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project) => (
<div
key={project.id}
className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
selectedProject === project.containerTag
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<button
className="flex items-center gap-2 flex-1 cursor-pointer"
type="button"
onClick={() =>
handleProjectSelect(project.containerTag)
}
>
{project.emoji ? (
<span className="text-sm">{project.emoji}</span>
) : (
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
)}
<span className="text-xs font-medium truncate max-w-32">
{project.name}
</span>
</button>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer text-xs hover:text-red-500"
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" />
Delete Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
<motion.div
className="flex items-center gap-2 p-2 rounded-md hover:bg-accent/50 transition-colors cursor-pointer border-t border-border 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-foreground/70" />
<span className="text-xs font-medium text-foreground/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">
<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>
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="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">
<SelectValue placeholder="Select target project..." />
</SelectTrigger>
<SelectContent>
<SelectItem
value={
projects.find(
(p) => p.containerTag === DEFAULT_PROJECT_ID,
)?.id || ""
}
>
Default Project
</SelectItem>
{projects
.filter(
(p: Project) =>
p.id !== deleteDialog.project?.id &&
p.containerTag !== DEFAULT_PROJECT_ID,
)
.map((project: Project) => (
<SelectItem 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="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-600 dark: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
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 dark:bg-red-600 dark:hover:bg-red-700 text-white"
: ""
}
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>
</div>
)
}

View file

@ -1,290 +0,0 @@
"use client"
import { cn } from "@lib/utils"
import { AnimatePresence, motion } from "motion/react"
import type {
TargetAndTransition,
Transition,
Variant,
Variants,
} from "motion/react"
import React from "react"
export type PresetType = "blur" | "fade-in-blur" | "scale" | "fade" | "slide"
export type PerType = "word" | "char" | "line"
export type TextEffectProps = {
children: string
per?: PerType
as?: keyof React.JSX.IntrinsicElements
variants?: {
container?: Variants
item?: Variants
}
className?: string
preset?: PresetType
delay?: number
speedReveal?: number
speedSegment?: number
trigger?: boolean
onAnimationComplete?: () => void
onAnimationStart?: () => void
segmentWrapperClassName?: string
containerTransition?: Transition
segmentTransition?: Transition
style?: React.CSSProperties
}
const defaultStaggerTimes: Record<PerType, number> = {
char: 0.03,
word: 0.05,
line: 0.1,
}
const defaultContainerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
}
const defaultItemVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
},
exit: { opacity: 0 },
}
const presetVariants: Record<
PresetType,
{ container: Variants; item: Variants }
> = {
blur: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(12px)" },
visible: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(12px)" },
},
},
"fade-in-blur": {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20, filter: "blur(12px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 20, filter: "blur(12px)" },
},
},
scale: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, scale: 0 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0 },
},
},
fade: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
},
slide: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
},
},
}
const AnimationComponent: React.FC<{
segment: string
variants: Variants
per: "line" | "word" | "char"
segmentWrapperClassName?: string
}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
const content =
per === "line" ? (
<motion.span variants={variants} className="block">
{segment}
</motion.span>
) : per === "word" ? (
<motion.span
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
{segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{segment.split("").map((char, charIndex) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: Character position is stable within animation
key={`char-${charIndex}`}
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
{char}
</motion.span>
))}
</motion.span>
)
if (!segmentWrapperClassName) {
return content
}
const defaultWrapperClassName = per === "line" ? "block" : "inline-block"
return (
<span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
{content}
</span>
)
})
AnimationComponent.displayName = "AnimationComponent"
const splitText = (text: string, per: PerType) => {
if (per === "line") return text.split("\n")
return text.split(/(\s+)/)
}
const hasTransition = (
variant?: Variant,
): variant is TargetAndTransition & { transition?: Transition } => {
if (!variant) return false
return typeof variant === "object" && "transition" in variant
}
const createVariantsWithTransition = (
baseVariants: Variants,
transition?: Transition & { exit?: Transition },
): Variants => {
if (!transition) return baseVariants
const { exit: _, ...mainTransition } = transition
return {
...baseVariants,
visible: {
...baseVariants.visible,
transition: {
...(hasTransition(baseVariants.visible)
? baseVariants.visible.transition
: {}),
...mainTransition,
},
},
exit: {
...baseVariants.exit,
transition: {
...(hasTransition(baseVariants.exit)
? baseVariants.exit.transition
: {}),
...mainTransition,
staggerDirection: -1,
},
},
}
}
export function TextEffect({
children,
per = "word",
as = "p",
variants,
className,
preset = "fade",
delay = 0,
speedReveal = 1,
speedSegment = 1,
trigger = true,
onAnimationComplete,
onAnimationStart,
segmentWrapperClassName,
containerTransition,
segmentTransition,
style,
}: TextEffectProps) {
const segments = splitText(children, per)
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
const baseVariants = preset
? presetVariants[preset]
: { container: defaultContainerVariants, item: defaultItemVariants }
const stagger = defaultStaggerTimes[per] / speedReveal
const baseDuration = 0.3 / speedSegment
const customStagger = hasTransition(variants?.container?.visible ?? {})
? (variants?.container?.visible as TargetAndTransition).transition
?.staggerChildren
: undefined
const customDelay = hasTransition(variants?.container?.visible ?? {})
? (variants?.container?.visible as TargetAndTransition).transition
?.delayChildren
: undefined
const computedVariants = {
container: createVariantsWithTransition(
variants?.container || baseVariants.container,
{
staggerChildren: customStagger ?? stagger,
delayChildren: customDelay ?? delay,
...containerTransition,
exit: {
staggerChildren: customStagger ?? stagger,
staggerDirection: -1,
},
},
),
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
duration: baseDuration,
...segmentTransition,
}),
}
return (
<AnimatePresence mode="popLayout">
{trigger && (
<MotionTag
initial="hidden"
animate="visible"
exit="exit"
variants={computedVariants.container}
className={className}
onAnimationComplete={onAnimationComplete}
onAnimationStart={onAnimationStart}
style={style}
>
{per !== "line" ? <span className="sr-only">{children}</span> : null}
{segments.map((segment, index) => (
<AnimationComponent
key={`${per}-${index}-${segment}`}
segment={segment}
variants={computedVariants.item}
per={per}
segmentWrapperClassName={segmentWrapperClassName}
/>
))}
</MotionTag>
)}
</AnimatePresence>
)
}

View file

@ -1,78 +0,0 @@
"use client"
import { cn } from "@lib/utils"
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react"
import { useMemo, useId } from "react"
export type TextMorphProps = {
children: string
as?: React.ElementType
className?: string
style?: React.CSSProperties
variants?: Variants
transition?: Transition
}
export function TextMorph({
children,
as: Component = "p",
className,
style,
variants,
transition,
}: TextMorphProps) {
const uniqueId = useId()
const characters = useMemo(() => {
const charCounts: Record<string, number> = {}
return children.split("").map((char) => {
const lowerChar = char.toLowerCase()
charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1
return {
id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
label: char === " " ? "\u00A0" : char,
}
})
}, [children, uniqueId])
const defaultVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
const defaultTransition: Transition = {
type: "spring",
stiffness: 280,
damping: 18,
mass: 0.3,
}
return (
<Component className={cn(className)} aria-label={children} style={style}>
<AnimatePresence mode="popLayout" initial={false}>
{characters.map((character) => (
<motion.span
key={character.id}
layoutId={character.id}
className="inline-block"
aria-hidden="true"
initial="initial"
animate="animate"
exit="exit"
variants={variants || defaultVariants}
transition={transition || defaultTransition}
>
{character.label}
</motion.span>
))}
</AnimatePresence>
</Component>
)
}

View file

@ -1,58 +0,0 @@
"use client"
import { cn } from "@lib/utils"
import { motion } from "motion/react"
import React, { type JSX, useMemo } from "react"
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: Number.POSITIVE_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)

View file

@ -1,67 +0,0 @@
import { Button } from "@repo/ui/components/button"
import { Loader2, type LucideIcon } from "lucide-react"
import { motion } from "motion/react"
interface ActionButtonsProps {
onCancel: () => void
onSubmit?: () => void
submitText: string
submitIcon?: LucideIcon
isSubmitting?: boolean
isSubmitDisabled?: boolean
submitType?: "button" | "submit"
className?: string
}
export function ActionButtons({
onCancel,
onSubmit,
submitText,
submitIcon: SubmitIcon,
isSubmitting = false,
isSubmitDisabled = false,
submitType = "submit",
className = "",
}: ActionButtonsProps) {
return (
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
<Button
className="hover:bg-foreground/10 border-none flex-1 sm:flex-initial cursor-pointer"
onClick={onCancel}
type="button"
variant="ghost"
>
Cancel
</Button>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="flex-1 sm:flex-initial"
>
<Button
className="w-full cursor-pointer text-black dark:text-white"
disabled={isSubmitting || isSubmitDisabled}
onClick={submitType === "button" ? onSubmit : undefined}
type={submitType}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{submitText.includes("Add")
? "Adding..."
: submitText.includes("Upload")
? "Uploading..."
: "Processing..."}
</>
) : (
<>
{SubmitIcon && <SubmitIcon className="h-4 w-4 mr-2" />}
{submitText}
</>
)}
</Button>
</motion.div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,54 +0,0 @@
interface MemoryUsageRingProps {
memoriesUsed: number
memoriesLimit: number
className?: string
}
export function MemoryUsageRing({
memoriesUsed,
memoriesLimit,
className = "",
}: MemoryUsageRingProps) {
const usagePercentage = memoriesUsed / memoriesLimit
const strokeColor =
memoriesUsed >= memoriesLimit * 0.8 ? "rgb(251 191 36)" : "rgb(34 197 94)"
const circumference = 2 * Math.PI * 10
return (
<div
className={`relative group cursor-help self-center sm:self-end mb-1 hidden sm:block ${className}`}
title={`${memoriesUsed} of ${memoriesLimit} memories used`}
>
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 24 24">
<title>{`${memoriesUsed} of ${memoriesLimit} memories used`}</title>
{/* Background circle */}
<circle
cx="12"
cy="12"
fill="none"
r="10"
stroke="rgb(255 255 255 / 0.1)"
strokeWidth="2"
/>
{/* Progress circle */}
<circle
className="transition-all duration-300"
cx="12"
cy="12"
fill="none"
r="10"
stroke={strokeColor}
strokeDasharray={`${circumference}`}
strokeDashoffset={`${circumference * (1 - usagePercentage)}`}
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
{/* Tooltip on hover */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
{memoriesUsed} / {memoriesLimit}
</div>
</div>
)
}

View file

@ -1,90 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/ui/components/select"
import { Plus } from "lucide-react"
interface Project {
id?: string
containerTag: string
name: string
}
interface ProjectSelectionProps {
projects: Project[]
selectedProject: string
onProjectChange: (value: string) => void
onCreateProject: () => void
disabled?: boolean
isLoading?: boolean
className?: string
id?: string
}
export function ProjectSelection({
projects,
selectedProject,
onProjectChange,
onCreateProject,
disabled = false,
isLoading = false,
className = "",
id = "project-select",
}: ProjectSelectionProps) {
const handleValueChange = (value: string) => {
if (value === "create-new-project") {
onCreateProject()
} else {
onProjectChange(value)
}
}
return (
<Select
key={`${id}-${selectedProject}`}
disabled={isLoading || disabled}
onValueChange={handleValueChange}
value={selectedProject}
>
<SelectTrigger
className={`bg-foreground/5 border-foreground/10 cursor-pointer ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent position="popper" sideOffset={5} className="z-[90]">
<SelectItem
className="hover:bg-foreground/10"
key="default"
value="sm_project_default"
>
Default Project
</SelectItem>
{projects
.filter((p) => p.containerTag !== "sm_project_default" && p.id)
.map((project) => (
<SelectItem
className="hover:bg-foreground/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
{project.name}
</SelectItem>
))}
<SelectItem
className="hover:bg-foreground/10 border-t border-foreground/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>
)
}

View file

@ -1,28 +0,0 @@
import type { LucideIcon } from "lucide-react"
interface TabButtonProps {
icon: LucideIcon
label: string
isActive: boolean
onClick: () => void
}
export function TabButton({
icon: Icon,
label,
isActive,
onClick,
}: TabButtonProps) {
return (
<button
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
isActive ? "bg-white/10" : "hover:bg-white/5"
}`}
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
{label}
</button>
)
}

View file

@ -1,557 +0,0 @@
"use client"
import { cn } from "@lib/utils"
import { Button } from "@repo/ui/components/button"
import isHotkey from "is-hotkey"
import {
Bold,
Code,
Heading1,
Heading2,
Heading3,
Italic,
List,
Quote,
} from "lucide-react"
import { useCallback, useMemo, useState } from "react"
import {
type BaseEditor,
createEditor,
type Descendant,
Editor,
Transforms,
} from "slate"
import type { ReactEditor as ReactEditorType } from "slate-react"
import {
Editable,
ReactEditor,
type RenderElementProps,
type RenderLeafProps,
Slate,
withReact,
} from "slate-react"
type CustomEditor = BaseEditor & ReactEditorType
type ParagraphElement = {
type: "paragraph"
children: CustomText[]
}
type HeadingElement = {
type: "heading"
level: number
children: CustomText[]
}
type ListItemElement = {
type: "list-item"
children: CustomText[]
}
type BlockQuoteElement = {
type: "block-quote"
children: CustomText[]
}
type CustomElement =
| ParagraphElement
| HeadingElement
| ListItemElement
| BlockQuoteElement
type FormattedText = {
text: string
bold?: true
italic?: true
code?: true
}
type CustomText = FormattedText
declare module "slate" {
interface CustomTypes {
Editor: CustomEditor
Element: CustomElement
Text: CustomText
}
}
// Hotkey mappings
const HOTKEYS: Record<string, keyof CustomText> = {
"mod+b": "bold",
"mod+i": "italic",
"mod+`": "code",
}
interface TextEditorProps {
value?: string
onChange?: (value: string) => void
onBlur?: () => void
placeholder?: string
disabled?: boolean
className?: string
containerClassName?: string
}
const initialValue: Descendant[] = [
{
type: "paragraph",
children: [{ text: "" }],
},
]
const serialize = (nodes: Descendant[]): string => {
return nodes.map((n) => serializeNode(n)).join("\n")
}
const serializeNode = (node: CustomElement | CustomText): string => {
if ("text" in node) {
let text = node.text
if (node.bold) text = `**${text}**`
if (node.italic) text = `*${text}*`
if (node.code) text = `\`${text}\``
return text
}
const children = node.children
? node.children.map(serializeNode).join("")
: ""
switch (node.type) {
case "paragraph":
return children
case "heading":
return `${"#".repeat(node.level || 1)} ${children}`
case "list-item":
return `- ${children}`
case "block-quote":
return `> ${children}`
default:
return children
}
}
const deserialize = (text: string): Descendant[] => {
if (!text.trim()) {
return initialValue
}
const lines = text.split("\n")
const nodes: Descendant[] = []
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine.startsWith("# ")) {
nodes.push({
type: "heading",
level: 1,
children: [{ text: trimmedLine.slice(2) }],
})
} else if (trimmedLine.startsWith("## ")) {
nodes.push({
type: "heading",
level: 2,
children: [{ text: trimmedLine.slice(3) }],
})
} else if (trimmedLine.startsWith("### ")) {
nodes.push({
type: "heading",
level: 3,
children: [{ text: trimmedLine.slice(4) }],
})
} else if (trimmedLine.startsWith("- ")) {
nodes.push({
type: "list-item",
children: [{ text: trimmedLine.slice(2) }],
})
} else if (trimmedLine.startsWith("> ")) {
nodes.push({
type: "block-quote",
children: [{ text: trimmedLine.slice(2) }],
})
} else {
nodes.push({
type: "paragraph",
children: [{ text: line }],
})
}
}
return nodes.length > 0 ? nodes : initialValue
}
const isMarkActive = (editor: CustomEditor, format: keyof CustomText) => {
const marks = Editor.marks(editor)
return marks ? marks[format as keyof typeof marks] === true : false
}
const toggleMark = (editor: CustomEditor, format: keyof CustomText) => {
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true)
}
// Focus back to editor after toggling
ReactEditor.focus(editor)
}
const isBlockActive = (
editor: CustomEditor,
format: string,
level?: number,
) => {
const { selection } = editor
if (!selection) return false
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) =>
!Editor.isEditor(n) &&
(n as CustomElement).type === format &&
(level === undefined || (n as HeadingElement).level === level),
}),
)
return !!match
}
const toggleBlock = (editor: CustomEditor, format: string, level?: number) => {
const isActive = isBlockActive(editor, format, level)
const newProperties: any = {
type: isActive ? "paragraph" : format,
}
if (format === "heading" && level && !isActive) {
newProperties.level = level
}
Transforms.setNodes(editor, newProperties)
// Focus back to editor after toggling
ReactEditor.focus(editor)
}
export function TextEditor({
value = "",
onChange,
onBlur,
placeholder = "Start writing...",
disabled = false,
className,
containerClassName,
}: TextEditorProps) {
const editor = useMemo(() => withReact(createEditor()) as CustomEditor, [])
const [editorValue, setEditorValue] = useState<Descendant[]>(() =>
deserialize(value),
)
const [selection, setSelection] = useState(editor.selection)
const renderElement = useCallback((props: RenderElementProps) => {
switch (props.element.type) {
case "heading": {
const element = props.element as HeadingElement
const HeadingTag = `h${element.level || 1}` as
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
return (
<HeadingTag
{...props.attributes}
className={cn(
"font-bold",
element.level === 1 && "text-2xl mb-4",
element.level === 2 && "text-xl mb-3",
element.level === 3 && "text-lg mb-2",
)}
>
{props.children}
</HeadingTag>
)
}
case "list-item":
return (
<li {...props.attributes} className="ml-4 list-disc">
{props.children}
</li>
)
case "block-quote":
return (
<blockquote
{...props.attributes}
className="border-l-4 border-foreground/20 pl-4 italic text-foreground/80"
>
{props.children}
</blockquote>
)
default:
return (
<p {...props.attributes} className="mb-2">
{props.children}
</p>
)
}
}, [])
const renderLeaf = useCallback((props: RenderLeafProps) => {
let { attributes, children, leaf } = props
if (leaf.bold) {
children = <strong>{children}</strong>
}
if (leaf.italic) {
children = <em>{children}</em>
}
if (leaf.code) {
children = (
<code className="bg-foreground/10 px-1 rounded text-sm">
{children}
</code>
)
}
return <span {...attributes}>{children}</span>
}, [])
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Handle hotkeys for formatting
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault()
const mark = HOTKEYS[hotkey]
if (mark) {
toggleMark(editor, mark)
}
return
}
}
// Handle block formatting hotkeys
if (isHotkey("mod+shift+1", event)) {
event.preventDefault()
toggleBlock(editor, "heading", 1)
return
}
if (isHotkey("mod+shift+2", event)) {
event.preventDefault()
toggleBlock(editor, "heading", 2)
return
}
if (isHotkey("mod+shift+3", event)) {
event.preventDefault()
toggleBlock(editor, "heading", 3)
return
}
if (isHotkey("mod+shift+8", event)) {
event.preventDefault()
toggleBlock(editor, "list-item")
return
}
if (isHotkey("mod+shift+.", event)) {
event.preventDefault()
toggleBlock(editor, "block-quote")
return
}
},
[editor],
)
const handleSlateChange = useCallback(
(newValue: Descendant[]) => {
setEditorValue(newValue)
const serializedValue = serialize(newValue)
onChange?.(serializedValue)
},
[onChange],
)
// Memoized active states that update when selection changes
const activeStates = useMemo(
() => ({
bold: isMarkActive(editor, "bold"),
italic: isMarkActive(editor, "italic"),
code: isMarkActive(editor, "code"),
heading1: isBlockActive(editor, "heading", 1),
heading2: isBlockActive(editor, "heading", 2),
heading3: isBlockActive(editor, "heading", 3),
listItem: isBlockActive(editor, "list-item"),
blockQuote: isBlockActive(editor, "block-quote"),
}),
[editor, selection],
)
const ToolbarButton = ({
icon: Icon,
isActive,
onMouseDown,
title,
}: {
icon: React.ComponentType<{ className?: string }>
isActive: boolean
onMouseDown: (event: React.MouseEvent) => void
title: string
}) => (
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 !p-0 text-foreground/70 transition-all duration-200 rounded-sm cursor-pointer",
"hover:bg-foreground/15 hover:text-foreground hover:scale-105",
"active:scale-95",
isActive && "bg-foreground/20 text-foreground",
)}
onMouseDown={onMouseDown}
title={title}
type="button"
>
<Icon
className={cn(
"h-4 w-4 transition-transform duration-200",
isActive && "scale-110",
)}
/>
</Button>
)
return (
<div
className={cn(
"bg-foreground/5 border border-foreground/10 rounded-md",
containerClassName,
)}
>
<div className={cn("flex flex-col", className)}>
<div className="flex-1 min-h-48 overflow-y-auto">
<Slate
editor={editor}
initialValue={editorValue}
onValueChange={handleSlateChange}
onSelectionChange={() => setSelection(editor.selection)}
>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
renderPlaceholder={({ children, attributes }) => {
return (
<div {...attributes} className="mt-2">
{children}
</div>
)
}}
onKeyDown={handleKeyDown}
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full placeholder:text-foreground/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
minHeight: "23rem",
maxHeight: "23rem",
padding: "12px",
overflowX: "hidden",
}}
/>
</Slate>
</div>
{/* Toolbar */}
<div className="p-1 flex items-center gap-2 bg-foreground/5 backdrop-blur-sm rounded-b-md">
<div className="flex items-center gap-1">
{/* Text formatting */}
<ToolbarButton
icon={Bold}
isActive={activeStates.bold}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "bold")
}}
title="Bold (Ctrl/Cmd+B)"
/>
<ToolbarButton
icon={Italic}
isActive={activeStates.italic}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "italic")
}}
title="Italic (Ctrl/Cmd+I)"
/>
<ToolbarButton
icon={Code}
isActive={activeStates.code}
onMouseDown={(event) => {
event.preventDefault()
toggleMark(editor, "code")
}}
title="Code (Ctrl/Cmd+`)"
/>
</div>
<div className="w-px h-6 bg-foreground/30 mx-2" />
<div className="flex items-center gap-1">
{/* Block formatting */}
<ToolbarButton
icon={Heading1}
isActive={activeStates.heading1}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 1)
}}
title="Heading 1 (Ctrl/Cmd+Shift+1)"
/>
<ToolbarButton
icon={Heading2}
isActive={activeStates.heading2}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 2)
}}
title="Heading 2 (Ctrl/Cmd+Shift+2)"
/>
<ToolbarButton
icon={Heading3}
isActive={activeStates.heading3}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "heading", 3)
}}
title="Heading 3"
/>
<ToolbarButton
icon={List}
isActive={activeStates.listItem}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "list-item")
}}
title="Bullet List"
/>
<ToolbarButton
icon={Quote}
isActive={activeStates.blockQuote}
onMouseDown={(event) => {
event.preventDefault()
toggleBlock(editor, "block-quote")
}}
title="Quote"
/>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,283 +0,0 @@
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 { AlertTriangle, CheckCircle, LoaderIcon, X } from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useEffect, useState } 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: status = {
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.api_pro
const proProductStatus = proStatus?.status
const isPastDue = proProductStatus === "past_due"
const hasProProduct = proProductStatus !== null
const { data: memoriesCheck } = fetchMemoriesFeature(
autumn,
!autumn.isLoading && !isCheckingStatus,
)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: connectionsCheck } = fetchConnectionsFeature(
autumn,
!autumn.isLoading && !isCheckingStatus,
)
const connectionsUsed = connectionsCheck?.usage ?? 0
// Handle upgrade
const handleUpgrade = async () => {
analytics.upgradeInitiated()
setIsLoading(true)
try {
await autumn.attach({
productId: "api_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",
})
}
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-muted-foreground mb-4">
Sign in to unlock premium features
</p>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
asChild
className="bg-muted hover:bg-muted/80 text-foreground border-border"
size="sm"
>
<Link href="/login">Sign in</Link>
</Button>
</motion.div>
</motion.div>
)
}
if (hasProProduct) {
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-foreground flex items-center gap-2">
Pro Plan
{isPastDue ? (
<span className="text-xs bg-red-500/20 text-red-600 dark:text-red-400 px-2 py-0.5 rounded-full">
Past Due
</span>
) : (
<span className="text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full">
Active
</span>
)}
</HeadingH3Bold>
<p className="text-sm text-muted-foreground">
{isPastDue
? "Your payment is past due. Please update your payment method to restore access."
: "You're enjoying expanded memory capacity with supermemory Pro!"}
</p>
</div>
{isPastDue && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
Payment Required
</p>
<p className="text-xs text-red-600/80 dark:text-red-400/80">
Your payment method failed or payment is past due. Update your
payment information to restore access to all Pro features.
</p>
</div>
</div>
)}
{/* Current Usage */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">Current Usage</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Memories</span>
<span className="text-sm text-foreground">Unlimited</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Connections</span>
<span className="text-sm text-foreground">
{connectionsUsed} / 10
</span>
</div>
</div>
</div>
<Button
onClick={handleManageBilling}
size="sm"
variant="default"
className={isPastDue ? "bg-red-600 hover:bg-red-700 text-white" : ""}
>
{isPastDue ? "Pay Past Due" : "Manage Billing"}
</Button>
</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-foreground">
Current Plan: Free
</HeadingH3Bold>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Memories</span>
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
>
{memoriesUsed} / {memoriesLimit}
</span>
</div>
<div className="w-full bg-muted-foreground/50 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-foreground">
Upgrade to Pro
</HeadingH3Bold>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Free Plan */}
<div className="p-4 bg-muted/50 rounded-lg border border-border">
<h4 className="font-medium text-foreground mb-3">Free Plan</h4>
<ul className="space-y-2">
<li className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
200 memories
</li>
<li className="flex items-center gap-2 text-sm text-muted-foreground">
<X className="h-4 w-4 text-red-500" />
No connections
</li>
<li className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
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-foreground mb-3 flex items-center gap-2">
Pro Plan
<span className="text-xs bg-blue-500/20 text-blue-600 dark: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" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-sm text-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
10 connections
</li>
<li className="flex items-center gap-2 text-sm text-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
Advanced search
</li>
<li className="flex items-center gap-2 text-sm text-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
Priority support
</li>
</ul>
</div>
</div>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white border-0 w-full"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
<div>Upgrade to Pro - $9/month (only for first 100 users)</div>
)}
</Button>
<p className="text-xs text-muted-foreground text-center">
Cancel anytime. No questions asked.
</p>
</div>
</motion.div>
)
}

View file

@ -1,742 +0,0 @@
"use client"
import { useChat, useCompletion, type UIMessage } from "@ai-sdk/react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { DefaultChatTransport } from "ai"
import {
ArrowUp,
Check,
ChevronDown,
ChevronRight,
Copy,
RotateCcw,
X,
Square,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { Streamdown } from "streamdown"
import { TextShimmer } from "@/components/text-shimmer"
import { usePersistentChat, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { ModelIcon } from "@/lib/models"
import { Spinner } from "../../spinner"
import { areUIMessageArraysEqual } from "@/stores/chat"
interface MemoryResult {
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface ExpandableMemoriesProps {
foundCount: number
results: MemoryResult[]
}
interface MessagePart {
type: string
state?: string
text?: string
output?: {
count?: number
results?: Array<{
documentId?: string
title?: string
content?: string
url?: string
score?: number
}>
}
}
interface ChatMessage {
id: string
role: "user" | "assistant"
parts: MessagePart[]
}
function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
const [isExpanded, setIsExpanded] = useState(false)
if (foundCount === 0) {
return (
<div className="text-sm flex items-center gap-2 text-muted-foreground">
<Check className="size-4" /> No memories found
</div>
)
}
return (
<div className="text-sm">
<button
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
>
{isExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
Related memories
</button>
{isExpanded && results.length > 0 && (
<div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto grid grid-cols-3 gap-2">
{results.map((result, index) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<>
{result.title && (
<div className="font-medium text-sm mb-1 text-foreground">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-muted-foreground line-clamp-2">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate">
{result.url}
</div>
)}
{result.score && (
<div className="text-xs text-muted-foreground mt-1">
Score: {(result.score * 100).toFixed(1)}%
</div>
)}
</>
)
if (isClickable) {
return (
<a
className="block p-2 bg-accent/50 rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
href={result.url}
key={result.documentId || index}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className="p-2 bg-accent/50 rounded-md border border-border"
key={result.documentId || index}
>
{content}
</div>
)
})}
</div>
)}
</div>
)
}
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)
const scrollToBottom = useCallback((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, scrollToBottom],
)
function enableAutoScroll() {
setIsAutoScroll(true)
}
useEffect(
function autoScrollOnNewContent() {
if (isAutoScroll) scrollToBottom("auto")
},
[isAutoScroll, scrollToBottom, ...triggerKeys],
)
const recomputeDistanceFromBottom = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight
setIsFarFromBottom(distanceFromBottom > 100)
}, [])
useEffect(() => {
recomputeDistanceFromBottom()
}, [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 storageKey = `chat-model-${currentChatId}`
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<
"gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro"
>("gemini-2.5-pro")
const activeChatIdRef = useRef<string | null>(null)
const shouldGenerateTitleRef = useRef<boolean>(false)
const hasRunInitialMessageRef = useRef<boolean>(false)
const lastSavedMessagesRef = useRef<UIMessage[] | null>(null)
const lastSavedActiveIdRef = useRef<string | null>(null)
const lastLoadedChatIdRef = useRef<string | null>(null)
const lastLoadedMessagesRef = useRef<UIMessage[] | null>(null)
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,
model: selectedModel,
chatId: currentChatId,
},
},
}),
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: { type?: string; text?: string }) => p?.type === "text",
) as { text?: string } | undefined
const text = textPart?.text?.trim()
if (text) {
shouldGenerateTitleRef.current = false
complete(text)
}
}
},
})
useEffect(() => {
lastLoadedMessagesRef.current = messages
}, [messages])
useEffect(() => {
activeChatIdRef.current = currentChatId ?? id ?? null
}, [currentChatId, id])
useEffect(() => {
if (typeof window === "undefined") return
if (currentChatId) {
const savedModel = sessionStorage.getItem(storageKey) as
| "gpt-5"
| "claude-sonnet-4.5"
| "gemini-2.5-pro"
if (
savedModel &&
["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel)
) {
setSelectedModel(savedModel)
}
}
}, [currentChatId, storageKey])
useEffect(() => {
if (typeof window === "undefined") return
if (currentChatId && !hasRunInitialMessageRef.current) {
// Check if there's an initial message from the home page in sessionStorage
const storageKey = `chat-initial-${currentChatId}`
const initialMessage = sessionStorage.getItem(storageKey)
if (initialMessage) {
// Clean up the storage and send the message
sessionStorage.removeItem(storageKey)
sendMessage({ text: initialMessage })
hasRunInitialMessageRef.current = true
}
}
}, [currentChatId, sendMessage])
useEffect(() => {
if (id && id !== currentChatId) {
setCurrentChatId(id)
}
}, [id, currentChatId, setCurrentChatId])
useEffect(() => {
if (currentChatId !== lastLoadedChatIdRef.current) {
lastLoadedMessagesRef.current = null
lastSavedMessagesRef.current = null
}
if (currentChatId === lastLoadedChatIdRef.current) {
setInput("")
return
}
const msgs = getCurrentConversation()
if (msgs && msgs.length > 0) {
const currentMessages = lastLoadedMessagesRef.current
if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) {
lastLoadedMessagesRef.current = msgs
setMessages(msgs)
}
} else if (!currentChatId) {
if (
lastLoadedMessagesRef.current &&
lastLoadedMessagesRef.current.length > 0
) {
lastLoadedMessagesRef.current = []
setMessages([])
}
}
lastLoadedChatIdRef.current = currentChatId
setInput("")
}, [currentChatId, getCurrentConversation, setMessages])
useEffect(() => {
const activeId = currentChatId ?? id
if (!activeId || messages.length === 0) {
return
}
if (activeId !== lastSavedActiveIdRef.current) {
lastSavedMessagesRef.current = null
lastSavedActiveIdRef.current = activeId
}
const lastSaved = lastSavedMessagesRef.current
if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) {
return
}
lastSavedMessagesRef.current = messages
setConversation(activeId, messages)
}, [messages, currentChatId, id, setConversation])
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") as ChatMessage | undefined
if (!lastAssistant) return
const lastSearchPart = [...(lastAssistant.parts as MessagePart[])]
.reverse()
.find(
(p) =>
p?.type === "tool-searchMemories" &&
p?.state === "output-available",
) as MessagePart | undefined
if (!lastSearchPart) return
const output = lastSearchPart.output
const ids = Array.isArray(output?.results)
? ((output.results as MemoryResult[])
.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
}, [getCurrentChat])
/**
* Handles sending a message from the input area.
* - Prevents sending during submitted (shows toast)
* - Stops streaming when active
* - Validates non-empty input (shows toast)
* Returns true when a message is sent.
*/
const handleSendMessage = useCallback(() => {
if (status === "submitted") {
toast.warning("Please wait for the current response to complete", {
id: "wait-for-response",
})
return false
}
if (status === "streaming") {
stop()
return false
}
if (!input.trim()) {
toast.warning("Please enter a message", { id: "empty-message" })
return false
}
sendMessage({ text: input })
setInput("")
return true
}, [status, input, sendMessage, stop])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const {
scrollContainerRef,
bottomRef,
isFarFromBottom,
onScroll,
enableAutoScroll,
scrollToBottom,
} = useStickyAutoScroll([messages, status])
return (
<div className="h-full flex flex-col w-full">
<div className="flex-1 relative">
<div
className="absolute inset-0 overflow-y-auto custom-scrollbar"
onScroll={onScroll}
ref={scrollContainerRef}
>
<div className="flex flex-col gap-2 max-w-4xl mx-auto px-4 md:px-2 pt-4 pb-7 scroll-pb-7">
{messages.map((message) => (
<div
className={cn(
"flex my-2",
message.role === "user"
? "items-center flex-row-reverse gap-2"
: "flex-col",
)}
key={message.id}
>
<div
className={cn(
"flex flex-col gap-2 ",
message.role === "user"
? "bg-accent/50 px-3 py-1.5 border border-border 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={`${message.id}-${part.type}-${index}`}>
<Streamdown>{part.text}</Streamdown>
</div>
)
case "tool-searchMemories": {
switch (part.state) {
case "input-available":
case "input-streaming":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<Spinner className="size-4" /> Searching
memories...
</div>
)
case "output-error":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<X className="size-4" /> Error recalling
memories
</div>
)
case "output-available": {
const output = part.output
const foundCount =
typeof output === "object" &&
output !== null &&
"count" in output
? Number(output.count) || 0
: 0
// @ts-expect-error
const results = Array.isArray(output?.results)
? // @ts-expect-error
output.results
: []
return (
<ExpandableMemories
foundCount={foundCount}
key={`${message.id}-${part.type}-${index}`}
results={results}
/>
)
}
default:
return null
}
}
case "tool-addMemory": {
switch (part.state) {
case "input-available":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<Spinner className="size-4" /> Adding
memory...
</div>
)
case "output-error":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<X className="size-4" /> Error adding memory
</div>
)
case "output-available":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<Check className="size-4" /> Memory added
</div>
)
case "input-streaming":
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={`${message.id}-${part.type}-${index}`}
>
<Spinner className="size-4" /> Adding
memory...
</div>
)
default:
return null
}
}
default:
return null
}
})}
</div>
{message.role === "assistant" && (
<div className="flex items-center gap-0.5 mt-0.5">
<Button
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => {
navigator.clipboard.writeText(
message.parts
.filter((p) => p.type === "text")
?.map((p) => (p as MessagePart).text ?? "")
.join("\n") ?? "",
)
toast.success("Copied to clipboard")
}}
size="icon"
variant="ghost"
>
<Copy className="size-3.5" />
</Button>
<Button
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => regenerate({ messageId: message.id })}
size="icon"
variant="ghost"
>
<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>
</div>
<Button
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",
)}
onClick={() => {
enableAutoScroll()
scrollToBottom("smooth")
}}
size="sm"
type="button"
variant="default"
>
Scroll to bottom
</Button>
</div>
<div className="pb-4 px-4 md:px-2 max-w-4xl mx-auto w-full">
<form
className="flex flex-col items-end gap-3 border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl"
onSubmit={(e) => {
e.preventDefault()
const sent = handleSendMessage()
if (sent) {
enableAutoScroll()
scrollToBottom("auto")
}
}}
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-busy={status === "streaming" || status === "submitted"}
aria-disabled={status === "submitted"}
placeholder="Ask your follow-up question..."
className="w-full text-foreground placeholder:text-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-3 py-3 bg-transparent"
rows={3}
/>
<div className="absolute bottom-2 right-2 flex items-center gap-4">
<div className="flex items-center gap-1.5">
<ModelIcon
width={16}
height={16}
className="text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{/*{modelNames[selectedModel]}*/}
</span>
</div>
{status === "streaming" || status === "submitted" ? (
<Button
onClick={() => stop()}
aria-label="Stop generation"
className="rounded-xl"
variant="destructive"
size="icon"
type="button"
>
<Square className="size-4" />
</Button>
) : (
<Button
type="submit"
aria-label="Send message"
disabled={!input.trim()}
className="text-primary-foreground rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-primary hover:bg-primary/90"
size="icon"
>
<ArrowUp className="size-4" />
</Button>
)}
</div>
</form>
</div>
</div>
)
}

View file

@ -1,156 +0,0 @@
"use client"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/components/dialog"
import { ScrollArea } from "@ui/components/scroll-area"
import { formatDistanceToNow } from "date-fns"
import { HistoryIcon, Plus, Trash2, X } from "lucide-react"
import { useMemo, useState } from "react"
import { analytics } from "@/lib/analytics"
import { useChatOpen, usePersistentChat, useProject } from "@/stores"
import { ChatMessages } from "./chat-messages"
import { generateId } from "@lib/generate-id"
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 = generateId()
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 (
<button
type="button"
key={c.id}
onClick={() => {
setCurrentChatId(c.id)
setIsDialogOpen(false)
}}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 outline-none w-full text-left",
"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>
</button>
)
})}
{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>
)
}

View file

@ -1,335 +0,0 @@
"use client"
import { $fetch } from "@lib/api"
import { Button } from "@repo/ui/components/button"
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 { Trash2 } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { useProject } from "@/stores"
// 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 { selectedProject } = useProject()
const autumn = useCustomer()
const [isProUser, setIsProUser] = useState(false)
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
} catch (error) {
console.error(error)
}
}
// Set pro user status when autumn data loads
useEffect(() => {
if (!autumn.isLoading) {
setIsProUser(
autumn.customer?.products.some((product) => product.id === "api_pro") ??
false,
)
}
}, [autumn.isLoading, autumn.customer])
// Get connections data directly from autumn customer
const connectionsFeature = autumn.customer?.features?.connections
const connectionsUsed = connectionsFeature?.usage ?? 0
const connectionsLimit = connectionsFeature?.included_usage ?? 0
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()
autumn.track({
featureId: "connections",
value: 1,
})
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="mb-4">
<p className="text-sm text-foreground/70">
Connect your favorite services to import documents
</p>
{isProUser && !autumn.isLoading && (
<p className="text-xs text-foreground/50 mt-1">
{connectionsUsed} of {connectionsLimit} connections used
</p>
)}
{!isProUser && !autumn.isLoading && (
<p className="text-xs text-foreground/50 mt-1">
Connections require a Pro subscription
</p>
)}
</div>
{/* Show upgrade prompt for free users */}
{!autumn.isLoading && !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-black-400 dark:text-yellow-400 mb-2">
🔌 Connections are a Pro feature
</p>
<p className="text-xs text-foreground/60 mb-3">
Connect Google Drive, Notion, OneDrive and more to automatically
sync your documents.
</p>
<Button
className="bg-yellow-500/20 text-black-400 hover:bg-yellow-500/30 dark:text-yellow-400 border-yellow-500/30 cursor-pointer"
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-foreground/5 rounded-lg"
initial={{ opacity: 0 }}
key={`skeleton-${Date.now()}-${i}`}
transition={{ delay: i * 0.1 }}
>
<Skeleton className="h-12 w-full bg-foreground/10" />
</motion.div>
))}
</div>
) : connections.length === 0 ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="text-center py-4"
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-foreground/50 mb-2">No connections yet</p>
<p className="text-xs text-foreground/40">
Choose a service below to connect
</p>
</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-foreground/5 rounded-lg hover:bg-foreground/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">
{getProviderIcon(connection.provider)}
<div>
<p className="font-medium text-foreground capitalize">
{connection.provider.replace("-", " ")}
</p>
{connection.email && (
<p className="text-sm text-foreground/60">
{connection.email}
</p>
)}
</div>
</div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Button
className="text-foreground/50 hover:text-red-400 cursor-pointer"
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>
)}
{/* Available Connections Section */}
<div className="mt-6">
<h3 className="text-lg font-medium mb-4">Available Connections</h3>
<div className="grid gap-3">
{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 }}
>
<Button
className="justify-start h-auto p-4 bg-foreground/5 hover:bg-foreground/10 border-foreground/10 w-full cursor-pointer"
disabled={addConnectionMutation.isPending}
onClick={() => {
addConnectionMutation.mutate(provider as ConnectorProvider)
}}
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-foreground/60 mt-0.5">
{config.description}
</div>
</div>
</Button>
</motion.div>
)
})}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,311 +0,0 @@
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 { useEffect, useState } from "react"
import { toast } from "sonner"
import { z } from "zod/v4"
import { analytics } from "@/lib/analytics"
import { InstallationDialogContent } from "./installation-dialog-content"
// 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/documents/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://mcp.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/en-US/install-mcp?name=supermemory&config=eyJ1cmwiOiJodHRwczovL21jcC5zdXBlcm1lbW9yeS5haS9tY3AiLCJoZWFkZXJzIjp7fX0%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>
)
}

View file

@ -1,157 +0,0 @@
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 { Label } from "@ui/components/label"
import { CopyIcon } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@repo/lib/api"
import type { Project } from "@repo/lib/types"
import { useQuery } from "@tanstack/react-query"
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")
const [selectedProject, setSelectedProject] = useState<string | null>("none")
// Fetch projects
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,
})
// Generate installation command based on selected project
function generateInstallCommand() {
let command = `npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${client} --oauth=yes`
if (selectedProject && selectedProject !== "none") {
// Remove the "sm_project_" prefix from the containerTag
const projectId = selectedProject.replace(/^sm_project_/, "")
command += ` --project ${projectId}`
}
return command
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Install the supermemory MCP Server</DialogTitle>
<DialogDescription>
Select the app and project you want to install supermemory MCP to,
then run the following command:
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-select">Client Application</Label>
<Select
onValueChange={(value) => setClient(value as keyof typeof clients)}
value={client}
>
<SelectTrigger id="client-select" className="w-full">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{Object.entries(clients).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="project-select">Target Project (Optional)</Label>
<Select
onValueChange={setSelectedProject}
value={selectedProject || "none"}
disabled={isLoadingProjects}
>
<SelectTrigger id="project-select" className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem value="none" className="text-white hover:bg-white/10">
Auto-select project
</SelectItem>
<SelectItem
value="sm_project_default"
className="text-white hover:bg-white/10"
>
Default Project
</SelectItem>
{projects
.filter((p: Project) => p.containerTag !== "sm_project_default")
.map((project: Project) => (
<SelectItem
key={project.id}
value={project.containerTag}
className="text-white hover:bg-white/10"
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="command-input">Installation Command</Label>
<Input
id="command-input"
className="font-mono text-xs!"
readOnly
value={generateInstallCommand()}
/>
</div>
</div>
<Button
onClick={() => {
const command = generateInstallCommand()
navigator.clipboard.writeText(command)
analytics.mcpInstallCmdCopied()
toast.success("Copied to clipboard!")
}}
>
<CopyIcon className="size-4" /> Copy Installation Command
</Button>
</DialogContent>
)
}

View file

@ -1,486 +0,0 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import {
fetchConnectionsFeature,
fetchMemoriesFeature,
fetchSubscriptionStatus,
} from "@lib/queries"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { Button } from "@repo/ui/components/button"
import { Input } from "@repo/ui/components/input"
import { Skeleton } from "@repo/ui/components/skeleton"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import {
AlertTriangle,
CheckCircle,
CreditCard,
LoaderIcon,
User,
X,
} from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
export function ProfileView() {
const { user: session, org } = useAuth()
const organizations = org
const autumn = useCustomer()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const {
data: status = {
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.api_pro
const isPro = proStatus?.allowed ?? false
const proProductStatus = proStatus?.status
const isPastDue = proProductStatus === "past_due"
const hasProProduct = proProductStatus !== null
const { data: memoriesCheck } = fetchMemoriesFeature(
autumn,
!isCheckingStatus && !autumn.isLoading,
)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: connectionsCheck } = fetchConnectionsFeature(
autumn,
!isCheckingStatus && !autumn.isLoading,
)
const connectionsUsed = connectionsCheck?.usage ?? 0
const handleUpgrade = async () => {
setIsLoading(true)
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
} catch (error) {
console.error(error)
setIsLoading(false)
}
}
const handleManageBilling = async () => {
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
})
}
const handleDeleteAccount = async () => {
if (deleteConfirmation !== "DELETE" || !org?.id) return
setIsDeleting(true)
setDeleteError(null)
try {
await authClient.organization.delete({
organizationId: org.id,
})
await authClient.signOut()
router.push("/login")
} catch (error) {
console.error("Failed to delete account:", error)
setDeleteError(
error instanceof Error
? error.message
: "Failed to delete account. Please try again or contact support.",
)
setIsDeleting(false)
}
}
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-foreground/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-muted hover:bg-muted/80 text-foreground border-border"
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-card border border-border rounded-lg p-3 sm:p-4 space-y-4">
<div className="flex items-start gap-3 sm:gap-4">
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
{session?.image ? (
<img
src={session.image}
alt={session?.name || session?.email || "User"}
className="w-full h-full rounded-full object-cover"
/>
) : (
<User className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="space-y-1">
{session?.name && (
<h3 className="text-foreground font-semibold text-base sm:text-lg truncate">
{session.name}
</h3>
)}
<p className="text-foreground font-medium text-sm truncate">
{session?.email}
</p>
</div>
</div>
</div>
{/* Additional Profile Details */}
<div className="border-t border-border pt-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 text-xs">
<div>
<span className="text-muted-foreground block">Organization</span>
<span className="text-foreground font-medium">
{organizations?.name || "Personal"}
</span>
</div>
<div>
<span className="text-muted-foreground block">Member since</span>
<span className="text-foreground font-medium">
{session?.createdAt
? new Date(session.createdAt).toLocaleDateString("en-US", {
month: "short",
year: "numeric",
})
: "Recent"}
</span>
</div>
</div>
</div>
</div>
{/* Billing Section */}
{autumn.isLoading || isCheckingStatus ? (
<div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3">
<div className="flex items-center gap-3 mb-3">
<Skeleton className="w-8 h-8 sm:w-10 sm:h-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
<div className="flex justify-between items-center">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-8" />
</div>
<div className="pt-2">
<Skeleton className="h-8 w-full rounded" />
</div>
</div>
) : (
<div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-muted rounded-full flex items-center justify-center">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
</div>
<div className="flex-1">
<HeadingH3Bold className="text-foreground text-sm">
{hasProProduct ? "Pro Plan" : "Free Plan"}
{isPastDue ? (
<span className="ml-2 text-xs bg-red-500/20 text-red-600 dark:text-red-400 px-2 py-0.5 rounded-full">
Past Due
</span>
) : isPro ? (
<span className="ml-2 text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full">
Active
</span>
) : null}
</HeadingH3Bold>
<p className="text-muted-foreground text-xs">
{hasProProduct ? "Expanded memory capacity" : "Basic plan"}
</p>
</div>
</div>
{isPastDue && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-600 dark:text-red-400 font-medium">
Payment Required
</p>
<p className="text-xs text-red-600/80 dark:text-red-400/80 mt-1">
Your payment is past due. Please update your payment method to
restore access to Pro features.
</p>
</div>
</div>
)}
{/* Usage Stats */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Memories</span>
{hasProProduct ? (
<span className="text-sm text-foreground">Unlimited</span>
) : (
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
>
{memoriesUsed} / {memoriesLimit}
</span>
)}
</div>
{!hasProProduct && (
<div className="w-full bg-muted-foreground/50 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>
{hasProProduct && (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Connections</span>
<span className="text-sm text-foreground">
{connectionsUsed} / 10
</span>
</div>
)}
{/* Billing Actions */}
<div className="pt-2">
{isPastDue ? (
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white border-0"
onClick={handleManageBilling}
size="sm"
variant="default"
>
Pay Past Due
</Button>
) : hasProProduct ? (
<Button
className="w-full"
onClick={handleManageBilling}
size="sm"
variant="default"
>
Manage Billing
</Button>
) : (
<Button
className="w-full bg-[#267ffa] hover:bg-[#267ffa]/90 text-white border-0"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
size="lg"
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
<span className="hidden sm:inline">Upgrading...</span>
<span className="sm:hidden">Loading...</span>
</>
) : (
<>
<span className="hidden sm:inline">
Upgrade to Pro - $9/month
</span>
<span className="sm:hidden">Upgrade to Pro</span>
</>
)}
</Button>
)}
</div>
{!hasProProduct && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
{/* Free Plan */}
<div className="p-3 bg-muted/50 rounded-lg border border-border">
<h4 className="font-medium text-foreground mb-3 text-sm">
Free Plan
</h4>
<ul className="space-y-2">
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
200 memories
</li>
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<X className="h-3 w-3 sm:h-4 sm:w-4 text-red-500 flex-shrink-0" />
No connections
</li>
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
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-foreground mb-3 text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span>Pro Plan</span>
<span className="text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
Recommended
</span>
</div>
</h4>
<ul className="space-y-2">
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
<CheckCircle className="h-4 w-4 text-green-400" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
10 connections
</li>
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
Advanced search
</li>
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
Priority support
</li>
</ul>
</div>
</div>
<p className="text-xs text-muted-foreground text-center leading-relaxed">
$9/month (only for first 100 users) Cancel anytime. No
questions asked.
</p>
</div>
)}
</div>
)}
{/* Delete Account */}
{!session?.isAnonymous && org && (
<div className="pt-4 mt-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Delete account</p>
<p className="text-xs text-muted-foreground">
Permanently delete your data and cancel subscription
</p>
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-500/10"
>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete account?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
This will permanently delete your memories, connections,
settings, and cancel any subscriptions.
</p>
<p className="text-foreground">
Type{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-red-500">
DELETE
</code>{" "}
to confirm:
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<Input
value={deleteConfirmation}
onChange={(e) => setDeleteConfirmation(e.target.value)}
placeholder="DELETE"
autoComplete="off"
/>
{deleteError && (
<p className="text-sm text-red-500">{deleteError}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setDeleteConfirmation("")
setDeleteError(null)
}}
>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmation !== "DELETE" || isDeleting}
>
{isDeleting ? "Deleting..." : "Delete account"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
</div>
)
}

View file

@ -1,749 +0,0 @@
"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>
)
}

View file

@ -5,6 +5,7 @@ import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
@ -34,6 +35,7 @@ export function useDocumentMutations({
onClose,
}: UseDocumentMutationsOptions = {}) {
const queryClient = useQueryClient()
const { user } = useAuth()
const noteMutation = useMutation({
mutationFn: async ({
@ -47,6 +49,7 @@ export function useDocumentMutations({
body: {
content: content,
containerTags: [project],
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
metadata: {
sm_source: "consumer",
},
@ -134,6 +137,7 @@ export function useDocumentMutations({
body: {
content: url,
containerTags: [project],
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
metadata: {
sm_source: "consumer",
},
@ -230,6 +234,10 @@ export function useDocumentMutations({
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
formData.append(
"entityContext",
`This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
)
formData.append(
"metadata",
JSON.stringify({

View file

@ -1,26 +0,0 @@
"use client"
import { useQueryClient } from "@tanstack/react-query"
import { useMemo } from "react"
import { useProject } from "@/stores"
/**
* Returns the display name of the currently selected project.
* Falls back to the containerTag / id if a matching project record
* hasnt been fetched yet.
*/
export function useProjectName() {
const { selectedProject } = useProject()
const queryClient = useQueryClient()
// This query is populated by ProjectsView we just read from the cache.
const projects = queryClient.getQueryData(["projects"]) as
| Array<{ name: string; containerTag: string }>
| undefined
return useMemo(() => {
if (selectedProject === "sm_project_default") return "Default Project"
const found = projects?.find((p) => p.containerTag === selectedProject)
return found?.name ?? selectedProject
}, [projects, selectedProject])
}

View file

@ -1,23 +0,0 @@
import { useEffect, useState } from "react"
export default function useResizeObserver<T extends HTMLElement>(
ref: React.RefObject<T | null>,
) {
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
if (!ref.current) return
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry?.contentRect.width ?? 0,
height: entry?.contentRect.height ?? 0,
})
})
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref])
return size
}

View file

@ -1,32 +0,0 @@
"use client"
import { createContext, type ReactNode, useContext, useState } from "react"
type ActivePanel = "menu" | "chat" | null
interface MobilePanelContextType {
activePanel: ActivePanel
setActivePanel: (panel: ActivePanel) => void
}
const MobilePanelContext = createContext<MobilePanelContextType | undefined>(
undefined,
)
export function MobilePanelProvider({ children }: { children: ReactNode }) {
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
return (
<MobilePanelContext.Provider value={{ activePanel, setActivePanel }}>
{children}
</MobilePanelContext.Provider>
)
}
export function useMobilePanel() {
const context = useContext(MobilePanelContext)
if (!context) {
throw new Error("useMobilePanel must be used within a MobilePanelProvider")
}
return context
}

View file

@ -0,0 +1,28 @@
import {
parseAsString,
parseAsBoolean,
parseAsStringLiteral,
parseAsArrayOf,
} from "nuqs"
// Modal states
export const addDocumentParam = parseAsStringLiteral([
"note",
"link",
"file",
"connect",
] as const)
export const mcpParam = parseAsBoolean.withDefault(false)
export const searchParam = parseAsBoolean.withDefault(false)
export const qParam = parseAsString.withDefault("")
export const docParam = parseAsString
export const fullscreenParam = parseAsBoolean.withDefault(false)
export const chatParam = parseAsBoolean
export const threadParam = parseAsString
export const shareParam = parseAsBoolean.withDefault(false)
export const feedbackParam = parseAsBoolean.withDefault(false)
// View & filter states
export const viewParam = parseAsStringLiteral(["graph", "list"] as const).withDefault("graph")
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault([])
export const projectParam = parseAsString.withDefault("sm_project_default")

View file

@ -1,96 +1,22 @@
"use client"
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react"
import { useQueryState } from "nuqs"
import { viewParam } from "@/lib/search-params"
import { analytics } from "@/lib/analytics"
import { useCallback } from "react"
type ViewMode = "graph" | "list"
interface ViewModeContextType {
viewMode: ViewMode
setViewMode: (mode: ViewMode) => void
isInitialized: boolean
}
const ViewModeContext = createContext<ViewModeContextType | undefined>(
undefined,
)
// Cookie utility functions
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null
const nameEQ = `${name}=`
const ca = document.cookie.split(";")
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
if (!c) continue
while (c.charAt(0) === " ") c = c.substring(1, c.length)
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
}
return null
}
const isMobileDevice = () => {
if (typeof window === "undefined") return false
return window.innerWidth < 768
}
export function ViewModeProvider({ children }: { children: ReactNode }) {
// Start with a default that works for SSR
const [viewMode, setViewModeState] = useState<ViewMode>("graph")
const [isInitialized, setIsInitialized] = useState(false)
// Load preferences on the client side
useEffect(() => {
if (!isInitialized) {
// Check for saved preference first
const savedMode = getCookie("memoryViewMode")
if (savedMode === "list" || savedMode === "graph") {
setViewModeState(savedMode)
} else {
// If no saved preference, default to list on mobile, graph on desktop
setViewModeState(isMobileDevice() ? "list" : "graph")
}
setIsInitialized(true)
}
}, [isInitialized])
// Save to cookie whenever view mode changes
const handleSetViewMode = (mode: ViewMode) => {
analytics.viewModeChanged(mode)
setViewModeState(mode)
setCookie("memoryViewMode", mode)
}
return (
<ViewModeContext.Provider
value={{
viewMode,
setViewMode: handleSetViewMode,
isInitialized,
}}
>
{children}
</ViewModeContext.Provider>
)
}
export function useViewMode() {
const context = useContext(ViewModeContext)
if (!context) {
throw new Error("useViewMode must be used within a ViewModeProvider")
}
return context
const [viewMode, _setViewMode] = useQueryState("view", viewParam)
const setViewMode = useCallback(
(mode: ViewMode) => {
analytics.viewModeChanged(mode)
_setViewMode(mode)
},
[_setViewMode],
)
return { viewMode, setViewMode, isInitialized: true }
}

View file

@ -1,99 +1,23 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
"use client"
interface ProjectState {
selectedProject: string
setSelectedProject: (projectId: string) => void
}
export const useProjectStore = create<ProjectState>()(
persist(
(set) => ({
selectedProject: "sm_project_default",
setSelectedProject: (projectId) => set({ selectedProject: projectId }),
}),
{
name: "selectedProject",
},
),
)
interface MemoryGraphState {
positionX: number
positionY: number
setPositionX: (x: number) => void
setPositionY: (y: number) => void
setPosition: (x: number, y: number) => void
}
export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
positionX: 0,
positionY: 0,
setPositionX: (x) => set({ positionX: x }),
setPositionY: (y) => set({ positionY: y }),
setPosition: (x, y) => set({ positionX: x, positionY: y }),
}))
interface ChatState {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
toggleChat: () => void
}
export const useChatStore = create<ChatState>()((set, get) => ({
isOpen: false,
setIsOpen: (isOpen) => set({ isOpen }),
toggleChat: () => set({ isOpen: !get().isOpen }),
}))
import { useQueryState } from "nuqs"
import { projectParam } from "@/lib/search-params"
import { useCallback } from "react"
export function useProject() {
const selectedProject = useProjectStore((state) => state.selectedProject)
const setSelectedProject = useProjectStore(
(state) => state.setSelectedProject,
const [selectedProject, _setSelectedProject] = useQueryState(
"project",
projectParam,
)
const setSelectedProject = useCallback(
(projectId: string) => {
_setSelectedProject(projectId)
},
[_setSelectedProject],
)
return { selectedProject, setSelectedProject }
}
export function useMemoryGraphPosition() {
const positionX = useMemoryGraphStore((state) => state.positionX)
const positionY = useMemoryGraphStore((state) => state.positionY)
const setPositionX = useMemoryGraphStore((state) => state.setPositionX)
const setPositionY = useMemoryGraphStore((state) => state.setPositionY)
const setPosition = useMemoryGraphStore((state) => state.setPosition)
return {
x: positionX,
y: positionY,
setX: setPositionX,
setY: setPositionY,
setPosition,
}
}
export function useChatOpen() {
const isOpen = useChatStore((state) => state.isOpen)
const setIsOpen = useChatStore((state) => state.setIsOpen)
const toggleChat = useChatStore((state) => state.toggleChat)
return { isOpen, setIsOpen, toggleChat }
}
interface GraphModalState {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
toggleGraphModal: () => void
}
export const useGraphModalStore = create<GraphModalState>()((set, get) => ({
isOpen: false,
setIsOpen: (isOpen) => set({ isOpen }),
toggleGraphModal: () => set({ isOpen: !get().isOpen }),
}))
export function useGraphModal() {
const isOpen = useGraphModalStore((state) => state.isOpen)
const setIsOpen = useGraphModalStore((state) => state.setIsOpen)
const toggleGraphModal = useGraphModalStore((state) => state.toggleGraphModal)
return { isOpen, setIsOpen, toggleGraphModal }
}
export { usePersistentChat, usePersistentChatStore } from "./chat"

View file

@ -157,6 +157,12 @@ export const MemoryUpdateSchema = z.object({
"Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.",
example: "mem_abc123",
}),
entityContext: z.string().max(1500).optional().openapi({
description:
"Context for memory extraction on this container tag. Helps guide how memories are extracted and understood.",
example:
"This user is John, saving items in a personal knowledge management system.",
}),
metadata: MetadataSchema.optional().openapi({
description:
"Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.",