feat: layout design with theme improvements (#443)

feat: layout design with theme improvements

new improvements on light mode layout

chore: settings page with graph header and memories list
This commit is contained in:
MaheshtheDev 2025-10-01 21:59:54 +00:00
parent 60794f63e2
commit b148296f50
64 changed files with 4065 additions and 10228 deletions

View file

@ -0,0 +1,39 @@
"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, getCurrentChat } = usePersistentChat()
const chatId = params.id as string
useEffect(() => {
if (chatId) {
setCurrentChatId(chatId)
}
}, [chatId, setCurrentChatId])
const currentChat = getCurrentChat()
return (
<div className="flex flex-col w-full">
<div className="flex flex-col h-[93vh]">
<div className="flex justify-center w-full">
<h1 className="text-lg font-semibold flex-1 truncate text-center">
{currentChat?.title || "New Chat"}
</h1>
</div>
<div className="flex-1 flex justify-center min-h-0 w-full px-4">
<div className="flex flex-col min-h-0 w-full max-w-4xl">
<ChatMessages />
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,57 @@
"use client"
import { Header } from "@/components/header"
import { AddMemoryView } from "@/components/views/add-memory"
import { useEffect, useState } from "react"
export default function NavigationLayout({
children,
}: {
children: React.ReactNode
}) {
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
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 min-h-screen">
<div className="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-white/10">
<Header onAddMemory={() => setShowAddMemoryView(true)} />
</div>
{children}
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,81 @@
"use client"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useAuth } from "@lib/auth-context"
import { ChevronsDown, LoaderIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { InstallPrompt } from "@/components/install-prompt"
import { ChatInput } from "@/components/chat-input"
import { BackgroundPlus } from "@ui/components/grid-plus"
import { Memories } from "@/components/memories"
export default function Page() {
const { user, session } = useAuth()
const { shouldShowOnboarding, isLoading: onboardingLoading } =
useOnboardingStorage()
const router = useRouter()
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 }, "*")
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}
}, [user, session])
useEffect(() => {
if (user && !onboardingLoading && shouldShowOnboarding()) {
router.push("/onboarding")
}
}, [user, shouldShowOnboarding, onboardingLoading, router])
if (!user || onboardingLoading) {
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 />
</div>
)
}

View file

@ -0,0 +1,12 @@
"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

@ -0,0 +1,10 @@
"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

@ -0,0 +1,52 @@
"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

@ -0,0 +1,12 @@
"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

@ -0,0 +1,125 @@
"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/supermemoryai", "_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 5,000 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/supermemoryai", "_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

@ -1,5 +1,5 @@
import type { Metadata } from "next"
import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google"
import { Space_Grotesk } from "next/font/google"
import "../globals.css"
import "@ui/globals.css"
import { AuthProvider } from "@lib/auth-context"
@ -11,25 +11,15 @@ import { Suspense } from "react"
import { Toaster } from "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 sans = Inter({
const font = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
})
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
})
const serif = Instrument_Serif({
subsets: ["latin"],
variable: "--font-serif",
weight: ["400"],
})
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
@ -42,33 +32,39 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html className="dark bg-sm-black" lang="en">
<body
className={`${sans.variable} ${mono.variable} ${serif.variable} antialiased bg-[#0f1419] overflow-x-hidden`}
>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
}
includeCredentials={true}
<html lang="en" suppressHydrationWarning>
<body className={`${font.variable} antialiased overflow-x-hidden`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster richColors theme="dark" />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ??
"https://api.supermemory.ai"
}
includeCredentials={true}
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster richColors />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
</ThemeProvider>
</body>
</html>
)

View file

@ -1,53 +1,62 @@
'use client';
import { useEffect } from 'react';
import { TextEffect } from '@/components/text-effect';
"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,
},
},
},
};
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'
per='char'
variants={blurSlideVariants}
trigger={trigger}
delay={delay}
>
{children}
</TextEffect>
);
return (
<TextEffect
className="inline-flex font-medium"
style={{ letterSpacing: "-3px" }}
per="char"
variants={blurSlideVariants}
trigger={trigger}
delay={delay}
>
{children}
</TextEffect>
)
}

View file

@ -1,213 +0,0 @@
"use client";
import { motion, type Transition } from "framer-motion";
import { Button } from "@ui/components/button";
import { useOnboarding } from "./onboarding-context";
import { $fetch } from "@lib/api";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ConnectionResponseSchema } from "@repo/validation/api";
import type { z } from "zod";
import { Check } from "lucide-react";
import { toast } from "sonner";
import { analytics } from "@/lib/analytics";
import { useProject } from "@/stores";
import { NavMenu } from "./nav-menu";
type Connection = z.infer<typeof ConnectionResponseSchema>;
const CONNECTORS = {
"google-drive": {
title: "Google Drive",
description: "Supermemory can use the documents and files in your Google Drive to better understand and assist you.",
iconSrc: "/images/gdrive.svg",
},
notion: {
title: "Notion",
description: "Help Supermemory understand how you organize your life and what you have going on by connecting your Notion account.",
iconSrc: "/images/notion.svg",
},
onedrive: {
title: "OneDrive",
description: "By integrating with OneDrive, Supermemory can better understand both your previous and your current work.",
iconSrc: "/images/onedrive.svg",
},
} as const;
type ConnectorProvider = keyof typeof CONNECTORS;
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.15, delayChildren: 0.1 } satisfies Transition,
},
};
const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } satisfies Transition,
},
};
function ConnectionCard({
title,
description,
iconSrc,
isConnected = false,
onConnect,
isConnecting = false
}: {
title: string;
description: string;
iconSrc: string;
isConnected?: boolean;
onConnect?: () => void;
isConnecting?: boolean;
}) {
return (
<div className="flex items-center max-sm:items-start max-sm:gap-3 max-sm:flex-col shadow-lg bg-white rounded-xl py-4 px-6 gap-6 border border-zinc-200">
<img src={iconSrc} alt={title} className="size-10 max-sm:hidden" />
<div className="flex flex-col gap-0.5 max-sm:gap-2">
<div className="flex items-center gap-3">
<img src={iconSrc} alt={title} className="size-5 sm:hidden" />
<h3 className="text-lg font-medium">{title}</h3>
</div>
<p className="text-zinc-600 text-sm">{description}</p>
</div>
<div className="sm:ml-auto max-sm:w-full">
{isConnected ? (
<Button
variant="outline"
className="max-sm:w-full border border-green-200 bg-green-50 text-green-700 hover:bg-green-100 cursor-default"
disabled
>
<Check className="w-4 h-4 mr-2" />
Connected
</Button>
) : (
<Button
variant="outline"
className="max-sm:w-full border border-zinc-200! hover:text-zinc-900 text-zinc-900 cursor-pointer"
onClick={onConnect}
disabled={isConnecting}
>
Connect
</Button>
)}
</div>
</div>
);
}
export function ConnectionsForm() {
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
const { selectedProject } = useProject();
const { data: connections = [] } = 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,
});
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: [selectedProject],
},
});
// biome-ignore lint/style/noNonNullAssertion: its fine
if ("data" in response && !("error" in response.data!)) {
return response.data;
}
throw new Error(response.error?.message || "Failed to connect");
},
onSuccess: (data, provider) => {
analytics.connectionAdded(provider);
analytics.connectionAuthStarted();
if (data?.authLink) {
window.location.href = data.authLink;
}
},
onError: (error, provider) => {
analytics.connectionAuthFailed();
toast.error(`Failed to connect ${provider}`, {
description: error instanceof Error ? error.message : "Unknown error",
});
},
});
function isConnectorConnected(provider: ConnectorProvider): boolean {
return connections.some(connection => connection.provider === provider);
}
function handleConnect(provider: ConnectorProvider) {
addConnectionMutation.mutate(provider);
}
return (
<div className="relative">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-zinc-600">
Step {getStepNumberFor("connections")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl">Connect your accounts</h1>
<p className="text-zinc-600 text-2xl max-sm:text-lg">
Help Supermemory get to know you and your documents better
{/* The more context you provide, the better Supermemory becomes */}
{/* Supermemory understands your needs and goals better with more context */}
{/* Supermemory understands you better when it integrates with your apps */}
</p>
</div>
<motion.div className="font-sans text-base mt-8 font-normal tracking-normal flex flex-col gap-4 max-w-prose" initial="hidden" animate="visible" variants={containerVariants}>
{Object.entries(CONNECTORS).map(([provider, config]) => {
const providerKey = provider as ConnectorProvider;
const isConnected = isConnectorConnected(providerKey);
const isConnecting = addConnectionMutation.isPending && addConnectionMutation.variables === providerKey;
return (
<motion.div key={provider} variants={itemVariants}>
<ConnectionCard
title={config.title}
description={config.description}
iconSrc={config.iconSrc}
isConnected={isConnected}
onConnect={() => handleConnect(providerKey)}
isConnecting={isConnecting}
/>
</motion.div>
);
})}
</motion.div>
<div className="flex justify-end mt-4">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}>
Skip For Now
</Button>
</div>
</div>
);
}

View file

