chore: delete document, document ui (#673)

This commit is contained in:
MaheshtheDev 2026-01-16 21:44:26 +00:00
parent 59c294b299
commit 9fd49193ac
19 changed files with 1505 additions and 539 deletions

View file

@ -26,6 +26,9 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
You are reviewing a PR for supermemory - a Turbo monorepo with multiple apps and packages.
## Repository Structure Context
@ -75,4 +78,4 @@ jobs:
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
claude_args: '--allowedTools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

View file

@ -33,6 +33,14 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{process.env.NODE_ENV === "development" && (
<script
crossOrigin="anonymous"
src="https://unpkg.com/react-scan/dist/auto.global.js"
/>
)}
</head>
<body
className={`${font.variable} antialiased overflow-x-hidden`}
suppressHydrationWarning

View file

@ -0,0 +1,106 @@
"use client"
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react"
import { useAuth } from "@lib/auth-context"
export type MemoryFormData = {
twitter: string
linkedin: string
description: string
otherLinks: string[]
} | null
interface OnboardingContextValue {
name: string
setName: (name: string) => void
memoryFormData: MemoryFormData
setMemoryFormData: (data: MemoryFormData) => void
resetOnboarding: () => void
}
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
export function useOnboardingContext() {
const ctx = useContext(OnboardingContext)
if (!ctx) {
throw new Error("useOnboardingContext must be used within OnboardingLayout")
}
return ctx
}
export default function OnboardingLayout({
children,
}: {
children: ReactNode
}) {
const { user } = useAuth()
const [name, setNameState] = useState<string>("")
const [memoryFormData, setMemoryFormDataState] =
useState<MemoryFormData>(null)
useEffect(() => {
const storedName = localStorage.getItem("onboarding_name")
const storedMemoryFormData = localStorage.getItem(
"onboarding_memoryFormData",
)
if (storedName) {
setNameState(storedName)
} else if (user?.name) {
setNameState(user.name)
localStorage.setItem("onboarding_name", user.name)
}
if (storedMemoryFormData) {
try {
setMemoryFormDataState(JSON.parse(storedMemoryFormData))
} catch {
// ignore parse errors
}
}
}, [user?.name])
const setName = useCallback((newName: string) => {
setNameState(newName)
localStorage.setItem("onboarding_name", newName)
localStorage.setItem("username", newName)
}, [])
const setMemoryFormData = useCallback((data: MemoryFormData) => {
setMemoryFormDataState(data)
if (data) {
localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data))
} else {
localStorage.removeItem("onboarding_memoryFormData")
}
}, [])
const resetOnboarding = useCallback(() => {
localStorage.removeItem("onboarding_name")
localStorage.removeItem("onboarding_memoryFormData")
setNameState("")
setMemoryFormDataState(null)
}, [])
const contextValue: OnboardingContextValue = {
name,
setName,
memoryFormData,
setMemoryFormData,
resetOnboarding,
}
return (
<OnboardingContext.Provider value={contextValue}>
{children}
</OnboardingContext.Provider>
)
}

View file