@ -636,7 +636,9 @@ export function ExtensionForm() {
Step {getStepNumberFor("extension")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-white">Install the Chrome extension</h1>
<h1 className="text-white font-medium">
Install the Chrome extension
</h1>
<p className="text-white/80 text-2xl">
{/* Install the Supermemory extension to start saving and organizing everything that matters. */}
Bring Supermemory everywhere
@ -706,7 +708,7 @@ export function ExtensionForm() {
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
className="text-black/40 hover:text-black font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue

View file

@ -67,19 +67,19 @@ function FloatingOrb({ size, initialX, initialY, duration, delay, revealDelay, s
return {
x: {
duration: shouldReveal ? duration : 0,
repeat: shouldReveal ? Infinity : 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 ? Infinity : 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 ? Infinity : 0,
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
delay: shouldReveal ? delay + revealDelay : revealDelay,
},
@ -163,7 +163,8 @@ export function FloatingOrbs() {
// Generate orb configurations positioned along edges
const newOrbs = Array.from({ length: 8 }, (_, i) => {
let x, y;
let x: number;
let y: number;
const zone = i % 4; // Rotate through 4 zones: top, right, bottom, left
switch (zone) {

View file

@ -1,114 +1,85 @@
"use client";
"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 { ArrowRightIcon } from "lucide-react";
import { useOnboarding } from "./onboarding-context";
import { useIsMobile } from "@hooks/use-mobile";
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();
const isMobile = useIsMobile();
const { nextStep, introTriggers: triggers } = useOnboarding()
return (
<motion.div
className="flex flex-col gap-4 relative max-sm:text-4xl max-sm:w-full text-white"
layout
transition={{
layout: { duration: 0.8, ease: "anticipate" }
}}
>
<AnimatePresence mode="popLayout">
{triggers.first && (
<motion.div
key="first"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
opacity: { duration: 0.3 },
layout: { duration: 0.8, ease: "easeInOut" }
}}
>
{isMobile ? (
<div className="flex flex-col">
<AnimatedText trigger={triggers.first} delay={0}>
Still looking for your
</AnimatedText>
<AnimatedText trigger={triggers.first} delay={0.1}>
other half?
</AnimatedText>
</div>
) : (
<AnimatedText trigger={triggers.first} delay={0}>
Still looking for your other half?
</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}>
Don't worry.
</AnimatedText>
</motion.div>
)}
{triggers.third && (
<motion.div
key="third"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
opacity: { duration: 0.3 },
layout: { duration: 0.8, ease: "easeInOut" }
}}
>
<AnimatedText trigger={triggers.third} delay={0.4}>
It's right here.
{/* It's right in front of you. */}
{/* You're looking at it. */}
</AnimatedText>
</motion.div>
)}
</AnimatePresence>
<motion.div
key="fourth"
className="absolute -bottom-16 left-0"
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={"link"}
size={"lg"}
className={cn("text-white/60 flex items-center gap-2 hover:text-white/90 text-2xl underline group font-normal w-fit px-0! cursor-pointer", !triggers.fourth && "pointer-events-none")}
style={{
transform: triggers.fourth ? "scale(1)" : "scale(0.95)"
}}
onClick={nextStep}
>
Meet Supermemory
<ArrowRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform duration-200" />
</Button>
</motion.div>
</motion.div>
)
}
return (
<motion.div
className="flex flex-col gap-4 relative max-sm:text-4xl max-sm: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

@ -63,7 +63,6 @@ export function NameForm() {
<div className="relative flex flex-col">
<input
type="text"
autoFocus
name="name"
autoComplete="name"
autoCorrect="off"

View file

@ -110,7 +110,7 @@ export function OnboardingProvider({
}, 400),
]
return () => cleanups.forEach((cleanup) => clearTimeout(cleanup))
return () => cleanups.forEach(clearTimeout)
}, [currentStep])
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step

View file

@ -15,7 +15,7 @@ export function Welcome() {
return (
<div className="flex flex-col gap-4 items-center text-center">
<h1 className="text-white">Welcome to Supermemory</h1>
<h1 className="text-white font-medium">Welcome to Supermemory</h1>
<p className="text-white/80 text-2xl">
We're excited to have you on board.
</p>

View file

@ -1,622 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import { MemoryGraph } from "@repo/ui/memory-graph"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import {
HelpCircle,
LayoutGrid,
List,
LoaderIcon,
MessageSquare,
Unplug,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { InstallPrompt } from "@/components/install-prompt"
import { MemoryListView } from "@/components/memory-list-view"
import Menu from "@/components/menu"
import { ProjectSelector } from "@/components/project-selector"
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
import { AddMemoryView } from "@/components/views/add-memory"
import { ChatRewrite } from "@/components/views/chat"
import { useViewMode } from "@/lib/view-mode-context"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const isMobile = useIsMobile()
const { viewMode, setViewMode } = useViewMode()
const { selectedProject } = useProject()
const { isOpen, setIsOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showReferralModal, setShowReferralModal] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const [isHelpHovered, setIsHelpHovered] = useState(false)
// Fetch projects meta to detect experimental flag
const { data: projectsMeta = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
return response.data?.projects ?? []
},
staleTime: 5 * 60 * 1000,
})
const isCurrentProjectExperimental = !!projectsMeta.find(
(p: any) => p.containerTag === selectedProject,
)?.isExperimental
// Progressive loading via useInfiniteQuery
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) => {
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,
})
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])
// Reset injected docs when project changes
useEffect(() => {
setInjectedDocs([])
}, [selectedProject])
// Surgical fetch of missing highlighted documents (customId-based IDs from search)
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 as any).customId) present.add((d as any).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 as any)?.error) return
const extraDocs = (resp as any)?.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.join("|"),
baseDocuments,
injectedDocs,
selectedProject,
$fetch,
])
// Handle view mode change
const handleViewModeChange = useCallback(
(mode: "graph" | "list") => {
setViewMode(mode)
},
[setViewMode],
)
useEffect(() => {
if (allDocuments.length === 0) {
setShowConnectAIModal(true)
}
}, [allDocuments.length])
// Prevent body scrolling
useEffect(() => {
document.body.style.overflow = "hidden"
document.body.style.height = "100vh"
document.documentElement.style.overflow = "hidden"
document.documentElement.style.height = "100vh"
return () => {
document.body.style.overflow = ""
document.body.style.height = ""
document.documentElement.style.overflow = ""
document.documentElement.style.height = ""
}
}, [])
return (
<div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none">
{/* Main content area */}
<motion.div
animate={{
marginRight: isOpen && !isMobile ? 600 : 0,
}}
className="h-full relative"
transition={{
duration: 0.2,
ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth
}}
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden"
initial={{ opacity: 0, y: -20 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 p-2 flex gap-1">
<motion.button
animate={{
color: viewMode === "graph" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("graph")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "graph" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<LayoutGrid className="w-4 h-4" />
<span className="hidden md:inline">Graph</span>
</span>
</motion.button>
<motion.button
animate={{
color: viewMode === "list" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("list")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "list" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<List className="w-4 h-4" />
<span className="hidden md:inline">List</span>
</span>
</motion.button>
</div>
</motion.div>
{/* Animated content switching */}
<AnimatePresence mode="wait">
{viewMode === "graph" ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="graph"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryGraph
autoLoadOnViewport={false}
documents={allDocuments}
error={error}
hasMore={hasMore}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
isExperimental={isCurrentProjectExperimental}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={undefined}
loadMoreDocuments={loadMoreDocuments}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
showSpacesSelector={false}
totalLoaded={totalLoaded}
variant="consumer"
>
<div className="absolute inset-0 flex items-center justify-center">
<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">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<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>
</MemoryGraph>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 md:ml-18"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="list"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryListView
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
>
<div className="absolute inset-0 flex items-center justify-center">
<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">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<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>
</MemoryListView>
</motion.div>
)}
</AnimatePresence>
{/* Top Bar */}
<div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between">
<div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start">
<Link
className="pointer-events-auto"
href="https://supermemory.ai"
rel="noopener noreferrer"
target="_blank"
>
<LogoFull className="h-8 hidden md:block" />
<Logo className="h-8 md:hidden" />
</Link>
<div className="hidden sm:block">
<ProjectSelector />
</div>
<ConnectAIModal>
<Button
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
size="sm"
variant="outline"
>
<Unplug className="h-4 w-4" />
<span className="hidden sm:inline ml-2">
Connect to your AI
</span>
<span className="sm:hidden ml-1">Connect AI</span>
</Button>
</ConnectAIModal>
</div>
<div>
<Menu />
</div>
</div>
{/* Floating Open Chat Button */}
{!isOpen && !isMobile && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="fixed bottom-6 right-6 z-50"
initial={{ opacity: 0, scale: 0.8 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
}}
>
<Button
className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer"
onClick={() => setIsOpen(true)}
size="lg"
>
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Open Chat</span>
</Button>
</motion.div>
)}
<button
className="fixed bottom-6 left-6 z-50 flex items-center overflow-hidden rounded-full shadow-2xl bg-transparent border-none cursor-pointer"
onMouseEnter={() => setIsHelpHovered(true)}
onMouseLeave={() => setIsHelpHovered(false)}
type="button"
>
<div className="absolute inset-0 rounded-full">
<GlassMenuEffect rounded="rounded-full" />
</div>
<div className="relative z-10 p-3 text-white">
<HelpCircle className="h-5 w-5" />
</div>
<div
className={`relative z-10 flex items-center text-white transition-all duration-300 overflow-hidden ${
isHelpHovered
? "opacity-100 max-w-32 pr-4 pl-0 py-3"
: "opacity-0 max-w-0 px-0 py-3"
}`}
>
<a
className="flex items-center gap-2 text-sm font-medium hover:text-white/80 transition-colors whitespace-nowrap"
href="mailto:dhravya@supermemory.com"
>
<span>Need Help?</span>
</a>
</div>
</button>
</motion.div>
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
style={{
width: isOpen ? (isMobile ? "100vw" : "600px") : 0,
pointerEvents: isOpen ? "auto" : "none",
}}
>
<motion.div
animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }}
className="absolute inset-0"
exit={{ x: isMobile ? "100%" : 600 }}
initial={{ x: isMobile ? "100%" : 600 }}
key="chat"
transition={{
type: "spring",
stiffness: 500,
damping: 40,
}}
>
<ChatRewrite />
</motion.div>
</motion.div>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
)
}
// Wrapper component to handle auth and waitlist checks
export default function Page() {
const { user, session } = useAuth()
const { shouldShowOnboarding, isLoading: onboardingLoading } =
useOnboardingStorage()
const router = useRouter()
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 }, "*")
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}
}, [user, session])
useEffect(() => {
if (user && !onboardingLoading && shouldShowOnboarding()) {
router.push("/onboarding")
}
}, [user, shouldShowOnboarding, onboardingLoading, router])
if (!user || onboardingLoading) {
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 (
<>
<MemoryGraphPage />
<InstallPrompt />
</>
)
}

View file

@ -1,58 +0,0 @@
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,80 @@
"use client"
import { useState } 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"
export function ChatInput() {
const [message, setMessage] = useState("")
const router = useRouter()
const { setCurrentChatId } = usePersistentChat()
const handleSend = () => {
if (!message.trim()) return
const newChatId = generateId()
setCurrentChatId(newChatId)
// Store the initial message in sessionStorage for the chat page to pick up
sessionStorage.setItem(`chat-initial-${newChatId}`, message.trim())
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">
Good evening, <span className="text-primary">Mahesh</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 bg-accent py-2 px-3 rounded-b-[14px]">
<ProjectSelector />
<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>
</form>
</div>
</div>
</div>
)
}

View file