@ -1,267 +1,18 @@
"use client"
import { useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { useState, useEffect } from "react"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { InputStep } from "../../../components/new/onboarding/welcome/input-step"
import { GreetingStep } from "../../../components/new/onboarding/welcome/greeting-step"
import { WelcomeStep } from "../../../components/new/onboarding/welcome/welcome-step"
import { ContinueStep } from "../../../components/new/onboarding/welcome/continue-step"
import { FeaturesStep } from "../../../components/new/onboarding/welcome/features-step"
import { ProfileStep } from "../../../components/new/onboarding/welcome/profile-step"
import { RelatableQuestion } from "../../../components/new/onboarding/setup/relatable-question"
import { IntegrationsStep } from "../../../components/new/onboarding/setup/integrations-step"
import { InitialHeader } from "@/components/initial-header"
import { SetupHeader } from "../../../components/new/onboarding/setup/header"
import { ChatSidebar } from "../../../components/new/onboarding/setup/chat-sidebar"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
function UserSupermemory({ name }: { name: string }) {
return (
<motion.div
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
>
<Logo className="h-14 text-white" />
<div className="flex flex-col items-start justify-center ml-4">
<p className="text-white text-[25px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-4xl leading-none -mt-2">
supermemory
</p>
</div>
</motion.div>
)
}
import { useEffect } from "react"
import { useRouter } from "next/navigation"
export default function OnboardingPage() {
const searchParams = useSearchParams()
const { user } = useAuth()
const flow = searchParams.get("flow") as "welcome" | "setup" | null
const step = searchParams.get("step") as string | null
const [name, setName] = useState(user?.name ?? "")
const [isSubmitting, setIsSubmitting] = useState(false)
const [memoryFormData, setMemoryFormData] = useState<{
twitter: string
linkedin: string
description: string
otherLinks: string[]
} | null>(null)
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
const currentFlow = flow || "welcome"
const currentStep = step || "input"
const router = useRouter()
useEffect(() => {
if (user?.name) {
setName(user.name)
localStorage.setItem("username", user.name)
}
}, [user?.name])
useEffect(() => {
if (currentFlow === "welcome" && currentStep === "input") {
setShowWelcomeContent(false)
const timer = setTimeout(() => {
setShowWelcomeContent(true)
}, 1250)
return () => clearTimeout(timer)
}
}, [currentFlow, currentStep])
useEffect(() => {
if (currentFlow !== "welcome") return
const timers: NodeJS.Timeout[] = []
switch (currentStep) {
case "greeting":
timers.push(
setTimeout(() => {
// Auto-advance to welcome step
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=welcome",
)
}, 2000),
)
break
case "welcome":
timers.push(
setTimeout(() => {
// Auto-advance to username step
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=username",
)
}, 2000),
)
break
}
return () => {
timers.forEach(clearTimeout)
}
}, [currentStep, currentFlow])
const handleSubmit = () => {
localStorage.setItem("username", name)
if (name.trim()) {
setIsSubmitting(true)
window.history.replaceState(
null,
"",
"/new/onboarding?flow=welcome&step=greeting",
)
setIsSubmitting(false)
}
}
const renderWelcomeStep = () => {
switch (currentStep) {
case "input":
return (
<InputStep
key="input"
name={name}
setName={setName}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)
case "greeting":
return <GreetingStep key="greeting" name={name} />
case "welcome":
return <WelcomeStep key="welcome" />
case "username":
return <ContinueStep key="username" />
case "features":
return <FeaturesStep key="features" />
case "memories":
return <ProfileStep key="profile" onSubmit={setMemoryFormData} />
default:
return null
}
}
const renderSetupStep = () => {
switch (currentStep) {
case "relatable":
return <RelatableQuestion key="relatable" />
case "integrations":
return <IntegrationsStep key="integrations" />
default:
return null
}
}
const isWelcomeFlow = currentFlow === "welcome"
const isSetupFlow = currentFlow === "setup"
const minimizeNovaOrb =
isWelcomeFlow && ["features", "memories"].includes(currentStep)
const novaSize = currentStep === "memories" ? 150 : 300
const showUserSupermemory = isWelcomeFlow && currentStep === "username"
router.replace("/new/onboarding/welcome?step=input")
}, [router])
return (
<div className="h-screen overflow-hidden bg-black">
{isWelcomeFlow && (
<InitialHeader
showUserSupermemory={
currentStep === "features" || currentStep === "memories"
}
name={name}
/>
)}
{isSetupFlow && <SetupHeader />}
{isSetupFlow && <AnimatedGradientBackground animateFromBottom={false} />}
{isWelcomeFlow && currentStep === "input" && (
<AnimatedGradientBackground animateFromBottom={true} />
)}
{isWelcomeFlow && showWelcomeContent && (
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
<motion.div
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.6,
}}
/>
<motion.div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
)}
animate={{
gap: minimizeNovaOrb ? 0 : 16,
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
>
<motion.div
animate={{
scale:
currentStep === "features"
? 0.7
: currentStep === "memories"
? 0.4
: 1,
padding: minimizeNovaOrb ? 0 : 48,
paddingTop: 0,
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 0.2,
}}
className="relative"
>
<NovaOrb size={novaSize} />
{showUserSupermemory && <UserSupermemory name={name} />}
</motion.div>
<AnimatePresence mode="wait">{renderWelcomeStep()}</AnimatePresence>
</motion.div>
</div>
)}
{isSetupFlow && (
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-8">
<AnimatePresence mode="wait">
{renderSetupStep()}
</AnimatePresence>
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
</div>
</div>
</main>
)}
<div className="h-screen overflow-hidden bg-black flex items-center justify-center">
<div className="text-white/50 text-sm">Loading...</div>
</div>
)
}

View file

@ -0,0 +1,70 @@
"use client"
import { createContext, useContext, useCallback, type ReactNode } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
export const SETUP_STEPS = ["relatable", "integrations"] as const
export type SetupStep = (typeof SETUP_STEPS)[number]
interface SetupContextValue {
memoryFormData: MemoryFormData
currentStep: SetupStep
goToStep: (step: SetupStep) => void
goToWelcome: (step?: string) => void
finishOnboarding: () => void
}
const SetupContext = createContext<SetupContextValue | null>(null)
export function useSetupContext() {
const ctx = useContext(SetupContext)
if (!ctx) {
throw new Error("useSetupContext must be used within SetupLayout")
}
return ctx
}
export default function SetupLayout({ children }: { children: ReactNode }) {
const router = useRouter()
const searchParams = useSearchParams()
const { memoryFormData, resetOnboarding } = useOnboardingContext()
const stepParam = searchParams.get("step")
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
? (stepParam as SetupStep)
: "relatable"
const goToStep = useCallback(
(step: SetupStep) => {
router.push(`/new/onboarding/setup?step=${step}`)
},
[router],
)
const goToWelcome = useCallback(
(step = "input") => {
router.push(`/new/onboarding/welcome?step=${step}`)
},
[router],
)
const finishOnboarding = useCallback(() => {
resetOnboarding()
router.push("/new")
}, [router, resetOnboarding])
const contextValue: SetupContextValue = {
memoryFormData,
currentStep,
goToStep,
goToWelcome,
finishOnboarding,
}
return (
<SetupContext.Provider value={contextValue}>
{children}
</SetupContext.Provider>
)
}

View file