@ -193,7 +193,7 @@ export function ConnectAIModal({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Connect Supermemory to Your AI</DialogTitle>
<DialogTitle>Connect supermemory to Your AI</DialogTitle>
<DialogDescription>
Connect supermemory to your favorite AI tools using the Model
Context Protocol (MCP). This allows your AI assistant to create,
@ -205,9 +205,7 @@ export function ConnectAIModal({
{/* Step 1: Client Selection */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${"bg-white/10 text-white/60"}`}
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium bg-muted text-muted-foreground">
1
</div>
<h3 className="text-sm font-medium">Select Your AI Client</h3>
@ -220,8 +218,8 @@ export function ConnectAIModal({
<button
className={`pr-3 pl-1 rounded-full border cursor-pointer transition-all ${
selectedClient === key
? "border-blue-500 bg-blue-500/10"
: "border-white/10 hover:border-white/20 hover:bg-white/5"
? "border-primary bg-primary/10"
: "border-border hover:border-border/60 hover:bg-muted/50"
}`}
key={key}
onClick={() =>
@ -233,7 +231,7 @@ export function ConnectAIModal({
<div className="w-8 h-8 flex items-center justify-center">
<Image
alt={clientName}
className="rounded object-contain text-white fill-white"
className="rounded object-contain"
height={20}
onError={(e) => {
const target = e.target as HTMLImageElement
@ -245,7 +243,7 @@ export function ConnectAIModal({
) {
const fallback = document.createElement("span")
fallback.className =
"fallback-text text-sm font-bold text-white/40"
"fallback-text text-sm font-bold text-muted-foreground"
fallback.textContent = clientName
.substring(0, 2)
.toUpperCase()
@ -260,7 +258,7 @@ export function ConnectAIModal({
width={20}
/>
</div>
<span className="text-sm font-medium text-white/80">
<span className="text-sm font-medium text-foreground/80">
{clientName}
</span>
</div>
@ -274,7 +272,7 @@ export function ConnectAIModal({
<div className="space-y-4">
<div className="flex justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
2
</div>
<h3 className="text-sm font-medium">
@ -294,12 +292,12 @@ export function ConnectAIModal({
>
{/* Tabs */}
<div className="flex justify-end">
<div className="flex bg-white/5 rounded-full p-1 border border-white/10">
<div className="flex bg-muted/50 rounded-full p-1 border border-border">
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
cursorInstallTab === "oneClick"
? "bg-white/10 text-white border border-white/20"
: "text-white/60 hover:text-white/80"
? "bg-background text-foreground border border-border shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setCursorInstallTab("oneClick")}
type="button"
@ -309,8 +307,8 @@ export function ConnectAIModal({
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
cursorInstallTab === "manual"
? "bg-white/10 text-white border border-white/20"
: "text-white/60 hover:text-white/80"
? "bg-background text-foreground border border-border shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setCursorInstallTab("manual")}
type="button"
@ -329,11 +327,11 @@ export function ConnectAIModal({
<div className="space-y-4">
<div className="flex flex-col items-center gap-4 p-6 border border-green-500/20 rounded-lg bg-green-500/5">
<div className="text-center">
<p className="text-sm text-white/80 mb-2">
<p className="text-sm text-foreground/80 mb-2">
Click the button below to automatically install and
configure Supermemory in Cursor
</p>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
This will install the MCP server without any
additional setup required
</p>
@ -353,13 +351,13 @@ export function ConnectAIModal({
/>
</a>
</div>
<p className="text-xs text-white/40 text-center">
<p className="text-xs text-muted-foreground/60 text-center">
Make sure you have Cursor installed on your system
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Choose a project and follow the installation steps below
</p>
<div className="max-w-md">
@ -371,17 +369,11 @@ export function ConnectAIModal({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value="none"
>
<SelectContent>
<SelectItem value="none">
Auto-select project
</SelectItem>
<SelectItem
className="text-white hover:bg-white/10"
value="sm_project_default"
>
<SelectItem value="sm_project_default">
Default Project
</SelectItem>
{projects
@ -391,7 +383,6 @@ export function ConnectAIModal({
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.containerTag}
>
@ -426,7 +417,7 @@ export function ConnectAIModal({
<CopyIcon className="size-4" />
</Button>
</div>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
Use this URL to configure supermemory in your AI assistant
</p>
</div>
@ -440,17 +431,9 @@ export function ConnectAIModal({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value="none"
>
Auto-select project
</SelectItem>
<SelectItem
className="text-white hover:bg-white/10"
value="sm_project_default"
>
<SelectContent>
<SelectItem value="none">Auto-select project</SelectItem>
<SelectItem value="sm_project_default">
Default Project
</SelectItem>
{projects
@ -460,7 +443,6 @@ export function ConnectAIModal({
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.containerTag}
>
@ -480,7 +462,7 @@ export function ConnectAIModal({
(selectedClient !== "cursor" || cursorInstallTab === "manual") && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<h3 className="text-sm font-medium">
@ -506,7 +488,7 @@ export function ConnectAIModal({
</Button>
</div>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
{selectedClient === "cursor" && cursorInstallTab === "manual"
? "Copy and run this command in your terminal for manual installation (or switch to the one-click option above)"
: "Copy and run this command in your terminal to install the MCP server"}
@ -518,19 +500,19 @@ export function ConnectAIModal({
{!selectedClient && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<h3 className="text-sm font-medium">Installation Command</h3>
</div>
<div className="relative">
<div className="w-full h-10 bg-white/5 border border-white/10 rounded-md flex items-center px-3">
<div className="w-full h-4 bg-white/20 rounded animate-pulse blur-sm" />
<div className="w-full h-10 bg-muted border border-border rounded-md flex items-center px-3">
<div className="w-full h-4 bg-muted-foreground/20 rounded animate-pulse blur-sm" />
</div>
</div>
<p className="text-xs text-white/30">
<p className="text-xs text-muted-foreground/50">
Select a client above to see the installation command
</p>
</div>
@ -539,18 +521,18 @@ export function ConnectAIModal({
<div className="gap-2 hidden">
<div>
<label
className="text-sm font-medium text-white/80 block mb-2"
className="text-sm font-medium text-foreground/80 block mb-2"
htmlFor="mcp-server-url-desktop"
>
MCP Server URL
</label>
<p className="text-xs text-white/50 mt-2">
<p className="text-xs text-muted-foreground mt-2">
Use this URL to configure supermemory in your AI assistant
</p>
</div>
<div className="p-1 bg-white/5 rounded-lg border border-white/10 items-center flex px-2">
<div className="p-1 bg-muted rounded-lg border border-border items-center flex px-2">
<CopyableCell
className="font-mono text-xs text-blue-400"
className="font-mono text-xs text-primary"
value="https://api.supermemory.ai/mcp"
/>
</div>
@ -599,11 +581,11 @@ export function ConnectAIModal({
onOpenChange={setIsMigrateDialogOpen}
open={isMigrateDialogOpen}
>
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
<DialogContent className="sm:max-w-2xl bg-popover border-border text-popover-foreground">
<div>
<DialogHeader>
<DialogTitle>Migrate from MCP v1</DialogTitle>
<DialogDescription className="text-white/60">
<DialogDescription className="text-muted-foreground">
Migrate your MCP documents from the legacy system.
</DialogDescription>
</DialogHeader>
@ -623,7 +605,7 @@ export function ConnectAIModal({
{({ state, handleChange, handleBlur }) => (
<>
<Input
className="bg-white/5 border-white/10 text-white"
className="bg-input border-border text-foreground"
id="mcpUrl"
onBlur={handleBlur}
onChange={(e) => handleChange(e.target.value)}
@ -631,14 +613,14 @@ export function ConnectAIModal({
value={state.value}
/>
{state.meta.errors.length > 0 && (
<p className="text-sm text-red-400 mt-1">
<p className="text-sm text-destructive mt-1">
{state.meta.errors.join(", ")}
</p>
)}
</>
)}
</mcpMigrationForm.Field>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
Enter your old MCP Link in the format: <br />
<span className="font-mono">
https://mcp.supermemory.ai/userId/sse
@ -648,7 +630,6 @@ export function ConnectAIModal({
</div>
<div className="flex justify-end gap-3 mt-4">
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() => {
setIsMigrateDialogOpen(false)
mcpMigrationForm.reset()
@ -659,7 +640,6 @@ export function ConnectAIModal({
Cancel
</Button>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
disabled={
migrateMCPMutation.isPending ||
!mcpMigrationForm.state.canSubmit

View file

@ -0,0 +1,164 @@
"use client"
import { Card, CardContent } from "@repo/ui/components/card"
import { Badge } from "@repo/ui/components/badge"
import { ExternalLink, FileText, Brain } from "lucide-react"
import { useState } from "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
showExternalLink?: boolean
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
lastModified?: string | Date
}
export const GoogleDocsCard = ({
title,
url,
description,
className,
onClick,
showExternalLink = true,
activeMemories,
lastModified,
}: GoogleDocsCardProps) => {
const [imageError, setImageError] = useState(false)
const handleCardClick = () => {
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"),
}}
>
<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>
{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 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

@ -0,0 +1,133 @@
import { Badge } from "@repo/ui/components/badge"
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
import { colors } from "@repo/ui/memory-graph/constants"
import { Brain, ExternalLink } from "lucide-react"
import { cn } from "@lib/utils"
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,
}: NoteCardProps) => {
return (
<Card
className="w-full p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 shadow-xs"
onClick={() => {
analytics.documentCardClicked()
onOpenDetails(document)
}}
style={{
backgroundColor: getPastelBackgroundColor(
document.id || document.title || "note",
),
width: width,
}}
>
<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>
{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-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

@ -0,0 +1,105 @@
import { Suspense } 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 { Brain } 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,
}: {
data: Tweet
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
}) => {
return (
<div
className="relative transition-all"
style={{
backgroundColor: getPastelBackgroundColor(data.id_str || "tweet"),
}}
>
<CustomTweet components={{}} tweet={data} />
{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

@ -0,0 +1,103 @@
"use client"
import { Card, CardContent } from "@repo/ui/components/card"
import { ExternalLink } from "lucide-react"
import { useState } from "react"
import { cn } from "@lib/utils"
import { getPastelBackgroundColor } from "../memories-utils"
interface WebsiteCardProps {
title: string
url: string
image?: string
description?: string
className?: string
onClick?: () => void
showExternalLink?: boolean
}
export const WebsiteCard = ({
title,
url,
image,
description,
className,
onClick,
showExternalLink = true,
}: WebsiteCardProps) => {
const [imageError, setImageError] = useState(false)
const handleCardClick = () => {
if (onClick) {
onClick()
} 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",
className,
)}
onClick={handleCardClick}
style={{
backgroundColor: getPastelBackgroundColor(url || title || "website"),
}}
>
<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}
{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>
{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

@ -0,0 +1,175 @@
import { Button } from "@ui/components/button"
import { Logo, LogoFull } from "@ui/assets/Logo"
import Link from "next/link"
import { MoonIcon, Plus, SunIcon, MonitorIcon, Network } 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 { useAuth } from "@lib/auth-context"
import { ConnectAIModal } from "./connect-ai-modal"
import { useTheme } from "next-themes"
import { cn } from "@lib/utils"
import { useRouter } from "next/navigation"
import { MCPIcon } from "./menu"
import { authClient } from "@lib/auth"
import { analytics } from "@/lib/analytics"
import { useGraphModal } from "@/stores"
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
const { user } = useAuth()
const { theme, setTheme } = useTheme()
const router = useRouter()
const { setIsOpen: setGraphModalOpen } = useGraphModal()
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">
<Link
className="pointer-events-auto"
href={
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.supermemory.ai"
}
rel="noopener noreferrer"
>
<LogoFull className="h-8 hidden md:block" />
<Logo className="h-8 md:hidden" />
</Link>
<div className="flex items-center gap-1.5 md:gap-3">
<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>
<Button
variant="ghost"
size="sm"
onClick={() => setGraphModalOpen(true)}
className="gap-1.5"
>
<Network className="h-4 w-4" />
<span className="hidden sm:inline">Graph View</span>
</Button>
<ConnectAIModal>
<Button variant="ghost" size="sm" className="gap-1.5">
<MCPIcon className="h-4 w-4" />
<span className="hidden lg:inline">Connect to AI (MCP)</span>
</Button>
</ConnectAIModal>
<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")}>
Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings/billing")}
>
Billing
</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-primay-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-primay-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-primay-foreground"
: "text-muted-foreground",
"h-3 w-3 group-hover:text-foreground",
)}
/>
</Button>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleSignOut()}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,269 @@
"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}
/>
)
}
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}
/>
)
}
if (document.url?.includes("https://")) {
return (
<WebsiteCard
url={document.url}
title={document.title || "Untitled Document"}
image={document.ogImage}
/>
)
}
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,
),
}))
.filter((doc) => doc.memoryEntries.length > 0)
}, [documents, selectedSpace])
const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
analytics.memoryDetailOpened()
setSelectedDocument(document)
setIsDetailOpen(true)
}, [])
const handleCloseDetails = useCallback(() => {
setIsDetailOpen(false)
setTimeout(() => setSelectedDocument(null), 300)
}, [])
// 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 flex items-center justify-center p-4">
<div className="rounded-xl overflow-hidden">
<div
className="relative z-10 px-6 py-4"
>
<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
className="h-full overflow-auto custom-scrollbar sm-tweet-theme"
data-theme="light"
>
<Masonry
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

@ -0,0 +1,62 @@
import { memo, useMemo } from "react"
import DOMPurify from "dompurify"
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, processedContent } = useMemo(() => {
const contentIsHTML = isHTMLContent(content)
if (contentIsHTML) {
return {
isHTML: true,
processedContent: DOMPurify.sanitize(content),
}
}
return {
isHTML: false,
processedContent: content,
}
}, [content])
if (isHTML) {
return (
<div
className={`${className} bg-background`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
)
}
return (
<p
className={`text-sm leading-relaxed whitespace-pre-wrap text-foreground ${className}`}
>
{processedContent}
</p>
)
},
)
HTMLContentRenderer.displayName = "HTMLContentRenderer"

View file

@ -0,0 +1,109 @@
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}`
}
// 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

@ -0,0 +1,385 @@
import { getDocumentIcon } from "@/lib/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"
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-start 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",
)}
</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>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-0.5 md:gap-1 transition-all hover:gap-1 md:hover:gap-2 text-primary hover:text-primary/80 whitespace-nowrap"
onClick={() => {
const sourceUrl = getSourceUrl(document)
window.open(sourceUrl ?? undefined, "_blank")
}}
type="button"
>
<span className="hidden sm:inline">View 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>
</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 mb-2 flex items-start gap-2 py-2 text-muted-foreground">
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{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">
<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:px-4 overflow-y-auto custom-scrollbar transition-all duration-300">
<h3 className="font-medium text-[10px] md:text-xs text-muted-foreground uppercase pb-1 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 lg:border-l border-border">
<div className="flex-1 flex flex-col">
<div className="flex-1 memory-dialog-scroll overflow-y-auto p-3 md:p-4">
<MemoryContent />
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
},
)

View file

@ -0,0 +1,276 @@
"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 { MemoryGraph } from "@repo/ui/memory-graph"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { MasonryMemoryList } from "@/components/masonry-memory-list"
import { AddMemoryView } from "@/components/views/add-memory"
import { useChatOpen, useProject, useGraphModal } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
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 { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
useGraphModal()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
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) => {
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) {
setShowConnectAIModal(true)
}
}, [allDocuments.length])
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">
<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>
</MasonryMemoryList>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</div>
{/* Memory Graph Modal */}
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
<DialogContent
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
showCloseButton={true}
>
<div className="w-full h-full">
<MemoryGraph
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
variant="console"
showSpacesSelector={true}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
>
<div className="absolute inset-0 flex items-center justify-center">
<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>
</MemoryGraph>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,53 +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]!);
};
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}`;
}
// Fallback to existing URL for all other document types
return document.url;
};

View file

@ -1,415 +0,0 @@
import { getDocumentIcon } from '@/lib/document-icon';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@repo/ui/components/drawer';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@repo/ui/components/sheet';
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@repo/ui/components/tabs';
import { colors } from '@repo/ui/memory-graph/constants';
import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api';
import { Badge } from '@ui/components/badge';
import { Brain, Calendar, CircleUserRound, ExternalLink, List, Sparkles } from 'lucide-react';
import { memo } from 'react';
import type { z } from 'zod';
import { formatDate, getSourceUrl } from '.';
import { Label1Regular } from '@ui/text/label/label-1-regular';
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse['documents'][0];
type MemoryEntry = DocumentWithMemories['memoryEntries'][0];
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === 'pdf') return 'PDF';
// Replace underscores with spaces and capitalize each word
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<button
className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: 'rgba(255, 255, 255, 0.02)',
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: 'transparent',
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? 'text-blue-400' : 'text-blue-400/50'
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex gap-2 justify-between">
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: '#dc2626',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs"
style={{
backgroundColor: colors.memory.secondary,
color: colors.text.primary,
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
color: colors.status.expiring,
backgroundColor: 'rgba(251, 165, 36, 0.1)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
</div>
</div>
</div>
</button>
);
});
export const MemoryDetail = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten);
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.background.secondary,
}}
>
{getDocumentIcon(document.type, 'w-5 h-5')}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || 'Untitled Document'}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, '_blank');
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const ContentAndSummarySection = () => {
const hasContent = document.content && document.content.trim().length > 0;
const hasSummary = document.summary && document.summary.trim().length > 0;
if (!hasContent && !hasSummary) return null;
const defaultTab = hasContent ? 'content' : 'summary';
return (
<div className="mt-4">
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList
className={`grid w-full bg-white/5 border border-white/10 h-11 ${
hasContent && hasSummary ? 'grid-cols-2' : 'grid-cols-1'
}`}
>
{hasContent && (
<TabsTrigger
value="content"
className="text-xs bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<CircleUserRound className="w-3 h-3" />
Original Content
</TabsTrigger>
)}
{hasSummary && (
<TabsTrigger
value="summary"
className="text-xs flex items-center gap-1 bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<List className="w-3 h-3" />
Summary
</TabsTrigger>
)}
</TabsList>
{hasContent && (
<TabsContent value="content" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.primary }}
>
{document.content}
</p>
</div>
</TabsContent>
)}
{hasSummary && (
<TabsContent value="summary" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
const MemoryContent = () => (
<div className="space-y-6 px-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-2 flex items-start gap-2 py-2"
style={{
color: colors.text.secondary,
}}
>
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{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"
style={{
color: colors.text.muted,
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
Forgotten Memories ({forgottenMemories.length})
</div>
<div className="space-y-3 opacity-40">
{forgottenMemories.map((memory) => (
<MemoryDetailItem key={memory.id} memory={memory} />
))}
</div>
</div>
)}
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
<div
className="text-center py-12 rounded-lg"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
No memories found for this document
</p>
</div>
)}
</div>
);
if (isMobile) {
return (
<Drawer onOpenChange={onClose} open={isOpen}>
<DrawerContent
className="border-0 p-0 overflow-hidden max-h-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
<ContentAndSummarySection />
</div>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<ContentAndSummarySection />
</div>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
}
);

View file

@ -26,9 +26,9 @@ import { analytics } from "@/lib/analytics"
import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
import { MemoryDetail } from "./memories/memory-detail"
import { MemoryDetail } from "./memories-utils/memory-detail"
import { getDocumentIcon } from "@/lib/document-icon"
import { formatDate, getSourceUrl } from "./memories"
import { formatDate, getSourceUrl } from "./memories-utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -44,31 +44,6 @@ interface MemoryListViewProps {
loadMoreDocuments: () => Promise<void>
}
const GreetingMessage = memo(() => {
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return "Good morning"
if (hour < 17) return "Good afternoon"
return "Good evening"
}
return (
<div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3">
<div>
<h1
className="text-lg md:text-xl font-semibold"
style={{ color: colors.text.primary }}
>
{getGreeting()}!
</h1>
<p className="text-xs md:text-sm" style={{ color: colors.text.muted }}>
Welcome back to your memory collection
</p>
</div>
</div>
)
})
const DocumentCard = memo(
({
document,
@ -86,7 +61,7 @@ const DocumentCard = memo(
return (
<Card
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full"
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 md:w-full shadow-xs"
onClick={() => {
analytics.documentCardClicked()
onOpenDetails(document)
@ -312,7 +287,7 @@ export const MemoryListView = ({
hasMore,
isLoadingMore,
loadMoreDocuments,
virtualizer.getVirtualItems(),
virtualizer.getVirtualItems,
virtualItems.length,
])
@ -322,7 +297,6 @@ export const MemoryListView = ({
<div
className="h-full overflow-hidden relative pb-20"
ref={containerRef}
style={{ backgroundColor: colors.background.primary }}
>
{error ? (
<div className="h-full flex items-center justify-center p-4">
@ -358,8 +332,6 @@ export const MemoryListView = ({
ref={parentRef}
className="h-full overflow-auto mt-20 custom-scrollbar"
>
<GreetingMessage />
<div
className="w-full relative"
style={{
@ -375,13 +347,13 @@ export const MemoryListView = ({
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
className="absolute top-0 left-0 w-full sm-tweet-theme"
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
>
<div
className="grid justify-start"
className="grid justify-center"
style={{
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
gap: `${gap}px`,

View file

@ -10,7 +10,7 @@ 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 { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-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"
@ -22,7 +22,7 @@ import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
const MCPIcon = ({ className }: { className?: string }) => {
export const MCPIcon = ({ className }: { className?: string }) => {
return (
<svg
className={className}
@ -66,7 +66,7 @@ function Menu({ id }: { id?: string }) {
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
const { data: memoriesCheck } = fetchMemoriesFeature(autumn)
const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
@ -103,12 +103,6 @@ function Menu({ id }: { id?: string }) {
key: "addUrl" as const,
disabled: false,
},
{
icon: MessageSquareMore,
text: "Chat",
key: "chat" as const,
disabled: false,
},
{
icon: Puzzle,
text: "Integrations",
@ -248,14 +242,9 @@ function Menu({ id }: { id?: string }) {
},
}}
>
{/* Glass effect background */}
<motion.div className="absolute inset-0" layout>
<GlassMenuEffect />
</motion.div>
{/* Menu content */}
<motion.div
className="relative z-20 flex flex-col gap-6 w-full"
className="relative z-20 flex flex-col gap-6 w-full bg-white"
layout
>
<AnimatePresence
@ -304,7 +293,7 @@ function Menu({ id }: { id?: string }) {
duration: 0.1,
},
}}
className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
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)}
@ -326,14 +315,14 @@ function Menu({ id }: { id?: string }) {
initial={{ scale: 0.8 }}
layout="position"
>
<item.icon className="duration-200 h-6 w-6 drop-shadow-lg flex-shrink-0" />
<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="drop-shadow-lg pl-3 whitespace-nowrap"
className="pl-3 whitespace-nowrap"
initial={{ opacity: 0, x: -10 }}
style={{
transform: "translateZ(0)",
@ -353,7 +342,7 @@ function Menu({ id }: { id?: string }) {
opacity: 1,
scaleX: 1,
}}
className="w-full h-px bg-white/20 mt-3 origin-left"
className="w-full h-px bg-black/20 mt-3 origin-left"
initial={{ opacity: 0, scaleX: 0 }}
transition={{
duration: 0.3,

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { $fetch } from "@repo/lib/api";
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants";
import { Button } from "@repo/ui/components/button";
import { $fetch } from "@repo/lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { Button } from "@repo/ui/components/button"
import {
Dialog,
DialogContent,
@ -10,141 +10,139 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
} from "@repo/ui/components/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/ui/components/dropdown-menu";
import { Label } from "@repo/ui/components/label";
} 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
} from "@repo/ui/components/select"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
ChevronDown,
FolderIcon,
Loader2,
MoreHorizontal,
MoreVertical,
Plus,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { useProjectMutations } from "@/hooks/use-project-mutations";
import { useProjectName } from "@/hooks/use-project-name";
import { useProject } from "@/stores";
import { CreateProjectDialog } from "./create-project-dialog";
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { toast } from "sonner"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProjectName } from "@/hooks/use-project-name"
import { useProject } from "@/stores"
import { CreateProjectDialog } from "./create-project-dialog"
interface Project {
id: string;
name: string;
containerTag: string;
createdAt: string;
updatedAt: string;
isExperimental?: boolean;
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
export function ProjectSelector() {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { selectedProject } = useProject();
const projectName = useProjectName();
const { switchProject, deleteProjectMutation } = useProjectMutations();
const queryClient = useQueryClient()
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const { selectedProject } = useProject()
const projectName = useProjectName()
const { switchProject, deleteProjectMutation } = useProjectMutations()
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
project: null | { id: string; name: string; containerTag: string };
action: "move" | "delete";
targetProjectId: string;
open: boolean
project: null | { id: string; name: string; containerTag: string }
action: "move" | "delete"
targetProjectId: string
}>({
open: false,
project: null,
action: "move",
targetProjectId: DEFAULT_PROJECT_ID,
});
})
const [expDialog, setExpDialog] = useState<{
open: boolean;
projectId: string;
open: boolean
projectId: string
}>({
open: false,
projectId: "",
});
})
const { data: projects = [], isLoading } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects");
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects");
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || [];
return response.data?.projects || []
},
staleTime: 30 * 1000,
});
})
const enableExperimentalMutation = useMutation({
mutationFn: async (projectId: string) => {
const response = await $fetch(
`@post/projects/${projectId}/enable-experimental`,
);
)
if (response.error) {
throw new Error(
response.error?.message || "Failed to enable experimental mode",
);
)
}
return response.data;
return response.data
},
onSuccess: () => {
toast.success("Experimental mode enabled for project");
queryClient.invalidateQueries({ queryKey: ["projects"] });
setExpDialog({ open: false, projectId: "" });
toast.success("Experimental mode enabled for project")
queryClient.invalidateQueries({ queryKey: ["projects"] })
setExpDialog({ open: false, projectId: "" })
},
onError: (error) => {
toast.error("Failed to enable experimental mode", {
description: error instanceof Error ? error.message : "Unknown error",
});
})
},
});
})
const handleProjectSelect = (containerTag: string) => {
switchProject(containerTag);
setIsOpen(false);
};
switchProject(containerTag)
setIsOpen(false)
}
const handleCreateNewProject = () => {
setIsOpen(false);
setShowCreateDialog(true);
};
setIsOpen(false)
setShowCreateDialog(true)
}
return (
<div className="relative">
<motion.button
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition-colors"
<Button
variant="ghost"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
onClick={() => setIsOpen(!isOpen)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white/90 max-w-32 truncate">
<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.15 }}
>
<ChevronDown className="h-3 w-3 text-white/50" />
<ChevronDown className="h-3 w-3" />
</motion.div>
</motion.button>
</Button>
<AnimatePresence>
{isOpen && (
@ -158,29 +156,27 @@ export function ProjectSelector() {
/>
<motion.div
className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden"
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">
{/* Default Project */}
<motion.div
<Button
variant="ghost"
className={`flex items-center justify-between p-2 rounded-md transition-colors cursor-pointer ${
selectedProject === DEFAULT_PROJECT_ID
? "bg-white/15"
: "hover:bg-white/8"
? "bg-accent"
: "hover:bg-accent/50"
}`}
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
>
<div className="flex items-center gap-2">
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white">
Default
</span>
<FolderIcon className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Default</span>
</div>
</motion.div>
</Button>
{/* User Projects */}
{projects
@ -190,71 +186,69 @@ export function ProjectSelector() {
key={project.id}
className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
selectedProject === project.containerTag
? "bg-white/15"
: "hover:bg-white/8"
? "bg-accent"
: "hover:bg-accent/50"
}`}
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.03 }}
>
<div
<button
className="flex items-center gap-2 flex-1 cursor-pointer"
type="button"
onClick={() =>
handleProjectSelect(project.containerTag)
}
>
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white truncate max-w-32">
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
<span className="text-xs font-medium truncate max-w-32">
{project.name}
</span>
</div>
</button>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<motion.button
className="p-1 hover:bg-white/10 rounded transition-all"
className="p-1 hover:bg-accent rounded transition-all"
onClick={(e) => e.stopPropagation()}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<MoreHorizontal className="h-3 w-3 text-white/50" />
<MoreHorizontal className="h-3 w-3" />
</motion.button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-black/90 border-white/10"
>
<DropdownMenuContent align="end">
{/* Show experimental toggle only if NOT experimental and NOT default project */}
{!project.isExperimental &&
project.containerTag !== DEFAULT_PROJECT_ID && (
<DropdownMenuItem
className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs"
className="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 cursor-pointer text-xs"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setExpDialog({
open: true,
projectId: project.id,
});
setIsOpen(false);
})
setIsOpen(false)
}}
>
<div className="h-3 w-3 mr-2 rounded border border-blue-400" />
<div className="h-3 w-3 mr-2 rounded border border-blue-600 dark:border-blue-400" />
Enable Experimental Mode
</DropdownMenuItem>
)}
{project.isExperimental && (
<DropdownMenuItem
className="text-blue-300/50 text-xs"
className="text-blue-600/50 dark:text-blue-300/50 text-xs"
disabled
>
<div className="h-3 w-3 mr-2 rounded bg-blue-400" />
<div className="h-3 w-3 mr-2 rounded bg-blue-600 dark:bg-blue-400" />
Experimental Mode Active
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-400 hover:text-red-300 cursor-pointer text-xs"
className="text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 cursor-pointer text-xs"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setDeleteDialog({
open: true,
project: {
@ -264,8 +258,8 @@ export function ProjectSelector() {
},
action: "move",
targetProjectId: "",
});
setIsOpen(false);
})
setIsOpen(false)
}}
>
<Trash2 className="h-3 w-3 mr-2" />
@ -278,15 +272,15 @@ export function ProjectSelector() {
))}
<motion.div
className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1"
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-white/70" />
<span className="text-xs font-medium text-white/80">
<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>
@ -310,7 +304,7 @@ export function ProjectSelector() {
}
open={deleteDialog.open}
>
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
<DialogContent className="sm:max-w-2xl">
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
@ -318,7 +312,7 @@ export function ProjectSelector() {
>
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription className="text-white/60">
<DialogDescription>
Are you sure you want to delete "{deleteDialog.project.name}
"? Choose what to do with the documents in this project.
</DialogDescription>
@ -339,10 +333,7 @@ export function ProjectSelector() {
}
type="radio"
/>
<Label
className="text-white cursor-pointer text-sm"
htmlFor="move"
>
<Label className="cursor-pointer text-sm" htmlFor="move">
Move documents to another project
</Label>
</div>
@ -362,14 +353,11 @@ export function ProjectSelector() {
}
value={deleteDialog.targetProjectId}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
<SelectTrigger className="w-full">
<SelectValue placeholder="Select target project..." />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value={DEFAULT_PROJECT_ID}
>
<SelectContent>
<SelectItem value={DEFAULT_PROJECT_ID}>
Default Project
</SelectItem>
{projects
@ -379,11 +367,7 @@ export function ProjectSelector() {
p.containerTag !== DEFAULT_PROJECT_ID,
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.id}
>
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
@ -406,7 +390,7 @@ export function ProjectSelector() {
type="radio"
/>
<Label
className="text-white cursor-pointer text-sm"
className="cursor-pointer text-sm"
htmlFor="delete"
>
Delete all documents in this project
@ -415,7 +399,7 @@ export function ProjectSelector() {
{deleteDialog.action === "delete" && (
<motion.p
animate={{ opacity: 1 }}
className="text-sm text-red-400 ml-6"
className="text-sm text-red-600 dark:text-red-400 ml-6"
initial={{ opacity: 0 }}
>
This action cannot be undone. All documents will be
@ -430,7 +414,6 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() =>
setDeleteDialog({
open: false,
@ -450,11 +433,11 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className={`${
className={
deleteDialog.action === "delete"
? "bg-red-600 hover:bg-red-700"
: "bg-white/10 hover:bg-white/20"
} text-white border-white/20`}
? "bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white"
: ""
}
disabled={
deleteProjectMutation.isPending ||
(deleteDialog.action === "move" &&
@ -478,10 +461,10 @@ export function ProjectSelector() {
project: null,
action: "move",
targetProjectId: "",
});
})
},
},
);
)
}
}}
type="button"
@ -514,7 +497,7 @@ export function ProjectSelector() {
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">
<DialogContent className="sm:max-w-lg">
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-4"
@ -522,19 +505,19 @@ export function ProjectSelector() {
initial={{ opacity: 0, scale: 0.95 }}
>
<DialogHeader>
<DialogTitle className="text-white">
Enable Experimental Mode?
</DialogTitle>
<DialogDescription className="text-white/60">
<DialogTitle>Enable Experimental Mode?</DialogTitle>
<DialogDescription>
Experimental mode enables beta features and advanced memory
relationships for this project.
<br />
<br />
<span className="text-yellow-400 font-medium">
<span className="text-yellow-600 dark:text-yellow-400 font-medium">
Warning:
</span>{" "}
This action is{" "}
<span className="text-red-400 font-bold">irreversible</span>
<span className="text-red-600 dark:text-red-400 font-bold">
irreversible
</span>
. Once enabled, you cannot return to regular mode for this
project.
</DialogDescription>
@ -545,7 +528,6 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() =>
setExpDialog({ open: false, projectId: "" })
}
@ -560,7 +542,7 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
disabled={enableExperimentalMutation.isPending}
onClick={() =>
enableExperimentalMutation.mutate(expDialog.projectId)
@ -584,5 +566,5 @@ export function ProjectSelector() {
)}
</AnimatePresence>
</div>
);
)
}