@ -0,0 +1,68 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { RelatableQuestion } from "@/components/new/onboarding/setup/relatable-question"
import { IntegrationsStep } from "@/components/new/onboarding/setup/integrations-step"
import { SetupHeader } from "@/components/new/onboarding/setup/header"
import { ChatSidebar } from "@/components/new/onboarding/setup/chat-sidebar"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
import { useSetupContext, type SetupStep } from "./layout"
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
return (
<motion.div
className="text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
<button
type="button"
onClick={() => goToStep("relatable")}
className="text-blue-400 underline"
>
Go to first step
</button>
</motion.div>
)
}
export default function SetupPage() {
const { memoryFormData, currentStep, goToStep } = useSetupContext()
const renderStep = () => {
switch (currentStep) {
case "relatable":
return <RelatableQuestion key="relatable" />
case "integrations":
return <IntegrationsStep key="integrations" />
default:
return <StepNotFound key="not-found" goToStep={goToStep} />
}
}
return (
<div className="h-screen overflow-hidden bg-black">
<SetupHeader />
<AnimatedGradientBackground animateFromBottom={false} />
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-8">
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
</div>
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,143 @@
"use client"
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
export const WELCOME_STEPS = [
"input",
"greeting",
"welcome",
"username",
"features",
"memories",
] as const
export type WelcomeStep = (typeof WELCOME_STEPS)[number]
interface WelcomeContextValue {
name: string
setName: (name: string) => void
isSubmitting: boolean
setIsSubmitting: (value: boolean) => void
showWelcomeContent: boolean
memoryFormData: MemoryFormData
setMemoryFormData: (data: MemoryFormData) => void
currentStep: WelcomeStep
goToStep: (step: WelcomeStep) => void
goToSetup: (step?: string) => void
}
const WelcomeContext = createContext<WelcomeContextValue | null>(null)
export function useWelcomeContext() {
const ctx = useContext(WelcomeContext)
if (!ctx) {
throw new Error("useWelcomeContext must be used within WelcomeLayout")
}
return ctx
}
export default function WelcomeLayout({ children }: { children: ReactNode }) {
const router = useRouter()
const searchParams = useSearchParams()
const { name, setName, memoryFormData, setMemoryFormData } =
useOnboardingContext()
const stepParam = searchParams.get("step")
const currentStep: WelcomeStep = WELCOME_STEPS.includes(
stepParam as WelcomeStep,
)
? (stepParam as WelcomeStep)
: "input"
const [isSubmitting, setIsSubmitting] = useState(false)
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
useEffect(() => {
if (currentStep === "input") {
setShowWelcomeContent(false)
const timer = setTimeout(() => {
if (isMountedRef.current) {
setShowWelcomeContent(true)
}
}, 1000)
return () => clearTimeout(timer)
}
setShowWelcomeContent(true)
}, [currentStep])
useEffect(() => {
const timers: NodeJS.Timeout[] = []
if (currentStep === "greeting") {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
router.replace("/new/onboarding/welcome?step=welcome")
}
}, 2000),
)
} else if (currentStep === "welcome") {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
router.replace("/new/onboarding/welcome?step=username")
}
}, 2000),
)
}
return () => {
timers.forEach(clearTimeout)
}
}, [currentStep, router])
const goToStep = useCallback(
(step: WelcomeStep) => {
router.push(`/new/onboarding/welcome?step=${step}`)
},
[router],
)
const goToSetup = useCallback(
(step = "relatable") => {
router.push(`/new/onboarding/setup?step=${step}`)
},
[router],
)
const contextValue: WelcomeContextValue = {
name,
setName,
isSubmitting,
setIsSubmitting,
showWelcomeContent,
memoryFormData,
setMemoryFormData,
currentStep,
goToStep,
goToSetup,
}
return (
<WelcomeContext.Provider value={contextValue}>
{children}
</WelcomeContext.Provider>
)
}

View file

@ -0,0 +1,185 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { cn } from "@lib/utils"
import { InputStep } from "@/components/new/onboarding/welcome/input-step"
import { GreetingStep } from "@/components/new/onboarding/welcome/greeting-step"
import { WelcomeStep } from "@/components/new/onboarding/welcome/welcome-step"
import { OnboardingContentStep } from "@/components/new/onboarding/welcome/continue-step"
import { InitialHeader } from "@/components/initial-header"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
import {
useWelcomeContext,
type WelcomeStep as WelcomeStepType,
} from "./layout"
import { gapVariants, orbVariants } from "@/lib/variants"
function UserSupermemory({ name }: { name: string }) {
return (
<motion.div
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
>
<Logo className="h-14 text-white" />
<div className="flex flex-col items-start justify-center ml-4">
<p className="text-white text-[25px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-4xl leading-none -mt-2">
supermemory
</p>
</div>
</motion.div>
)
}
function StepNotFound({
goToStep,
}: {
goToStep: (step: WelcomeStepType) => void
}) {
return (
<motion.div
className="text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
<button
type="button"
onClick={() => goToStep("input")}
className="text-blue-400 underline"
>
Start from beginning
</button>
</motion.div>
)
}
export default function WelcomePage() {
const {
name,
setName,
isSubmitting,
setIsSubmitting,
showWelcomeContent,
setMemoryFormData,
currentStep,
goToStep,
} = useWelcomeContext()
const handleSubmit = () => {
localStorage.setItem("username", name)
if (name.trim()) {
setIsSubmitting(true)
goToStep("greeting")
setIsSubmitting(false)
}
}
const renderStep = () => {
switch (currentStep) {
case "input":
return (
<InputStep
key="input"
name={name}
setName={setName}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)
case "greeting":
return <GreetingStep key="greeting" name={name} />
case "welcome":
return <WelcomeStep key="welcome" />
case "username":
case "features":
case "memories":
return (
<OnboardingContentStep
key="onboarding-content"
currentView={
currentStep === "username"
? "continue"
: currentStep === "features"
? "features"
: "memories"
}
onSubmit={setMemoryFormData}
/>
)
default:
return <StepNotFound key="not-found" goToStep={goToStep} />
}
}
const minimizeNovaOrb = ["features", "memories"].includes(currentStep)
const novaSize = currentStep === "memories" ? 150 : 300
const showUserSupermemory = currentStep === "username"
return (
<div className="h-screen overflow-hidden bg-black">
<InitialHeader
showUserSupermemory={
currentStep === "features" || currentStep === "memories"
}
name={name}
/>
{currentStep === "input" && (
<AnimatedGradientBackground animateFromBottom={true} />
)}
{showWelcomeContent && (
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
<motion.div
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.6,
}}
/>
<motion.div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
)}
variants={gapVariants}
animate={minimizeNovaOrb ? "minimized" : "default"}
>
<motion.div
variants={orbVariants}
animate={
currentStep === "features"
? "features"
: currentStep === "memories"
? "memories"
: "default"
}
initial={{
padding: 0,
paddingTop: 0,
y: 60,
}}
className="relative"
>
<NovaOrb size={novaSize} />
{showUserSupermemory && <UserSupermemory name={name} />}
</motion.div>
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</motion.div>
</div>
)}
</div>
)
}