View file

@ -1,304 +0,0 @@
"use client";
import { useAuth } from "@lib/auth-context";
import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries";
import { Button } from "@repo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
import { Input } from "@repo/ui/components/input";
import { Label } from "@repo/ui/components/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@repo/ui/components/tabs";
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold";
import { useCustomer } from "autumn-js/react";
import {
CheckCircle,
Copy,
CreditCard,
Gift,
LoaderIcon,
Share2,
Users,
} from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useState } from "react";
interface ReferralUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ReferralUpgradeModal({
isOpen,
onClose,
}: ReferralUpgradeModalProps) {
const { user } = useAuth();
const autumn = useCustomer();
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any);
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
// Fetch subscription status
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn as any);
const isPro = status.consumer_pro;
// Handle upgrade
const handleUpgrade = async () => {
setIsLoading(true);
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
});
window.location.reload();
} catch (error) {
console.error(error);
setIsLoading(false);
}
};
// Generate referral link (you'll need to implement this based on your referral system)
const referralLink = `https://app.supermemory.ai/ref/${user?.id || "user"}`;
const handleCopyReferralLink = async () => {
try {
await navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: "Join Supermemory",
text: "Check out Supermemory - the best way to organize and search your digital memories!",
url: referralLink,
});
} catch (error) {
console.error("Error sharing:", error);
}
} else {
handleCopyReferralLink();
}
};
if (user?.isAnonymous) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<DialogHeader>
<DialogTitle>Get More Memories</DialogTitle>
<DialogDescription className="text-white/60">
Sign in to access referrals and upgrade options
</DialogDescription>
</DialogHeader>
<div className="text-center py-6">
<Button
asChild
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
<Link href="/login">Sign in</Link>
</Button>
</div>
</motion.div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg bg-[#0f1419] backdrop-blur-xl border-white/10 text-white">
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<DialogHeader className="mb-4">
<DialogTitle>Get More Memories</DialogTitle>
<DialogDescription className="text-white/60">
Expand your memory capacity through referrals or upgrades
</DialogDescription>
</DialogHeader>
{/* Current Usage */}
<div className="bg-white/5 rounded-lg p-4 mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-white/70">Current Usage</span>
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}
>
{memoriesUsed} / {memoriesLimit} memories
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
memoriesUsed >= memoriesLimit
? "bg-red-500"
: isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="refer" className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-white/5">
<TabsTrigger value="refer" className="flex items-center gap-2">
<Users className="w-4 h-4" />
Refer Friends
</TabsTrigger>
{!isPro && (
<TabsTrigger
value="upgrade"
className="flex items-center gap-2"
>
<CreditCard className="w-4 h-4" />
Upgrade Plan
</TabsTrigger>
)}
</TabsList>
<TabsContent value="refer" className="space-y-4 mt-6">
<div className="text-center">
<Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">
Invite Friends, Get More Memories
</HeadingH3Bold>
<p className="text-white/70 text-sm">
For every friend who joins, you both get +5 extra memories!
</p>
</div>
<div className="bg-white/5 rounded-lg p-4">
<Label className="text-sm text-white/70 mb-2 block">
Your Referral Link
</Label>
<div className="flex gap-2">
<Input
className="flex-1 bg-white/10 border-white/20 text-white"
readOnly
value={referralLink}
/>
<Button
className="bg-white/5 hover:bg-white/10 text-white border-white/20"
onClick={handleCopyReferralLink}
size="sm"
variant="outline"
>
{copied ? (
<CheckCircle className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
<Button
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/20"
onClick={handleShare}
variant="outline"
>
<Share2 className="w-4 h-4 mr-2" />
Share Link
</Button>
</TabsContent>
{!isPro && (
<TabsContent value="upgrade" className="space-y-4 mt-6">
<div className="text-center">
<CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">
Upgrade to Pro
</HeadingH3Bold>
<p className="text-white/70 text-sm">
Get unlimited memories and advanced features
</p>
</div>
<div className="bg-white/5 rounded-lg border border-white/10 p-4">
<h4 className="font-medium text-white mb-3">
Pro Plan Benefits
</h4>
<ul className="space-y-2">
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
500 memories (vs {memoriesLimit} free)
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
10 connections
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Advanced search
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Priority support
</li>
</ul>
</div>
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
"Upgrade to Pro - $15/month"
)}
</Button>
<p className="text-xs text-white/50 text-center">
Cancel anytime. No questions asked.
</p>
</TabsContent>
)}
</Tabs>
</motion.div>
</DialogContent>
</Dialog>
);
}

View file

@ -38,7 +38,7 @@ function TextShimmerComponent({
initial={{ backgroundPosition: "100% center" }}
animate={{ backgroundPosition: "0% center" }}
transition={{
repeat: Infinity,
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}

View file

@ -26,7 +26,7 @@ export function ActionButtons({
return (
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
<Button
className="hover:bg-white/10 text-white border-none flex-1 sm:flex-initial"
className="hover:bg-foreground/10 border-none flex-1 sm:flex-initial"
onClick={onCancel}
type="button"
variant="ghost"
@ -40,7 +40,7 @@ export function ActionButtons({
className="flex-1 sm:flex-initial"
>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full"
className="bg-foreground hover:bg-foreground/20 border-foreground/20 w-full"
disabled={isSubmitting || isSubmitDisabled}
onClick={submitType === 'button' ? onSubmit : undefined}
type={submitType}

File diff suppressed because it is too large Load diff

View file

@ -50,18 +50,18 @@ export function ProjectSelection({
value={selectedProject}
>
<SelectTrigger
className={`bg-white/5 border-white/10 text-white ${className}`}
className={`bg-foreground/5 border-foreground/10 ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent
className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]"
className="bg-black/90 backdrop-blur-xl border-foreground/10 z-[90]"
position="popper"
sideOffset={5}
>
<SelectItem
className="text-white hover:bg-white/10"
className="hover:bg-foreground/10"
key="default"
value="sm_project_default"
>
@ -71,7 +71,7 @@ export function ProjectSelection({
.filter((p) => p.containerTag !== 'sm_project_default' && p.id)
.map((project) => (
<SelectItem
className="text-white hover:bg-white/10"
className="hover:bg-foreground/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
@ -79,7 +79,7 @@ export function ProjectSelection({
</SelectItem>
))}
<SelectItem
className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
className="hover:bg-foreground/10 border-t border-foreground/10 mt-1"
key="create-new"
value="create-new-project"
>

View file

@ -285,7 +285,7 @@ export function TextEditor({
return (
<blockquote
{...props.attributes}
className="border-l-4 border-white/20 pl-4 italic text-white/80"
className="border-l-4 border-foreground/20 pl-4 italic text-foreground/80"
>
{props.children}
</blockquote>
@ -312,7 +312,7 @@ export function TextEditor({
if (leaf.code) {
children = (
<code className="bg-white/10 px-1 rounded text-sm">{children}</code>
<code className="bg-foreground/10 px-1 rounded text-sm">{children}</code>
);
}
@ -402,10 +402,10 @@ export function TextEditor({
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 !p-0 text-white/70 transition-all duration-200 rounded-sm",
"hover:bg-white/15 hover:text-white hover:scale-105",
"h-8 w-8 !p-0 text-foreground/70 transition-all duration-200 rounded-sm",
"hover:bg-foreground/15 hover:text-foreground hover:scale-105",
"active:scale-95",
isActive && "bg-white/20 text-white",
isActive && "bg-foreground/20 text-foreground",
)}
onMouseDown={onMouseDown}
title={title}
@ -444,7 +444,7 @@ export function TextEditor({
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full text-white placeholder:text-white/50",
"outline-none w-full h-full placeholder:text-foreground/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
@ -457,7 +457,7 @@ export function TextEditor({
</div>
{/* Toolbar */}
<div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md">
<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
@ -489,7 +489,7 @@ export function TextEditor({
/>
</div>
<div className="w-px h-6 bg-white/30 mx-2" />
<div className="w-px h-6 bg-foreground/30 mx-2" />
<div className="flex items-center gap-1">
{/* Block formatting */}

View file

@ -1,70 +1,72 @@
import { useAuth } from "@lib/auth-context";
import { useAuth } from "@lib/auth-context"
import {
fetchConnectionsFeature,
fetchMemoriesFeature,
fetchSubscriptionStatus,
} from "@lib/queries";
import { Button } from "@ui/components/button";
import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold";
import { useCustomer } from "autumn-js/react";
import { CheckCircle, LoaderIcon, X } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { analytics } from "@/lib/analytics";
} from "@lib/queries"
import { Button } from "@ui/components/button"
import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import { CheckCircle, LoaderIcon, X } from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { analytics } from "@/lib/analytics"
export function BillingView() {
const autumn = useCustomer();
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const autumn = useCustomer()
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
analytics.billingViewed();
}, []);
analytics.billingViewed()
}, [])
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any);
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any);
const connectionsUsed = connectionsCheck?.usage ?? 0;
// Fetch subscription status with React Query
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn as any);
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
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);
analytics.upgradeInitiated()
setIsLoading(true)
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
});
analytics.upgradeCompleted();
window.location.reload();
})
analytics.upgradeCompleted()
window.location.reload()
} catch (error) {
console.error(error);
setIsLoading(false);
console.error(error)
setIsLoading(false)
}
};
}
// Handle manage billing
const handleManageBilling = async () => {
analytics.billingPortalOpened();
analytics.billingPortalOpened()
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
});
};
})
}
const isPro = status.consumer_pro;
const isPro = status.consumer_pro
if (user?.isAnonymous) {
return (
@ -74,18 +76,20 @@ export function BillingView() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/70 mb-4">Sign in to unlock premium features</p>
<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-white/10 hover:bg-white/20 text-white border-white/20"
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 (isPro) {
@ -96,28 +100,28 @@ export function BillingView() {
initial={{ opacity: 0, y: 10 }}
>
<div className="space-y-3">
<HeadingH3Bold className="text-white flex items-center gap-2">
<HeadingH3Bold className="text-foreground flex items-center gap-2">
Pro Plan
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
<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-white/70">
<p className="text-sm text-muted-foreground">
You're enjoying expanded memory capacity with supermemory Pro!
</p>
</div>
{/* Current Usage */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-white/90">Current Usage</h4>
<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-white/70">Memories</span>
<span className="text-sm text-white/90">
<span className="text-sm text-muted-foreground">Memories</span>
<span className="text-sm text-foreground">
{memoriesUsed} / {memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div className="w-full bg-muted-foreground/50 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{
@ -128,26 +132,19 @@ export function BillingView() {
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Connections</span>
<span className="text-sm text-white/90">
<span className="text-sm text-muted-foreground">Connections</span>
<span className="text-sm text-foreground">
{connectionsUsed} / 10
</span>
</div>
</div>
</div>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
onClick={handleManageBilling}
size="sm"
variant="outline"
>
Manage Billing
</Button>
</motion.div>
<Button onClick={handleManageBilling} size="sm" variant="default">
Manage Billing
</Button>
</motion.div>
);
)
}
return (
@ -158,17 +155,19 @@ export function BillingView() {
>
{/* Current Usage - Free Plan */}
<div className="space-y-3">
<HeadingH3Bold className="text-white">Current Plan: Free</HeadingH3Bold>
<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-white/70">Memories</span>
<span className="text-sm text-muted-foreground">Memories</span>
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
>
{memoriesUsed} / {memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<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"
@ -183,23 +182,25 @@ export function BillingView() {
{/* Comparison */}
<div className="space-y-4">
<HeadingH3Bold className="text-white">Upgrade to Pro</HeadingH3Bold>
<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-white/5 rounded-lg border border-white/10">
<h4 className="font-medium text-white/90 mb-3">Free Plan</h4>
<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-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/70">
<X className="h-4 w-4 text-red-400" />
<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-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
@ -207,9 +208,9 @@ export function BillingView() {
{/* Pro Plan */}
<div className="p-4 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
<h4 className="font-medium text-white mb-3 flex items-center gap-2">
<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-400 px-2 py-0.5 rounded-full">
<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>
@ -218,44 +219,41 @@ export function BillingView() {
<CheckCircle className="h-4 w-4 text-green-400" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0 w-full"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
size="sm"
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
<div>Upgrade to Pro - $15/month (only for first 100 users)</div>
)}
</Button>
</motion.div>
<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 - $15/month (only for first 100 users)</div>
)}
</Button>
<p className="text-xs text-white/50 text-center">
<p className="text-xs text-muted-foreground text-center">
Cancel anytime. No questions asked.
</p>
</div>
</motion.div>
);
)
}

View file

@ -1,49 +1,49 @@
"use client";
"use client"
import { useChat, useCompletion } from "@ai-sdk/react";
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { DefaultChatTransport } from "ai";
import { useChat, useCompletion } 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,
Plus,
RotateCcw,
X,
} from "lucide-react";
import { 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 { Spinner } from "../../spinner";
} 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 { Spinner } from "../../spinner"
interface MemoryResult {
documentId?: string;
title?: string;
content?: string;
url?: string;
score?: number;
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface ExpandableMemoriesProps {
foundCount: number;
results: MemoryResult[];
foundCount: number
results: MemoryResult[]
}
function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
const [isExpanded, setIsExpanded] = useState(false);
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 (
@ -58,17 +58,16 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
) : (
<ChevronRight className="size-4" />
)}
<Check className="size-4" />
Found {foundCount} {foundCount === 1 ? "memory" : "memories"}
Related memories
</button>
{isExpanded && results.length > 0 && (
<div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto">
<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://"));
result.url.startsWith("https://"))
const content = (
<>
@ -83,7 +82,7 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
</div>
)}
{result.url && (
<div className="text-xs text-blue-400 mt-1 truncate">
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate">
{result.url}
</div>
)}
@ -93,12 +92,12 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
</div>
)}
</>
);
)
if (isClickable) {
return (
<a
className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 hover:border-white/20 transition-colors cursor-pointer"
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"
@ -106,93 +105,93 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
>
{content}
</a>
);
)
}
return (
<div
className="p-2 bg-white/5 rounded-md border border-white/10"
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 scrollContainerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const [isAutoScroll, setIsAutoScroll] = useState(true)
const [isFarFromBottom, setIsFarFromBottom] = useState(false)
const scrollToBottom = (behavior: ScrollBehavior = "auto") => {
const node = bottomRef.current;
if (node) node.scrollIntoView({ behavior, block: "end" });
};
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 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);
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();
}, []);
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [])
useEffect(
function observeContentResize() {
const container = scrollContainerRef.current;
if (!container) return;
const container = scrollContainerRef.current
if (!container) return
const resizeObserver = new ResizeObserver(() => {
if (isAutoScroll) scrollToBottom("auto");
if (isAutoScroll) scrollToBottom("auto")
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setIsFarFromBottom(distanceFromBottom > 100);
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
container.scrollHeight - container.scrollTop - container.clientHeight
setIsFarFromBottom(distanceFromBottom > 100)
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
},
[isAutoScroll],
);
[isAutoScroll, scrollToBottom],
)
function enableAutoScroll() {
setIsAutoScroll(true);
setIsAutoScroll(true)
}
useEffect(
function autoScrollOnNewContent() {
if (isAutoScroll) scrollToBottom("auto");
if (isAutoScroll) scrollToBottom("auto")
},
[isAutoScroll, scrollToBottom, ...triggerKeys],
);
)
const recomputeDistanceFromBottom = () => {
const container = scrollContainerRef.current;
if (!container) return;
const recomputeDistanceFromBottom = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setIsFarFromBottom(distanceFromBottom > 100);
};
container.scrollHeight - container.scrollTop - container.clientHeight
setIsFarFromBottom(distanceFromBottom > 100)
}, [])
useEffect(() => {
recomputeDistanceFromBottom();
}, [recomputeDistanceFromBottom, ...triggerKeys]);
recomputeDistanceFromBottom()
}, [recomputeDistanceFromBottom, ...triggerKeys])
function onScroll() {
recomputeDistanceFromBottom();
recomputeDistanceFromBottom()
}
return {
@ -203,11 +202,11 @@ function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) {
onScroll,
enableAutoScroll,
scrollToBottom,
} as const;
} as const
}
export function ChatMessages() {
const { selectedProject } = useProject();
const { selectedProject } = useProject()
const {
currentChatId,
setCurrentChatId,
@ -215,12 +214,14 @@ export function ChatMessages() {
getCurrentConversation,
setConversationTitle,
getCurrentChat,
} = usePersistentChat();
} = usePersistentChat()
const activeChatIdRef = useRef<string | null>(null);
const shouldGenerateTitleRef = useRef<boolean>(false);
const [input, setInput] = useState("")
const activeChatIdRef = useRef<string | null>(null)
const shouldGenerateTitleRef = useRef<boolean>(false)
const hasRunInitialMessageRef = useRef<boolean>(false)
const { setDocumentIds } = useGraphHighlights();
const { setDocumentIds } = useGraphHighlights()
const { messages, sendMessage, status, stop, setMessages, id, regenerate } =
useChat({
@ -232,91 +233,118 @@ export function ChatMessages() {
}),
maxSteps: 2,
onFinish: (result) => {
const activeId = activeChatIdRef.current;
if (!activeId) return;
if (result.message.role !== "assistant") return;
const activeId = activeChatIdRef.current
if (!activeId) return
if (result.message.role !== "assistant") return
if (shouldGenerateTitleRef.current) {
const textPart = result.message.parts.find(
(p: any) => p?.type === "text",
) as any;
const text = textPart?.text?.trim();
) as any
const text = textPart?.text?.trim()
if (text) {
shouldGenerateTitleRef.current = false;
complete(text);
shouldGenerateTitleRef.current = false
complete(text)
}
}
},
});
})
useEffect(() => {
activeChatIdRef.current = currentChatId ?? id ?? null;
}, [currentChatId, id]);
activeChatIdRef.current = currentChatId ?? id ?? null
}, [currentChatId, id])
useEffect(() => {
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])
useEffect(() => {
if (id && id !== currentChatId) {
setCurrentChatId(id);
setCurrentChatId(id)
}
}, [id, currentChatId, setCurrentChatId]);
}, [id])
useEffect(() => {
const msgs = getCurrentConversation();
setMessages(msgs ?? []);
setInput("");
}, [currentChatId]);
const msgs = getCurrentConversation()
if (msgs && msgs.length > 0) {
setMessages(msgs)
} else if (!currentChatId) {
setMessages([])
}
setInput("")
}, [currentChatId])
useEffect(() => {
const activeId = currentChatId ?? id;
const activeId = currentChatId ?? id
if (activeId && messages.length > 0) {
setConversation(activeId, messages);
setConversation(activeId, messages)
}
}, [messages, currentChatId, id, setConversation]);
}, [messages, currentChatId, id])
const [input, setInput] = useState("");
const { complete } = useCompletion({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`,
credentials: "include",
onFinish: (_, completion) => {
const activeId = activeChatIdRef.current;
if (!completion || !activeId) return;
setConversationTitle(activeId, completion.trim());
const activeId = activeChatIdRef.current
if (!completion || !activeId) return
setConversationTitle(activeId, completion.trim())
},
});
})
// Update graph highlights from the most recent tool-searchMemories output
useEffect(() => {
try {
const lastAssistant = [...messages]
.reverse()
.find((m) => m.role === "assistant");
if (!lastAssistant) return;
.find((m) => m.role === "assistant")
if (!lastAssistant) return
const lastSearchPart = [...(lastAssistant.parts as any[])]
.reverse()
.find(
(p) =>
p?.type === "tool-searchMemories" &&
p?.state === "output-available",
);
if (!lastSearchPart) return;
const output = (lastSearchPart as any).output;
)
if (!lastSearchPart) return
const output = (lastSearchPart as any).output
const ids = Array.isArray(output?.results)
? ((output.results as any[])
.map((r) => r?.documentId)
.filter(Boolean) as string[])
: [];
: []
if (ids.length > 0) {
setDocumentIds(ids);
setDocumentIds(ids)
}
} catch {}
}, [messages, setDocumentIds]);
}, [messages])
useEffect(() => {
const currentSummary = getCurrentChat();
const currentSummary = getCurrentChat()
const hasTitle = Boolean(
currentSummary?.title && currentSummary.title.trim().length > 0,
);
shouldGenerateTitleRef.current = !hasTitle;
}, [getCurrentChat]);
)
shouldGenerateTitleRef.current = !hasTitle
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage({ text: input })
setInput("")
}
}
const {
scrollContainerRef,
bottomRef,
@ -324,39 +352,48 @@ export function ChatMessages() {
onScroll,
enableAutoScroll,
scrollToBottom,
} = useStickyAutoScroll([messages, status]);
} = useStickyAutoScroll([messages, status])
return (
<>
<div className="relative grow">
<div className="h-full flex flex-col w-full">
<div className="flex-1 relative">
<div
className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7"
className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7 custom-scrollbar"
onScroll={onScroll}
ref={scrollContainerRef}
>
{messages.map((message) => (
<div
className={cn(
"flex flex-col",
message.role === "user" ? "items-end" : "items-start",
"flex my-2",
message.role === "user"
? "items-center flex-row-reverse gap-2"
: "flex-col",
)}
key={message.id}
>
<div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg">
<div
className={cn(
"flex flex-col gap-2 max-w-4/5",
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) => {
.map((part, index) => {
switch (part.type) {
case "text":
return (
<div key={message.id + part.type}>
<div key={`${message.id}-${part.type}-${index}`}>
<Streamdown>{part.text}</Streamdown>
</div>
);
)
case "tool-searchMemories": {
switch (part.state) {
case "input-available":
@ -364,46 +401,46 @@ export function ChatMessages() {
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={message.id + part.type}
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}
key={`${message.id}-${part.type}-${index}`}
>
<X className="size-4" /> Error recalling
memories
</div>
);
)
case "output-available": {
const output = part.output;
const output = part.output
const foundCount =
typeof output === "object" &&
output !== null &&
"count" in output
? Number(output.count) || 0
: 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}
key={`${message.id}-${part.type}-${index}`}
results={results}
/>
);
)
}
default:
return null;
return null
}
}
case "tool-addMemory": {
@ -412,44 +449,44 @@ export function ChatMessages() {
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={message.id + part.type}
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}
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}
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}
key={`${message.id}-${part.type}-${index}`}
>
<Spinner className="size-4" /> Adding memory...
</div>
);
)
default:
return null;
return null
}
}
default:
return null;
return null
}
})}
</div>
@ -463,8 +500,8 @@ export function ChatMessages() {
.filter((p) => p.type === "text")
?.map((p) => (p as any).text)
.join("\n") ?? "",
);
toast.success("Copied to clipboard");
)
toast.success("Copied to clipboard")
}}
size="icon"
variant="ghost"
@ -503,8 +540,8 @@ export function ChatMessages() {
: "opacity-0 scale-95 pointer-events-none",
)}
onClick={() => {
enableAutoScroll();
scrollToBottom("smooth");
enableAutoScroll()
scrollToBottom("smooth")
}}
size="sm"
type="button"
@ -514,41 +551,44 @@ export function ChatMessages() {
</Button>
</div>
<form
className="flex gap-2 px-4 pb-4 pt-1 relative"
onSubmit={(e) => {
e.preventDefault();
if (status === "submitted") return;
if (status === "streaming") {
stop();
return;
}
if (input.trim()) {
enableAutoScroll();
scrollToBottom("auto");
sendMessage({ text: input });
setInput("");
}
}}
>
<div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" />
<Input
className="w-full"
disabled={status === "submitted"}
onChange={(e) => setInput(e.target.value)}
placeholder="Say something..."
value={input}
/>
<Button disabled={status === "submitted"} type="submit">
{status === "ready" ? (
<ArrowUp className="size-4" />
) : status === "submitted" ? (
<Spinner className="size-4" />
) : (
<X className="size-4" />
)}
</Button>
</form>
</>
);
<div className="px-4 pb-4 pt-1 relative flex-shrink-0">
<form
className="flex flex-col items-end gap-3 bg-card border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl"
onSubmit={(e) => {
e.preventDefault()
if (status === "submitted") return
if (status === "streaming") {
stop()
return
}
if (input.trim()) {
enableAutoScroll()
scrollToBottom("auto")
sendMessage({ text: input })
setInput("")
}
}}
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
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">
<Button
type="submit"
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