View file

@ -2,7 +2,7 @@
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { ArrowUpRightIcon, XIcon, Loader2 } from "lucide-react"
import { ArrowUpRightIcon, XIcon, Loader2, Trash2Icon, CheckIcon } from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
@ -20,6 +20,8 @@ import { useState, useEffect, useCallback, useMemo } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@repo/ui/components/button"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
// Dynamically importing to prevent DOMMatrix error
const PdfViewer = dynamic(
@ -43,12 +45,102 @@ interface DocumentModalProps {
onClose: () => void
}
interface DeleteButtonProps {
documentId: string | null | undefined
customId: string | null | undefined
deleteMutation: UseMutationResult<
unknown,
Error,
{ documentId: string },
unknown
>
}
function isTemporaryId(id: string | null | undefined): boolean {
if (!id) return false
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
function DeleteButton({ documentId, customId, deleteMutation }: DeleteButtonProps) {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const handleDelete = useCallback(() => {
const id = documentId ?? customId
if (!id) return
// Check both IDs to ensure we catch temporary documents regardless of which ID is used
if (isTemporaryId(documentId) || isTemporaryId(customId)) {
// this is when user added document immediately and trying to delete
toast.error("Cannot delete document", {
description: "This document is still being processed. Please wait.",
})
return
}
deleteMutation.mutate({ documentId: id as string })
}, [documentId, customId, deleteMutation])
return (
<AnimatePresence mode="wait">
{!deleteConfirmOpen ? (
<motion.button
key="trash"
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
onClick={() => setDeleteConfirmOpen(true)}
tabIndex={-1}
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
disabled={deleteMutation.isPending}
>
<Trash2Icon className="w-4 h-4 text-red-500" />
<span className="sr-only">Delete document</span>
</motion.button>
) : (
<motion.div
key="confirm"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1 px-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
<button
type="button"
onClick={() => setDeleteConfirmOpen(false)}
disabled={deleteMutation.isPending}
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<XIcon className="w-4 h-4 text-[#737373]" />
<span className="sr-only">Cancel delete</span>
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 text-green-500 animate-spin" />
) : (
<CheckIcon className="w-4 h-4 text-green-500" />
)}
<span className="sr-only">Confirm delete</span>
</button>
</motion.div>
)}
</AnimatePresence>
)
}
export function DocumentModal({
document: _document,
isOpen,
onClose,
}: DocumentModalProps) {
const { updateMutation } = useDocumentMutations()
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
const { initialEditorContent, initialEditorString } = useMemo(() => {
const content = _document?.content as string | null | undefined
@ -76,7 +168,9 @@ export function DocumentModal({
}, [initialEditorString])
useEffect(() => {
if (!isOpen) resetEditor()
if (!isOpen) {
resetEditor()
}
}, [isOpen, resetEditor])
const hasUnsavedChanges =
@ -107,13 +201,20 @@ export function DocumentModal({
<DialogTitle className="sr-only">
{_document?.title} - Document
</DialogTitle>
<div className="flex justify-between h-fit">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
/>
<div className="flex items-center gap-2">
<div className="flex items-center justify-between h-fit gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
deleteMutation={deleteMutation}
/>
{_document?.url && (
<a
href={_document.url}
@ -126,8 +227,10 @@ export function DocumentModal({
</a>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
type="button"
tabIndex={-1}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>

View file

@ -30,10 +30,10 @@ export function Title({
<div
className={cn(
dmSansClassName(),
"text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%] flex items-center gap-3",
"text-[16px] font-semibold text-[#FAFAFA] leading-[125%] flex items-center gap-3 min-w-0",
)}
>
<div className="pl-1 flex items-center gap-1 w-5 h-5">
<div className="pl-1 flex items-center gap-1 w-5 h-5 shrink-0">
{getDocumentIcon(
documentType as DocumentType,
"w-5 h-5",
@ -49,7 +49,7 @@ export function Title({
</p>
)}
</div>
{title || "Untitled Document"}
<span className="truncate">{title || "Untitled Document"}</span>
</div>
)
}

View file

@ -2,14 +2,18 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import { PanelRightCloseIcon, SendIcon } from "lucide-react"
import { PanelRightCloseIcon, SendIcon, CheckIcon, XIcon } from "lucide-react"
import { collectValidUrls } from "@/lib/url-helpers"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { Streamdown } from "streamdown"
interface ChatSidebarProps {
formData: {
@ -20,11 +24,20 @@ interface ChatSidebarProps {
} | null
}
interface DraftDoc {
kind: "likes" | "link" | "x_research"
content: string
metadata: Record<string, string>
title?: string
url?: string
}
export function ChatSidebar({ formData }: ChatSidebarProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
const [messages, setMessages] = useState<
const [timelineMessages, setTimelineMessages] = useState<
{
message: string
type?: "formData" | "exa" | "memory" | "waiting"
@ -40,10 +53,82 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
}[]
>([])
const [isLoading, setIsLoading] = useState(false)
const [isFetchingDrafts, setIsFetchingDrafts] = useState(false)
const [draftDocs, setDraftDocs] = useState<DraftDoc[]>([])
const [xResearchStatus, setXResearchStatus] = useState<
"correct" | "incorrect" | null
>(null)
const [isConfirmed, setIsConfirmed] = useState(false)
const displayedMemoriesRef = useRef<Set<string>>(new Set())
const contextInjectedRef = useRef(false)
const draftsBuiltRef = useRef(false)
const isProcessingRef = useRef(false)
const {
messages: chatMessages,
sendMessage,
status,
} = useChat({
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
credentials: "include",
body: {
metadata: {
projectId: selectedProject,
model: "gemini-2.5-pro",
},
},
}),
})
const buildOnboardingContext = useCallback(() => {
if (!formData) return ""
const contextParts: string[] = []
if (formData.description?.trim()) {
contextParts.push(`User's interests/likes: ${formData.description}`)
}
if (formData.twitter) {
contextParts.push(`X/Twitter profile: ${formData.twitter}`)
}
if (formData.linkedin) {
contextParts.push(`LinkedIn profile: ${formData.linkedin}`)
}
if (formData.otherLinks.length > 0) {
contextParts.push(`Other links: ${formData.otherLinks.join(", ")}`)
}
const memoryTexts = timelineMessages
.filter((msg) => msg.type === "memory" && msg.memories)
.flatMap(
(msg) => msg.memories?.map((m) => `${m.title}: ${m.description}`) || [],
)
if (memoryTexts.length > 0) {
contextParts.push(`Extracted memories:\n${memoryTexts.join("\n")}`)
}
return contextParts.join("\n\n")
}, [formData, timelineMessages])
const handleSend = () => {
console.log("Message:", message)
if (!message.trim() || status === "submitted" || status === "streaming")
return
let messageToSend = message
const context = buildOnboardingContext()
if (context && !contextInjectedRef.current && chatMessages.length === 0) {
messageToSend = `${context}\n\nUser question: ${message}`
contextInjectedRef.current = true
}
sendMessage({ text: messageToSend })
setMessage("")
}
@ -97,8 +182,8 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
},
)
if (newMemories.length > 0 && messages.length < 10) {
setMessages((prev) => [
if (newMemories.length > 0 && timelineMessages.length < 10) {
setTimelineMessages((prev) => [
...prev,
{
message: newMemories
@ -123,13 +208,151 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
}
}
},
[messages.length],
[timelineMessages.length],
)
const buildDraftDocs = useCallback(async () => {
if (!formData || draftsBuiltRef.current) return
draftsBuiltRef.current = true
const hasContent =
formData.twitter ||
formData.linkedin ||
formData.otherLinks.length > 0 ||
formData.description?.trim()
if (!hasContent) return
setIsFetchingDrafts(true)
const drafts: DraftDoc[] = []
try {
if (formData.description?.trim()) {
drafts.push({
kind: "likes",
content: formData.description,
metadata: {
sm_source: "consumer",
description_source: "user_input",
},
title: "Your Interests",
})
}
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
const [exaResults, xResearchResult] = await Promise.all([
urls.length > 0
? fetch("/api/onboarding/extract-content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
})
.then((r) => r.json())
.then((data) => data.results || [])
.catch(() => [])
: Promise.resolve([]),
formData.twitter
? fetch("/api/onboarding/research", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
xUrl: formData.twitter,
name: user?.name,
email: user?.email,
}),
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => data?.text?.trim() || null)
.catch(() => null)
: Promise.resolve(null),
])
for (const result of exaResults) {
if (result.text || result.description) {
drafts.push({
kind: "link",
content: result.text || result.description || "",
metadata: {
sm_source: "consumer",
exa_url: result.url,
exa_title: result.title,
},
title: result.title || "Extracted Content",
url: result.url,
})
}
}
if (xResearchResult) {
drafts.push({
kind: "x_research",
content: xResearchResult,
metadata: {
sm_source: "consumer",
onboarding_source: "x_research",
x_url: formData.twitter,
},
title: "X/Twitter Profile Research",
url: formData.twitter,
})
}
setDraftDocs(drafts)
} catch (error) {
console.warn("Error building draft docs:", error)
} finally {
setIsFetchingDrafts(false)
}
}, [formData, user])
const handleConfirmDocs = useCallback(async () => {
if (isConfirmed || isProcessingRef.current) return
isProcessingRef.current = true
setIsConfirmed(true)
setIsLoading(true)
try {
const documentIds: string[] = []
for (const draft of draftDocs) {
if (draft.kind === "x_research" && xResearchStatus !== "correct") {
continue
}
try {
const docResponse = await $fetch("@post/documents", {
body: {
content: draft.content,
containerTags: ["sm_project_default"],
metadata: draft.metadata,
},
})
if (docResponse.data?.id) {
documentIds.push(docResponse.data.id)
}
} catch (error) {
console.warn("Error creating document:", error)
}
}
if (documentIds.length > 0) {
await pollForMemories(documentIds)
}
} catch (error) {
console.warn("Error confirming documents:", error)
setIsConfirmed(false)
} finally {
setIsLoading(false)
isProcessingRef.current = false
}
}, [draftDocs, xResearchStatus, isConfirmed, pollForMemories])
useEffect(() => {
if (!formData) return
const formDataMessages: typeof messages = []
const formDataMessages: typeof timelineMessages = []
if (formData.twitter) {
formDataMessages.push({
@ -172,125 +395,9 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
})
}
setMessages(formDataMessages)
const hasContent =
formData.twitter ||
formData.linkedin ||
formData.otherLinks.length > 0 ||
formData.description?.trim()
if (!hasContent) return
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
const processContent = async () => {
setIsLoading(true)
try {
const documentIds: string[] = []
if (formData.description?.trim()) {
try {
const descDocResponse = await $fetch("@post/documents", {
body: {
content: formData.description,
containerTags: ["sm_project_default"],
metadata: {
sm_source: "consumer",
description_source: "user_input",
},
},
})
if (descDocResponse.data?.id) {
documentIds.push(descDocResponse.data.id)
}
} catch (error) {
console.warn("Error creating description document:", error)
}
}
if (formData.twitter) {
try {
const researchResponse = await fetch("/api/onboarding/research", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
xUrl: formData.twitter,
name: user?.name,
email: user?.email,
}),
})
if (researchResponse.ok) {
const { text } = await researchResponse.json()
if (text?.trim()) {
const xDocResponse = await $fetch("@post/documents", {
body: {
content: text,
containerTags: ["sm_project_default"],
metadata: {
sm_source: "consumer",
onboarding_source: "x_research",
x_url: formData.twitter,
},
},
})
if (xDocResponse.data?.id) {
documentIds.push(xDocResponse.data.id)
}
}
}
} catch (error) {
console.warn("Error fetching X research:", error)
}
}
if (urls.length > 0) {
const response = await fetch("/api/onboarding/extract-content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
})
const { results } = await response.json()
for (const result of results) {
try {
const docResponse = await $fetch("@post/documents", {
body: {
content: result.text || result.description || "",
containerTags: ["sm_project_default"],
metadata: {
sm_source: "consumer",
exa_url: result.url,
exa_title: result.title,
},
},
})
if (docResponse.data?.id) {
documentIds.push(docResponse.data.id)
}
} catch (error) {
console.warn("Error creating document:", error)
}
}
}
if (documentIds.length > 0) {
await pollForMemories(documentIds)
}
} catch (error) {
console.warn("Error processing content:", error)
}
setIsLoading(false)
}
processContent()
}, [formData, pollForMemories, user])
setTimelineMessages(formDataMessages)
buildDraftDocs()
}, [formData, buildDraftDocs])
return (
<AnimatePresence mode="wait">
@ -337,8 +444,8 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
<PanelRightCloseIcon className="size-4" />
Close chat
</motion.button>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
{messages.map((msg, i) => (
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end overflow-y-auto scrollbar-thin">
{timelineMessages.map((msg, i) => (
<div
key={`message-${i}-${msg.message}`}
className="flex items-start gap-2"
@ -438,12 +545,78 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
)}
</div>
))}
{messages.length === 0 && !isLoading && !formData && (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={28} className="blur-none!" />
<span className="text-sm">Waiting for your input</span>
</div>
)}
{chatMessages.map((msg) => {
if (msg.role === "user") {
const text = msg.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
return (
<div
key={msg.id}
className="flex items-start gap-2 justify-end"
>
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
</div>
)
}
if (msg.role === "assistant") {
return (
<div key={msg.id} className="flex items-start gap-2">
<NovaOrb size={30} className="blur-none!" />
<div className="flex-1">
{msg.parts.map((part, partIndex) => {
if (part.type === "text") {
return (
<div
key={`${msg.id}-${partIndex}`}
className="text-sm text-white/90 chat-markdown-content"
>
<Streamdown>{part.text}</Streamdown>
</div>
)
}
if (part.type === "tool-searchMemories") {
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
return (
<div
key={`${msg.id}-${partIndex}`}
className="text-xs text-white/50 italic"
>
Searching memories...
</div>
)
}
}
return null
})}
</div>
</div>
)
}
return null
})}
{(status === "submitted" || status === "streaming") &&
chatMessages[chatMessages.length - 1]?.role === "user" && (
<div className="flex items-start gap-2">
<NovaOrb size={30} className="blur-none!" />
<span className="text-sm text-white/50">Thinking...</span>
</div>
)}
{timelineMessages.length === 0 &&
chatMessages.length === 0 &&
!isLoading &&
!formData && (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={28} className="blur-none!" />
<span className="text-sm">Waiting for your input</span>
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-foreground/50">
<NovaOrb size={28} className="blur-none!" />
@ -452,7 +625,106 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
)}
</div>
<div className="p-4">
{draftDocs.some((d) => d.kind === "x_research") && !isConfirmed && (
<div className="px-4 pb-2 space-y-3">
<div className="bg-[#293952]/40 rounded-lg p-3 space-y-2">
<h3
className="text-sm font-medium"
style={{
background:
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
Your Profile Summary
</h3>
<div className="overflow-y-auto scrollbar-thin max-h-32">
<p className="text-xs text-white/70">
{draftDocs.find((d) => d.kind === "x_research")?.content}
</p>
</div>
<div className="flex items-center gap-2 pt-2">
<span className="text-xs text-white/50">
Is this accurate?
</span>
<button
type="button"
onClick={() => {
setXResearchStatus("correct")
handleConfirmDocs()
}}
disabled={isConfirmed || isLoading}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
xResearchStatus === "correct"
? "bg-green-500/20 text-green-400 border border-green-500/40"
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
(isConfirmed || isLoading) && "opacity-50 cursor-not-allowed",
)}
>
<CheckIcon className="size-3" />
Correct
</button>
<button
type="button"
onClick={() => setXResearchStatus("incorrect")}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
xResearchStatus === "incorrect"
? "bg-red-500/20 text-red-400 border border-red-500/40"
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
)}
>
<XIcon className="size-3" />
Incorrect
</button>
</div>
{xResearchStatus === "incorrect" && (
<>
<p className="text-xs text-white/40 pt-1">
If incorrect, share your info in the input below, or you
can add memories later as well.
</p>
<Button
type="button"
onClick={handleConfirmDocs}
disabled={isConfirmed || isLoading}
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer mt-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</Button>
</>
)}
</div>
</div>
)}
{!draftDocs.some((d) => d.kind === "x_research") &&
draftDocs.length > 0 &&
!isConfirmed && (
<div className="px-4 pb-2">
<Button
type="button"
onClick={handleConfirmDocs}
disabled={isConfirmed || isLoading}
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</Button>
</div>
)}
<div className="p-4 space-y-2">
{isFetchingDrafts && (
<div className="flex items-center gap-2 text-white/50 px-2">
<NovaOrb size={20} className="blur-none!" />
<span className="text-sm">
Getting all relevant info about you...
</span>
</div>
)}
<form
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
onSubmit={(e) => {
@ -468,11 +740,16 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
onKeyDown={handleKeyDown}
placeholder="Chat with your Supermemory"
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
disabled={status === "submitted" || status === "streaming"}
/>
<div className="flex justify-end absolute bottom-3 right-2">
<Button
type="submit"
disabled={!message.trim()}
disabled={
!message.trim() ||
status === "submitted" ||
status === "streaming"
}
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
size="icon"
>

View file

@ -165,7 +165,7 @@ export function IntegrationsStep() {
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={() =>
router.push("/new/onboarding?flow=setup&step=relatable")
router.push("/new/onboarding/setup?step=relatable")
}
>
Back

View file

@ -35,7 +35,7 @@ export function RelatableQuestion() {
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
const handleContinueOrSkip = () => {
router.push("/new/onboarding?flow=setup&step=integrations")
router.push("/new/onboarding/setup?step=integrations")
}
return (

View file

@ -1,45 +1,195 @@
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { motion } from "motion/react"
import { motion, type Variants } from "motion/react"
import { useRouter } from "next/navigation"
import { ProfileStep } from "./profile-step"
import { continueVariants, contentVariants } from "@/lib/variants"
export function ContinueStep() {
type OnboardingView = "continue" | "features" | "memories"
interface OnboardingContentStepProps {
currentView?: OnboardingView
onSubmit?: (data: {
twitter: string
linkedin: string
description: string
otherLinks: string[]
}) => void
}
const containerVariants: Variants = {
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: "easeOut",
},
},
hidden: {
opacity: 0,
transition: {
duration: 0,
},
},
}
export function OnboardingContentStep({
currentView = "continue",
onSubmit,
}: OnboardingContentStepProps) {
const router = useRouter()
const handleContinue = () => {
router.push("/new/onboarding?flow=welcome&step=features")
router.push("/new/onboarding/welcome?step=features")
}
const handleAddMemories = () => {
router.push("/new/onboarding/welcome?step=memories")
}
const isContinue = currentView === "continue"
const isFeatures = currentView === "features"
const isMemories = currentView === "memories"
return (
<motion.div
className="text-center"
initial={{ opacity: 0, y: 0, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 0, scale: 0.9 }}
transition={{ duration: 1, ease: "easeOut" }}
layout
variants={containerVariants}
initial="hidden"
animate="visible"
exit="hidden"
className="text-center relative"
>
<p
{/* Continue content */}
<motion.div
variants={continueVariants}
animate={isContinue ? "visible" : "hidden"}
initial="visible"
className={cn(
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
dmSansClassName(),
"flex flex-col items-center justify-center max-w-88",
!isContinue && "absolute inset-0 pointer-events-none",
)}
>
I'm built with Supermemory's super fast memory API,
<br /> so you never have to worry about forgetting <br /> what matters
across your AI apps.
</p>
<Button
variant="onboarding"
onClick={handleContinue}
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
width: "147px",
}}
<p
className={cn(
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
dmSansClassName(),
)}
>
I'm built with Supermemory's super fast memory API,
<br /> so you never have to worry about forgetting <br /> what matters
across your AI apps.
</p>
<Button
variant="onboarding"
onClick={handleContinue}
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
width: "147px",
}}
>
Continue
</Button>
</motion.div>
{/* Features content */}
<motion.div
variants={contentVariants}
animate={isFeatures ? "visible" : "hiddenDown"}
initial="hiddenDown"
className={cn(
"space-y-6 max-w-88",
!isFeatures && "absolute inset-0 pointer-events-none",
)}
>
Continue
</Button>
<h2 className="text-white text-[32px] font-medium leading-[110%]">
What I can do for you
</h2>
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/human-brain.png"
alt="Brain icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Remember every context</p>
<p className="text-[#8A8A8A] text-[14px]">
I keep track of what you've saved and shared with your
supermemory.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/search.png"
alt="Search icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Find when you need it</p>
<p className="text-[#8A8A8A] text-[14px]">
I surface the right memories inside <br /> your supermemory,
superfast.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/plant.png"
alt="Growth icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">
Grow with your supermemory
</p>
<p className="text-[#8A8A8A] text-[14px]">
I learn and personalize over time, so every interaction feels
natural.
</p>
</div>
</div>
</div>
<Button
variant="onboarding"
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
onClick={handleAddMemories}
>
Add memories
</Button>
</motion.div>
{/* Memories/Profile content */}
<div
className={cn(
"w-full",
!isMemories && "absolute inset-0 pointer-events-none",
)}
>
{onSubmit && (
<motion.div
variants={contentVariants}
animate={isMemories ? "visible" : "hiddenDown"}
initial="hiddenDown"
>
<ProfileStep onSubmit={onSubmit} />
</motion.div>
)}
</div>
</motion.div>
)
}

View file

@ -1,98 +0,0 @@
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
export function FeaturesStep() {
const router = useRouter()
const handleContinue = () => {
router.push("/new/onboarding?flow=welcome&step=memories")
}
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
className="text-center max-w-88 space-y-6"
layout
>
<h2 className="text-white text-[32px] font-medium leading-[110%]">
What I can do for you
</h2>
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/human-brain.png"
alt="Brain icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Remember every context</p>
<p className="text-[#8A8A8A] text-[14px]">
I keep track of what you've saved and shared with your
supermemory.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/search.png"
alt="Search icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Find when you need it</p>
<p className="text-[#8A8A8A] text-[14px]">
I surface the right memories inside <br /> your supermemory,
superfast.
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
<img
src="/onboarding/plant.png"
alt="Growth icon"
className="w-14 h-14"
/>
</div>
<div className="text-left">
<p className="text-white font-light">Grow with your supermemory</p>
<p className="text-[#8A8A8A] text-[14px]">
I learn and personalize over time, so every interaction feels
natural.
</p>
</div>
</div>
</div>
<motion.div
animate={{
opacity: 1,
y: 0,
}}
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
initial={{ opacity: 0, y: 10 }}
>
<Button
variant="onboarding"
style={{
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
onClick={handleContinue}
>
Add memories
</Button>
</motion.div>
</motion.div>
)
}

View file

@ -292,7 +292,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
otherLinks: otherLinks.filter((l) => l.trim()),
}
onSubmit(formData)
router.push("/new/onboarding?flow=setup&step=relatable")
router.push("/new/onboarding/setup?step=relatable")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}

View file

@ -20,7 +20,7 @@ import { Streamdown } from "streamdown"
import { TextShimmer } from "@/components/text-shimmer"
import { usePersistentChat, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { modelNames, ModelIcon } from "@/lib/models"
import { ModelIcon } from "@/lib/models"
import { Spinner } from "../../spinner"
import { areUIMessageArraysEqual } from "@/stores/chat"
@ -270,7 +270,6 @@ export function ChatMessages() {
},
},
}),
maxSteps: 10,
onFinish: (result) => {
const activeId = activeChatIdRef.current
if (!activeId) return

View file

@ -3,12 +3,28 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
interface DocumentWithId {
id?: string
customId?: string | null
}
interface DocumentsQueryData {
documents: unknown[]
documents: DocumentWithId[]
totalCount: number
}
type InfiniteQueryData = {
pages: DocumentsResponse[]
pageParams: number[]
}
type QueryData = DocumentsQueryData | InfiniteQueryData
interface UseDocumentMutationsOptions {
onClose?: () => void
}
@ -51,7 +67,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
])
const optimisticMemory = {
id: `temp-${Date.now()}`,
id: `temp-${crypto.randomUUID()}`,
content: content,
url: null,
title: content.substring(0, 100),
@ -134,7 +150,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
])
const optimisticMemory = {
id: `temp-${Date.now()}`,
id: `temp-${crypto.randomUUID()}`,
content: "",
url: url,
title: "Processing...",
@ -251,7 +267,7 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
])
const optimisticMemory = {
id: `temp-file-${Date.now()}`,
id: `temp-file-${crypto.randomUUID()}`,
content: "",
url: null,
title: title || file.name,
@ -341,10 +357,100 @@ export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions =
},
})
const deleteMutation = useMutation({
mutationFn: async ({ documentId }: { documentId: string }) => {
const response = await $fetch("@delete/documents/:id", {
params: { id: documentId },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete document")
}
return response.data
},
onMutate: async ({ documentId }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories"],
})
const previousQueries = queryClient.getQueriesData({
queryKey: ["documents-with-memories"],
})
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old: QueryData | undefined) => {
if (!old) return old
if ("pages" in old) {
const infiniteData = old as InfiniteQueryData
return {
...infiniteData,
pages: infiniteData.pages.map((page) => {
if (!page?.documents) return page
return {
...page,
documents: page.documents.filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
),
pagination: page.pagination
? {
...page.pagination,
totalItems: Math.max(
0,
(page.pagination.totalItems ?? 0) - 1,
),
}
: page.pagination,
}
}),
}
}
if ("documents" in old) {
const queryData = old as DocumentsQueryData
return {
...queryData,
documents: queryData.documents.filter(
(doc: DocumentWithId) => {
return doc.id !== documentId && doc.customId !== documentId
},
),
totalCount: Math.max(0, (queryData.totalCount ?? 0) - 1),
}
}
return old
},
)
return { previousQueries }
},
onError: (_error, _variables, context) => {
if (context?.previousQueries) {
context.previousQueries.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data)
})
}
toast.error("Failed to delete document", {
description: _error instanceof Error ? _error.message : "Unknown error",
})
},
onSuccess: () => {
toast.success("Document deleted successfully!")
queryClient.invalidateQueries({
queryKey: ["documents-with-memories"],
})
onClose?.()
},
})
return {
noteMutation,
linkMutation,
fileMutation,
updateMutation,
deleteMutation,
}
}