@ -183,16 +183,16 @@ export function ConnectionsTabContent() {
return (
<div className="space-y-4">
<div className="mb-4">
<p className="text-sm text-white/70">
<p className="text-sm text-foreground/70">
Connect your favorite services to import documents
</p>
{isProUser && !autumn.isLoading && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-foreground/50 mt-1">
{connectionsUsed} of {connectionsLimit} connections used
</p>
)}
{!isProUser && !autumn.isLoading && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-foreground/50 mt-1">
Connections require a Pro subscription
</p>
)}
@ -208,7 +208,7 @@ export function ConnectionsTabContent() {
<p className="text-sm text-yellow-400 mb-2">
🔌 Connections are a Pro feature
</p>
<p className="text-xs text-white/60 mb-3">
<p className="text-xs text-foreground/60 mb-3">
Connect Google Drive, Notion, OneDrive and more to automatically
sync your documents.
</p>
@ -228,12 +228,12 @@ export function ConnectionsTabContent() {
{[...Array(2)].map((_, i) => (
<motion.div
animate={{ opacity: 1 }}
className="p-4 bg-white/5 rounded-lg"
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-white/10" />
<Skeleton className="h-12 w-full bg-foreground/10" />
</motion.div>
))}
</div>
@ -244,8 +244,8 @@ export function ConnectionsTabContent() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/50 mb-2">No connections yet</p>
<p className="text-xs text-white/40">
<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>
@ -255,7 +255,7 @@ export function ConnectionsTabContent() {
{connections.map((connection, index) => (
<motion.div
animate={{ opacity: 1, x: 0 }}
className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
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}
@ -263,19 +263,13 @@ export function ConnectionsTabContent() {
transition={{ delay: index * 0.05 }}
>
<div className="flex items-center gap-3">
<motion.div
animate={{ rotate: 0, opacity: 1 }}
initial={{ rotate: -180, opacity: 0 }}
transition={{ delay: index * 0.05 + 0.2 }}
>
{getProviderIcon(connection.provider)}
</motion.div>
{getProviderIcon(connection.provider)}
<div>
<p className="font-medium text-white capitalize">
<p className="font-medium text-foreground capitalize">
{connection.provider.replace("-", " ")}
</p>
{connection.email && (
<p className="text-sm text-white/60">
<p className="text-sm text-foreground/60">
{connection.email}
</p>
)}
@ -286,7 +280,7 @@ export function ConnectionsTabContent() {
whileTap={{ scale: 0.9 }}
>
<Button
className="text-white/50 hover:text-red-400"
className="text-foreground/50 hover:text-red-400"
disabled={deleteConnectionMutation.isPending}
onClick={() =>
deleteConnectionMutation.mutate(connection.id)
@ -305,9 +299,7 @@ export function ConnectionsTabContent() {
{/* Available Connections Section */}
<div className="mt-6">
<h3 className="text-lg font-medium text-white mb-4">
Available Connections
</h3>
<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
@ -317,11 +309,9 @@ export function ConnectionsTabContent() {
initial={{ opacity: 0, y: 20 }}
key={provider}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full"
className="justify-start h-auto p-4 bg-foreground/5 hover:bg-foreground/10 border-foreground/10 w-full"
disabled={addConnectionMutation.isPending}
onClick={() => {
addConnectionMutation.mutate(provider as ConnectorProvider)
@ -331,7 +321,7 @@ export function ConnectionsTabContent() {
<Icon className="h-8 w-8 mr-3" />
<div className="text-left">
<div className="font-medium">{config.title}</div>
<div className="text-sm text-white/60 mt-0.5">
<div className="text-sm text-foreground/60 mt-0.5">
{config.description}
</div>
</div>

View file

@ -118,7 +118,10 @@ export function IntegrationsView() {
}
}, [autumn.isLoading, autumn.customer])
const { data: connectionsCheck } = fetchConnectionsFeature(autumn)
const { data: connectionsCheck } = fetchConnectionsFeature(
autumn,
!autumn.isLoading,
)
const connectionsUsed = connectionsCheck?.balance ?? 0
const connectionsLimit = connectionsCheck?.included_usage ?? 0
@ -281,25 +284,25 @@ export function IntegrationsView() {
return (
<div className="space-y-4 sm:space-y-4 custom-scrollbar">
{/* iOS Shortcuts */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0">
<Smartphone className="h-5 w-5 text-blue-400" />
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<Smartphone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-base mb-1">
<h3 className="text-card-foreground font-semibold text-base mb-1">
Apple shortcuts
</h3>
<p className="text-white/70 text-sm leading-relaxed">
<p className="text-muted-foreground text-sm leading-relaxed">
Add memories directly from iPhone, iPad or Mac.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75 "
variant="secondary"
className="flex-1"
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
>
@ -314,8 +317,8 @@ export function IntegrationsView() {
: "Add Memory Shortcut"}
</Button>
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75"
variant="secondary"
className="flex-1"
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
>
@ -334,19 +337,19 @@ export function IntegrationsView() {
</div>
{/* Chrome Extension */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className="p-2 bg-orange-500/20 rounded-lg flex-shrink-0">
<ChromeIcon className="h-5 w-5 text-orange-400" />
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<ChromeIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0 mb-3">
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-1">
<h3 className="text-white font-semibold text-base">
<h3 className="text-card-foreground font-semibold text-base">
Chrome Extension
</h3>
<Button
className="text-white bg-secondary w-fit"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-fit"
onClick={() => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
@ -366,20 +369,20 @@ export function IntegrationsView() {
</div>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Save any webpage to supermemory
</p>
</div>
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Import All your Twitter Bookmarks
</p>
</div>
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Bring all your chatGPT memories to Supermemory
</p>
</div>
@ -388,12 +391,12 @@ export function IntegrationsView() {
</div>
{/* Connections Section */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-green-500/20 rounded-lg flex-shrink-0">
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<svg
className="h-5 w-5 text-green-400"
className="h-5 w-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -408,14 +411,14 @@ export function IntegrationsView() {
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-base mb-1">
<h3 className="text-card-foreground font-semibold text-base mb-1">
Connections
</h3>
<p className="text-white/70 text-sm leading-relaxed mb-2">
<p className="text-muted-foreground text-sm leading-relaxed mb-2">
Connect your accounts to sync document.
</p>
{!isProUser && (
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground/70">
Connections require a Pro subscription
</p>
)}
@ -426,21 +429,21 @@ export function IntegrationsView() {
{!autumn.isLoading && !isProUser && (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg mb-3"
className="p-4 bg-accent border border-border rounded-lg mb-3"
initial={{ opacity: 0, y: -10 }}
>
<p className="text-sm text-yellow-400 mb-2">
<p className="text-sm text-accent-foreground mb-2 font-medium">
🔌 Connections are a Pro feature
</p>
<p className="text-xs text-white/60 mb-3">
<p className="text-xs text-muted-foreground mb-3">
Connect Google Drive, Notion, OneDrive and more to automatically
sync your documents.
</p>
<Button
className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30 w-full sm:w-auto"
className="w-full sm:w-auto"
onClick={handleUpgrade}
size="sm"
variant="secondary"
variant="default"
>
Upgrade to Pro
</Button>
@ -453,12 +456,12 @@ export function IntegrationsView() {
{Object.keys(CONNECTORS).map((_, i) => (
<motion.div
animate={{ opacity: 1 }}
className="p-3 bg-white/5 rounded-lg"
className="p-3 bg-accent rounded-lg"
initial={{ opacity: 0 }}
key={`skeleton-${Date.now()}-${i}`}
transition={{ delay: i * 0.1 }}
>
<Skeleton className="h-12 w-full bg-white/10" />
<Skeleton className="h-12 w-full bg-muted" />
</motion.div>
))}
</div>
@ -474,46 +477,39 @@ export function IntegrationsView() {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-accent rounded-lg hover:bg-accent/80 transition-colors border border-border/50"
initial={{ opacity: 0, y: 20 }}
key={provider}
transition={{ delay: index * 0.05 }}
>
<div className="flex items-center gap-3 flex-1">
<motion.div
animate={{ rotate: 0, opacity: 1 }}
className="flex-shrink-0"
initial={{ rotate: -180, opacity: 0 }}
transition={{ delay: index * 0.05 + 0.2 }}
>
<Icon className="h-8 w-8" />
</motion.div>
<Icon className="h-8 w-8" />
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<p className="font-medium text-white text-sm">
<p className="font-medium text-card-foreground text-sm">
{config.title}
</p>
{isConnected ? (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-xs text-green-400 font-medium">
<div className="w-2 h-2 bg-chart-2 rounded-full" />
<span className="text-xs text-chart-2 font-medium">
Connected
</span>
</div>
) : (
<div className="hidden sm:flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full" />
<span className="text-xs text-gray-400 font-medium">
<div className="w-2 h-2 bg-muted-foreground rounded-full" />
<span className="text-xs text-muted-foreground font-medium">
Disconnected
</span>
</div>
)}
</div>
<p className="text-xs text-white/60 mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{config.description}
</p>
{connection?.email && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-muted-foreground/70 mt-1">
{connection.email}
</p>
)}
@ -527,7 +523,7 @@ export function IntegrationsView() {
whileTap={{ scale: 0.95 }}
>
<Button
className="text-white/70 hover:text-red-400 hover:bg-red-500/10 w-full sm:w-auto"
className="text-destructive hover:bg-destructive/10 w-full sm:w-auto"
disabled={deleteConnectionMutation.isPending}
onClick={() =>
deleteConnectionMutation.mutate(connection.id)
@ -542,8 +538,8 @@ export function IntegrationsView() {
) : (
<div className="flex items-center justify-between gap-2 w-full sm:w-auto">
<div className="sm:hidden flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full" />
<span className="text-xs text-gray-400 font-medium">
<div className="w-2 h-2 bg-muted-foreground rounded-full" />
<span className="text-xs text-muted-foreground font-medium">
Disconnected
</span>
</div>
@ -553,7 +549,7 @@ export function IntegrationsView() {
className="flex-shrink-0"
>
<Button
className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px] disabled:cursor-not-allowed"
className="min-w-[80px] disabled:cursor-not-allowed"
disabled={
addConnectionMutation.isPending || !isProUser
}
@ -563,7 +559,7 @@ export function IntegrationsView() {
)
}}
size="sm"
variant="outline"
variant="default"
>
{addConnectionMutation.isPending &&
addConnectionMutation.variables === provider
@ -583,14 +579,14 @@ export function IntegrationsView() {
</div>
<div className="p-3">
<p className="text-white/70 text-sm leading-relaxed text-center">
<p className="text-muted-foreground text-sm leading-relaxed text-center">
More integrations are coming soon! Have a suggestion? Share it with us
on{" "}
<a
href="https://x.com/supermemoryai"
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-400 underline"
className="text-primary hover:text-primary/80 underline"
>
X
</a>
@ -601,9 +597,9 @@ export function IntegrationsView() {
{/* API Key Modal */}
<Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}>
<DialogPortal>
<DialogContent className="bg-[#0f1419] border-white/10 text-white md:max-w-md z-[100]">
<DialogContent className="bg-card border-border text-card-foreground md:max-w-md z-[100]">
<DialogHeader>
<DialogTitle className="text-white text-lg font-semibold">
<DialogTitle className="text-card-foreground text-lg font-semibold">
Setup{" "}
{selectedShortcutType === "add"
? "Add Memory"
@ -619,7 +615,7 @@ export function IntegrationsView() {
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className="text-sm font-medium text-white/80"
className="text-sm font-medium text-muted-foreground"
>
Your API Key
</label>
@ -629,16 +625,16 @@ export function IntegrationsView() {
type="text"
value={apiKey}
readOnly
className="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-sm text-white font-mono"
className="flex-1 bg-input border border-border rounded-lg px-3 py-2 text-sm text-foreground font-mono"
/>
<Button
size="sm"
variant="ghost"
onClick={handleCopyApiKey}
className="text-white/70 hover:text-white hover:bg-white/10"
className="hover:bg-accent"
>
{copied ? (
<Check className="h-4 w-4 text-green-400" />
<Check className="h-4 w-4 text-chart-2" />
) : (
<Copy className="h-4 w-4" />
)}
@ -648,31 +644,31 @@ export function IntegrationsView() {
{/* Steps */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-white/80">
<h4 className="text-sm font-medium text-muted-foreground">
Follow these steps:
</h4>
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
1
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Click "Add to Shortcuts" below to open the shortcut
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
2
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Paste your API key when prompted
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
3
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Start using your shortcut!
</p>
</div>
@ -682,8 +678,9 @@ export function IntegrationsView() {
<div className="flex gap-2 pt-2">
<Button
onClick={handleOpenShortcut}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
className="flex-1"
disabled={!selectedShortcutType}
variant="default"
>
<Image
src="/images/ios-shortcuts.png"

View file

@ -1,115 +1,61 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import {
fetchConnectionsFeature,
fetchMemoriesFeature,
fetchSubscriptionStatus,
} from "@lib/queries"
import { Button } from "@repo/ui/components/button"
import { Skeleton } from "@repo/ui/components/skeleton"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import {
CheckCircle,
CreditCard,
LoaderIcon,
LogOut,
User,
X,
} from "lucide-react"
import { CheckCircle, CreditCard, LoaderIcon, User, X } from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@lib/api"
import { useState } from "react"
export function ProfileView() {
const router = useRouter()
const { user: session } = useAuth()
const {
customer,
isLoading: isCustomerLoading,
openBillingPortal,
attach,
} = useCustomer()
const { user: session, org } = useAuth()
const organizations = org
const autumn = useCustomer()
const [isLoading, setIsLoading] = useState(false)
const [billingData, setBillingData] = useState<{
isPro: boolean
memoriesUsed: number
memoriesLimit: number
connectionsUsed: number
connectionsLimit: number
}>({
isPro: false,
memoriesUsed: 0,
memoriesLimit: 0,
connectionsUsed: 0,
connectionsLimit: 0,
})
useEffect(() => {
if (!isCustomerLoading) {
const memoriesFeature = customer?.features?.memories ?? {
usage: 0,
included_usage: 0,
}
const connectionsFeature = customer?.features?.connections ?? {
usage: 0,
included_usage: 0,
}
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
setBillingData({
isPro:
customer?.products?.some(
(product) => product.id === "consumer_pro",
) ?? false,
memoriesUsed: memoriesFeature?.usage ?? 0,
memoriesLimit: memoriesFeature?.included_usage ?? 0,
connectionsUsed: connectionsFeature?.usage ?? 0,
connectionsLimit: connectionsFeature?.included_usage ?? 0,
})
}
}, [isCustomerLoading, customer])
const isPro = status.consumer_pro
const handleLogout = () => {
analytics.userSignedOut()
authClient.signOut()
router.push("/login")
}
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 {
const upgradeResult = await attach({
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
})
if (
upgradeResult.statusCode === 200 &&
upgradeResult.data &&
"code" in upgradeResult.data
) {
const isProPlanActivated =
upgradeResult.data.code === "new_product_attached"
if (isProPlanActivated && session?.name && session?.email) {
try {
await $fetch("@post/emails/welcome/pro", {
body: {
email: session?.email,
firstName: session?.name,
},
})
} catch (error) {
console.error(error)
}
}
}
window.location.reload()
} catch (error) {
console.error(error)
setIsLoading(false)
}
}
// Handle manage billing
const handleManageBilling = async () => {
await openBillingPortal({
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
})
}
@ -123,13 +69,13 @@ export function ProfileView() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/70 mb-4">
<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-white/10 hover:bg-white/20 text-white border-white/20"
className="bg-muted hover:bg-muted/80 text-foreground border-border"
size="sm"
>
<Link href="/login">Sign in</Link>
@ -143,148 +89,193 @@ export function ProfileView() {
return (
<div className="space-y-4">
{/* Profile Section */}
<div className="bg-white/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white/80" />
<div 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">
<p className="text-white font-medium text-sm">{session?.email}</p>
<p className="text-white/60 text-xs">Logged in</p>
<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>
{isCustomerLoading ? (
<div className="bg-white/5 rounded-lg p-4 space-y-3 flex items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Loading...
{/* 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>
) : (
<>
{/* Billing Section */}
<div className="bg-white/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
<CreditCard className="w-5 h-5 text-white/80" />
</div>
<div className="flex-1">
<HeadingH3Bold className="text-white text-sm">
{billingData.isPro ? "Pro Plan" : "Free Plan"}
{billingData.isPro && (
<span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
Active
</span>
)}
</HeadingH3Bold>
<p className="text-white/60 text-xs">
{billingData.isPro
? "Expanded memory capacity"
: "Basic plan"}
</p>
</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>
{/* Usage Stats */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Memories</span>
<span
className={`text-sm ${billingData.memoriesUsed >= billingData.memoriesLimit ? "text-red-400" : "text-white/90"}`}
>
{billingData.memoriesUsed} / {billingData.memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
billingData.memoriesUsed >= billingData.memoriesLimit
? "bg-red-500"
: billingData.isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((billingData.memoriesUsed / billingData.memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
{billingData.isPro && (
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Connections</span>
<span className="text-sm text-white/90">
{billingData.connectionsUsed} / 10
</span>
</div>
)}
{/* Billing Actions */}
<div className="pt-2">
{billingData.isPro ? (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-white/20"
onClick={handleManageBilling}
size="sm"
variant="outline"
>
Manage Billing
</Button>
</motion.div>
) : (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0"
disabled={isLoading || isCustomerLoading}
onClick={handleUpgrade}
size="sm"
>
{isLoading || isCustomerLoading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
"Upgrade to Pro - $15/month"
)}
</Button>
</motion.div>
)}
<div className="flex-1">
<HeadingH3Bold className="text-foreground text-sm">
{isPro ? "Pro Plan" : "Free Plan"}
{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>
)}
</HeadingH3Bold>
<p className="text-muted-foreground text-xs">
{isPro ? "Expanded memory capacity" : "Basic plan"}
</p>
</div>
</div>
{/* Plan Comparison - Only show for free users */}
{!billingData.isPro && (
<div className="bg-white/5 rounded-lg p-4 space-y-4">
<HeadingH3Bold className="text-white text-sm">
Upgrade to Pro
</HeadingH3Bold>
{/* Usage Stats */}
<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"
: isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{isPro && (
<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">
{isPro ? (
<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 - $15/month
</span>
<span className="sm:hidden">Upgrade to Pro</span>
</>
)}
</Button>
)}
</div>
{!isPro && (
<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-white/5 rounded-lg border border-white/10">
<h4 className="font-medium text-white/90 mb-3 text-sm">
<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-sm text-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/70">
<X className="h-4 w-4 text-red-400" />
<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-sm text-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
@ -292,50 +283,43 @@ export function ProfileView() {
{/* Pro Plan */}
<div className="p-3 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
<h4 className="font-medium text-white mb-3 flex items-center gap-2 text-sm">
Pro Plan
<span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full">
Recommended
</span>
<h4 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-sm text-white/90">
<li className="flex items-center gap-2 text-sm text-black/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/50 text-center">
<p className="text-xs text-muted-foreground text-center leading-relaxed">
$15/month (only for first 100 users) Cancel anytime. No
questions asked.
</p>
</div>
)}
</>
</div>
)}
<Button
className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30"
onClick={handleLogout}
variant="destructive"
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
</div>
)
}
}

View file

@ -1,2 +1,11 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
.sm-tweet-theme .react-tweet-theme {
--tweet-container-margin: 0px;
}
.sm-tweet-theme .tweet-header-module__A9EVQG__authorFollow {
display: none;
}

View file

@ -1,3 +1,4 @@
import { MCPIcon } from '@/components/menu';
import { colors } from '@repo/ui/memory-graph/constants';
import {
GoogleDocs,
@ -14,12 +15,16 @@ import {
} from '@ui/assets/icons';
import { FileText } from 'lucide-react';
export const getDocumentIcon = (type: string, className: string) => {
export const getDocumentIcon = (type: string, className: string, source?: string) => {
const iconProps = {
className,
style: { color: colors.text.muted },
};
if(source === "mcp") {
return <MCPIcon {...iconProps} />;
}
switch (type) {
case 'google_doc':
return <GoogleDocs {...iconProps} />;

View file

@ -0,0 +1,8 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -49,11 +49,13 @@
"@tanstack/react-query-devtools": "^5.84.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/dompurify": "^3.2.0",
"ai": "5.0.0-beta.24",
"autumn-js": "0.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.7",
"dotenv": "^16.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
@ -61,6 +63,7 @@
"is-hotkey": "^0.2.0",
"isbot": "^5.1.28",
"lucide-react": "^0.525.0",
"masonic": "^4.1.0",
"motion": "^12.19.2",
"next": "15.3.0",
"next-themes": "^0.4.6",
@ -71,6 +74,7 @@
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^6.0.0",
"react-tweet": "^3.2.2",
"recharts": "2",
"shadcn-dropzone": "^0.2.1",
"slate": "^0.118.0",

View file

@ -1,6 +1,7 @@
import type { UIMessage } from "@ai-sdk/react"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { useCallback } from "react"
/**
* Deep equality check for UIMessage arrays to prevent unnecessary state updates
@ -206,39 +207,48 @@ export function usePersistentChat() {
const currentChatId = projectState?.currentChatId ?? null
function setCurrentChatId(chatId: string | null): void {
setCurrentChatIdRaw(projectId, chatId)
}
const setCurrentChatId = useCallback(
(chatId: string | null): void => {
setCurrentChatIdRaw(projectId, chatId)
},
[projectId, setCurrentChatIdRaw],
)
function setConversation(chatId: string, messages: UIMessage[]): void {
setConversationRaw(projectId, chatId, messages)
}
const setConversation = useCallback(
(chatId: string, messages: UIMessage[]): void => {
setConversationRaw(projectId, chatId, messages)
},
[projectId, setConversationRaw],
)
function deleteConversation(chatId: string): void {
deleteConversationRaw(projectId, chatId)
}
const deleteConversation = useCallback(
(chatId: string): void => {
deleteConversationRaw(projectId, chatId)
},
[projectId, deleteConversationRaw],
)
function setConversationTitle(
chatId: string,
title: string | undefined,
): void {
setConversationTitleRaw(projectId, chatId, title)
}
const setConversationTitle = useCallback(
(chatId: string, title: string | undefined): void => {
setConversationTitleRaw(projectId, chatId, title)
},
[projectId, setConversationTitleRaw],
)
function getCurrentConversation(): UIMessage[] | undefined {
const getCurrentConversation = useCallback((): UIMessage[] | undefined => {
const convs = projectState?.conversations ?? {}
const id = currentChatId
if (!id) return undefined
return convs[id]?.messages
}
}, [projectState?.conversations, currentChatId])
function getCurrentChat(): ConversationSummary | undefined {
const getCurrentChat = useCallback((): ConversationSummary | undefined => {
const id = currentChatId
if (!id) return undefined
const rec = projectState?.conversations?.[id]
if (!rec) return undefined
return { id, title: rec.title, lastUpdated: rec.lastUpdated }
}
}, [currentChatId, projectState?.conversations])
return {
conversations,

View file

@ -1,9 +1,9 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface ProjectState {
selectedProject: string;
setSelectedProject: (projectId: string) => void;
selectedProject: string
setSelectedProject: (projectId: string) => void
}
export const useProjectStore = create<ProjectState>()(
@ -16,14 +16,14 @@ export const useProjectStore = create<ProjectState>()(
name: "selectedProject",
},
),
);
)
interface MemoryGraphState {
positionX: number;
positionY: number;
setPositionX: (x: number) => void;
setPositionY: (y: number) => void;
setPosition: (x: number, y: number) => void;
positionX: number
positionY: number
setPositionX: (x: number) => void
setPositionY: (y: number) => void
setPosition: (x: number, y: number) => void
}
export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
@ -32,34 +32,34 @@ export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
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;
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 }),
}));
}))
export function useProject() {
const selectedProject = useProjectStore((state) => state.selectedProject);
const selectedProject = useProjectStore((state) => state.selectedProject)
const setSelectedProject = useProjectStore(
(state) => state.setSelectedProject,
);
return { selectedProject, 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);
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,
@ -67,14 +67,33 @@ export function useMemoryGraphPosition() {
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 };
const isOpen = useChatStore((state) => state.isOpen)
const setIsOpen = useChatStore((state) => state.setIsOpen)
const toggleChat = useChatStore((state) => state.toggleChat)
return { isOpen, setIsOpen, toggleChat }
}
export { usePersistentChat, usePersistentChatStore } from "./chat";
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

@ -3,8 +3,8 @@
"assist": {
"actions": {
"source": {
"organizeImports": "on",
"useSortedAttributes": "on",
"organizeImports": "off",
"useSortedAttributes": "off",
"useSortedKeys": "off"
}
},

6791
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -10,13 +10,11 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
export const fetchSubscriptionStatus = (
autumn: ReturnType<typeof useCustomer>,
isEnabled: boolean,
) =>
useQuery({
queryFn: async () => {
const allPlans = [
"pro",
"memory_starter",
"memory_growth",
"consumer_pro",
]
const statusMap: Record<string, boolean | null> = {}
@ -24,7 +22,7 @@ export const fetchSubscriptionStatus = (
await Promise.all(
allPlans.map(async (plan) => {
try {
const res = await autumn.check({
const res = autumn.check({
productId: plan,
})
statusMap[plan] = res.data?.allowed ?? false
@ -40,31 +38,35 @@ export const fetchSubscriptionStatus = (
queryKey: ["subscription-status"],
refetchInterval: 5000, // Refetch every 5 seconds
staleTime: 4000, // Consider data stale after 4 seconds
enabled: isEnabled,
})
// Feature checks
export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>) =>
export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>, isEnabled: boolean) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "memories" })
const res = autumn.check({ featureId: "memories" })
return res.data
},
queryKey: ["autumn-feature", "memories"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
enabled: isEnabled,
})
export const fetchConnectionsFeature = (
autumn: ReturnType<typeof useCustomer>,
isEnabled: boolean,
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "connections" })
const res = autumn.check({ featureId: "connections" })
return res.data
},
queryKey: ["autumn-feature", "connections"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
enabled: isEnabled,
})
// Product checks
@ -73,7 +75,7 @@ export const fetchConsumerProProduct = (
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "consumer_pro" })
const res = autumn.check({ productId: "consumer_pro" })
return res.data
},
queryKey: ["autumn-product", "consumer_pro"],
@ -84,7 +86,7 @@ export const fetchConsumerProProduct = (
export const fetchProProduct = (autumn: ReturnType<typeof useCustomer>) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "pro" })
const res = autumn.check({ productId: "pro" })
return res.data
},
queryKey: ["autumn-product", "pro"],

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@ function Avatar({
return (
<AvatarPrimitive.Root
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
"relative flex size-8 shrink-0 overflow-hidden rounded-full hover:cursor-pointer",
className,
)}
data-slot="avatar"

View file

@ -19,12 +19,14 @@ const buttonVariants = cva(
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
settingsNav: "cursor-pointer rounded-sm bg-transparent",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
settingsNav: "h-8 gap-0 px-0 py-0",
},
},
defaultVariants: {

View file

@ -18,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className,
)}
data-slot="card-header"

View file

@ -0,0 +1,45 @@
interface PlusPatternBackgroundProps {
plusSize?: number;
plusColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
fade?: boolean;
[key: string]: any;
}
export const BackgroundPlus: React.FC<PlusPatternBackgroundProps> = ({
plusColor = "#6b6b6b",
backgroundColor = "transparent",
plusSize = 60,
className,
fade = true,
style,
...props
}) => {
const encodedPlusColor = encodeURIComponent(plusColor);
const maskStyle: React.CSSProperties = fade
? {
maskImage: "radial-gradient(circle, white 10%, transparent 90%)",
WebkitMaskImage: "radial-gradient(circle, white 10%, transparent 90%)",
}
: {};
const backgroundStyle: React.CSSProperties = {
backgroundColor,
backgroundImage: `url("data:image/svg+xml,%3Csvg width='${plusSize}' height='${plusSize}' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='${encodedPlusColor}' fill-opacity='0.2'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
...maskStyle,
...style,
};
return (
<div
className={`absolute inset-0 h-full w-full ${className}`}
style={backgroundStyle}
{...props}
/>
);
};
export default BackgroundPlus;

View file

@ -40,7 +40,7 @@ function TabsTrigger({
return (
<TabsPrimitive.Trigger
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-accent dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-md [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-slot="tabs-trigger"

View file

@ -4,160 +4,167 @@
@source "../../apps/**/*.tsx";
@source "../../packages/**/*.tsx";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--color-sm-shark-alt);
--color-background: var(--color-sm-shark-alt);
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-sans:
--font-serif:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Design System */
--color-sm-alto: #dedede;
--color-sm-black: #000000;
--color-sm-boulder: #777777;
--color-sm-bunker: #090b0e;
--color-sm-charcoal: #2e2e2e;
--color-sm-dodger-blue: #267ffa;
--color-sm-chelsea-gem: #aa5d00;
--color-sm-dove-gray: #696969;
--color-sm-emperor: #545454;
--color-sm-gallery: #efefef;
--color-sm-gray: #888888;
--color-sm-heliotrope: #c472fb;
--color-sm-japanese-laurel: #008000;
--color-sm-mercury: #e1e1e1;
--color-sm-mine-shaft: #383838;
--color-sm-niagara: #0aa49f;
--color-sm-picton-blue: #3f96e6;
--color-sm-red-orange: #fb2c36;
--color-sm-seance: #7928a1;
--color-sm-selective-yellow: #ffb900;
--color-sm-shark: #1c2026;
--color-sm-shark-alt: #1c2026;
--color-sm-light-shark: #2b2e33;
--color-sm-silver-chalice: #b0b0b0;
--color-sm-tundora: #4d4d4d;
--color-sm-white: #ffffff;
--color-sm-white-alt: #fefefe;
--color-sm-wild-watermelon: #ff4d8d;
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--radius: 0.75rem;
--background: oklch(0.9846 0.0017 247.8389);
--foreground: oklch(0.2744 0.0073 285.9081);
--card: oklch(0.9851 0 0);
--card-foreground: oklch(0.2744 0.0073 285.9081);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--popover-foreground: oklch(0.2744 0.0073 285.9081);
--primary: oklch(0.614 0.2014 258.1073);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9378 0.0296 262.5395);
--secondary-foreground: oklch(0.614 0.2014 258.1073);
--muted: oklch(0.9846 0.0017 247.8389);
--muted-foreground: oklch(0.5693 0 0);
--accent: oklch(0.92 0.0054 286.2936);
--accent-foreground: oklch(0.2744 0.0073 285.9081);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9366 0.0017 247.8401);
--input: oklch(0.9702 0 0);
--ring: oklch(1 0 0);
--chart-1: oklch(0.7197 0.1448 266.6983);
--chart-2: oklch(0.8101 0.1488 148.2032);
--chart-3: oklch(0.7507 0.1353 58.4182);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.7535 0.139 232.6615);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-primary: oklch(0.9851 0 0);
--sidebar-primary-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-accent: oklch(0.92 0.0054 286.2936);
--sidebar-accent-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-border: oklch(0.9366 0.0017 247.8401);
--sidebar-ring: oklch(0.9889 0.0053 17.2475);
--destructive-foreground: oklch(0.967 0.0029 264.5419);
--font-sans: Space Grotesk, ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, ui-monospace, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.04;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: -0.01em;
--spacing: 0.24rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1);
--tracking-normal: -0.01em;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--background: oklch(0.1487 0.0073 258.0408);
--foreground: oklch(0.967 0.0029 264.5419);
--card: oklch(0.1487 0.0073 258.0408);
--card-foreground: oklch(0.967 0.0029 264.5419);
--popover: oklch(0.2264 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: var(--color-sm-black);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
--primary: oklch(0.614 0.2014 258.1073);
--primary-foreground: oklch(0.967 0.0029 264.5419);
--secondary: oklch(0.967 0.0029 264.5419);
--secondary-foreground: oklch(0.1487 0.0073 258.0408);
--muted: oklch(0.1482 0.0055 247.8035);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.3012 0 0);
--accent-foreground: oklch(0.967 0.0029 264.5419);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.3715 0 0);
--input: oklch(0.262 0.0074 285.8781);
--ring: oklch(0 0 0);
--chart-1: oklch(0.7197 0.1448 266.6983);
--chart-2: oklch(0.8101 0.1488 148.2032);
--chart-3: oklch(0.7507 0.1353 58.4182);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.7535 0.139 232.6615);
--sidebar: oklch(0.2264 0 0);
--sidebar-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-primary: oklch(0.2264 0 0);
--sidebar-primary-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-accent: oklch(0.3012 0 0);
--sidebar-accent-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(0.967 0.0029 264.5419);
--radius: 0.75rem;
--font-sans: Space Grotesk, ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, ui-monospace, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.04;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: -0.01em;
--spacing: 0.24rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1);
}
/* Custom Scrollbar Styles */
@ -223,3 +230,31 @@
.memory-sheet-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Memory Dialog Scrollbar Styles */
.memory-dialog-scroll::-webkit-scrollbar {
width: 8px;
}
.memory-dialog-scroll::-webkit-scrollbar-track {
background: hsl(var(--muted) / 0.05);
}
.memory-dialog-scroll::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.5);
border-radius: 4px;
}
.memory-dialog-scroll::-webkit-scrollbar-thumb:hover {
background: hsl(var(--border) / 0.7);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View file

@ -129,17 +129,19 @@ export const MemoryGraph = ({
containerSize.width > 0 &&
containerSize.height > 0
) {
// For consumer variant, auto-fit to show all content
if (variant === "consumer") {
// Auto-fit to show all content for both variants
// Add a small delay to ensure the canvas is fully initialized
const timer = setTimeout(() => {
autoFitToViewport(nodes, containerSize.width, containerSize.height);
hasAutoFittedRef.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [
nodes,
containerSize.width,
containerSize.height,
variant,
autoFitToViewport,
]);
@ -175,16 +177,36 @@ export const MemoryGraph = ({
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setContainerSize({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
// Only update if size actually changed and is valid
setContainerSize((prev) => {
if (prev.width !== newWidth || prev.height !== newHeight) {
return { width: newWidth, height: newHeight };
}
return prev;
});
}
};
updateSize();
// Use a slight delay to ensure DOM is fully rendered
const timer = setTimeout(updateSize, 0);
updateSize(); // Also call immediately
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
// Use ResizeObserver for more accurate container size detection
const resizeObserver = new ResizeObserver(updateSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
clearTimeout(timer);
window.removeEventListener("resize", updateSize);
resizeObserver.disconnect();
};
}, []);
// Enhanced node drag start that includes nodes data
@ -390,7 +412,7 @@ export const MemoryGraph = ({
WebkitUserSelect: "none",
}}
>
{containerSize.width > 0 && (
{(containerSize.width > 0 && containerSize.height > 0) && (
<GraphCanvas
draggingNodeId={draggingNodeId}
edges={edges}