95
apps/web/lib/variants.ts Normal file
View file

@ -0,0 +1,95 @@
import type { Variants } from "motion/react"
export const contentVariants: Variants = {
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: "easeInOut",
delay: 0.2,
},
},
hiddenUp: {
opacity: 0,
y: -40,
transition: {
duration: 0,
},
},
hiddenDown: {
opacity: 0,
y: 40,
transition: {
duration: 0,
},
},
}
export const continueVariants: Variants = {
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: "easeInOut",
},
},
hidden: {
opacity: 0,
y: -40,
transition: {
duration: 0,
},
},
}
export const gapVariants: Variants = {
default: {
gap: 16,
transition: {
duration: 0.6,
ease: "easeOut",
},
},
minimized: {
gap: 0,
transition: {
duration: 0.6,
ease: "easeOut",
},
},
}
export const orbVariants: Variants = {
default: {
scale: 1,
padding: 48,
paddingTop: 0,
y: 0,
transition: {
duration: 0.8,
ease: "easeOut",
},
},
features: {
scale: 0.7,
padding: 0,
paddingTop: 0,
y: 0,
transition: {
duration: 0.8,
ease: "easeOut",
},
},
memories: {
scale: 0.4,
padding: 0,
paddingTop: 0,
y: 0,
transition: {
duration: 0.8,
ease: "easeOut",
},
},
}