diff options
| author | MaheshtheDev <[email protected]> | 2026-01-16 21:44:26 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2026-01-16 21:44:26 +0000 |
| commit | 9fd49193ac6dcd8c6cdf75da642bfb4a11c76e9a (patch) | |
| tree | 993317bf9c73035a93856f7284ce611c019d93ac | |
| parent | feat: deep-research on user profile and tiptap integration (#672) (diff) | |
| download | supermemory-9fd49193ac6dcd8c6cdf75da642bfb4a11c76e9a.tar.xz supermemory-9fd49193ac6dcd8c6cdf75da642bfb4a11c76e9a.zip | |
chore: delete document, document ui (#673)01-16-chore_delete_document_document_ui
19 files changed, 1505 insertions, 539 deletions
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8564713b..7f63c2df 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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:*)"' diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 030b5b90..37e01cd7 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 diff --git a/apps/web/app/new/onboarding/layout.tsx b/apps/web/app/new/onboarding/layout.tsx new file mode 100644 index 00000000..0b077f1f --- /dev/null +++ b/apps/web/app/new/onboarding/layout.tsx @@ -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> + ) +} diff --git a/apps/web/app/new/onboarding/page.tsx b/apps/web/app/new/onboarding/page.tsx index 1b4962e4..19e9c62a 100644 --- a/apps/web/app/new/onboarding/page.tsx +++ b/apps/web/app/new/onboarding/page.tsx @@ -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" - - useEffect(() => { - if (user?.name) { - setName(user.name) - localStorage.setItem("username", user.name) - } - }, [user?.name]) + const router = useRouter() 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> ) } diff --git a/apps/web/app/new/onboarding/setup/layout.tsx b/apps/web/app/new/onboarding/setup/layout.tsx new file mode 100644 index 00000000..3a958469 --- /dev/null +++ b/apps/web/app/new/onboarding/setup/layout.tsx @@ -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> + ) +} diff --git a/apps/web/app/new/onboarding/setup/page.tsx b/apps/web/app/new/onboarding/setup/page.tsx new file mode 100644 index 00000000..d601970d --- /dev/null +++ b/apps/web/app/new/onboarding/setup/page.tsx @@ -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> + ) +} diff --git a/apps/web/app/new/onboarding/welcome/layout.tsx b/apps/web/app/new/onboarding/welcome/layout.tsx new file mode 100644 index 00000000..427907c2 --- /dev/null +++ b/apps/web/app/new/onboarding/welcome/layout.tsx @@ -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> + ) +} diff --git a/apps/web/app/new/onboarding/welcome/page.tsx b/apps/web/app/new/onboarding/welcome/page.tsx new file mode 100644 index 00000000..40f0134b --- /dev/null +++ b/apps/web/app/new/onboarding/welcome/page.tsx @@ -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> + ) +} diff --git a/apps/web/components/new/document-modal/index.tsx b/apps/web/components/new/document-modal/index.tsx index bd8ec05d..8d4aac87 100644 --- a/apps/web/components/new/document-modal/index.tsx +++ b/apps/web/components/new/document-modal/index.tsx @@ -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> diff --git a/apps/web/components/new/document-modal/title.tsx b/apps/web/components/new/document-modal/title.tsx index 066c437b..59eb285e 100644 --- a/apps/web/components/new/document-modal/title.tsx +++ b/apps/web/components/new/document-modal/title.tsx @@ -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> ) } diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx index 59742271..dd6eeb23 100644 --- a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx +++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx @@ -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" > diff --git a/apps/web/components/new/onboarding/setup/integrations-step.tsx b/apps/web/components/new/onboarding/setup/integrations-step.tsx index 49e3062a..8f83bdfe 100644 --- a/apps/web/components/new/onboarding/setup/integrations-step.tsx +++ b/apps/web/components/new/onboarding/setup/integrations-step.tsx @@ -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 diff --git a/apps/web/components/new/onboarding/setup/relatable-question.tsx b/apps/web/components/new/onboarding/setup/relatable-question.tsx index 5fbe9bb4..5edb4a99 100644 --- a/apps/web/components/new/onboarding/setup/relatable-question.tsx +++ b/apps/web/components/new/onboarding/setup/relatable-question.tsx @@ -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 ( diff --git a/apps/web/components/new/onboarding/welcome/continue-step.tsx b/apps/web/components/new/onboarding/welcome/continue-step.tsx index 86b4593a..7d56d30e 100644 --- a/apps/web/components/new/onboarding/welcome/continue-step.tsx +++ b/apps/web/components/new/onboarding/welcome/continue-step.tsx @@ -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( + "flex flex-col items-center justify-center max-w-88", + !isContinue && "absolute inset-0 pointer-events-none", + )} + > + <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( - "text-[#8A8A8A] text-sm mb-6 max-w-sm", - dmSansClassName(), + "space-y-6 max-w-88", + !isFeatures && "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", - }} + <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", + )} > - Continue → - </Button> + {onSubmit && ( + <motion.div + variants={contentVariants} + animate={isMemories ? "visible" : "hiddenDown"} + initial="hiddenDown" + > + <ProfileStep onSubmit={onSubmit} /> + </motion.div> + )} + </div> </motion.div> ) } diff --git a/apps/web/components/new/onboarding/welcome/features-step.tsx b/apps/web/components/new/onboarding/welcome/features-step.tsx deleted file mode 100644 index 2671efc1..00000000 --- a/apps/web/components/new/onboarding/welcome/features-step.tsx +++ /dev/null @@ -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> - ) -} diff --git a/apps/web/components/new/onboarding/welcome/profile-step.tsx b/apps/web/components/new/onboarding/welcome/profile-step.tsx index 65eb21c6..34393dad 100644 --- a/apps/web/components/new/onboarding/welcome/profile-step.tsx +++ b/apps/web/components/new/onboarding/welcome/profile-step.tsx @@ -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 →"} diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx index 55940a60..3e55dc23 100644 --- a/apps/web/components/views/chat/chat-messages.tsx +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -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 diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts index f3931b5f..bafa0e58 100644 --- a/apps/web/hooks/use-document-mutations.ts +++ b/apps/web/hooks/use-document-mutations.ts @@ -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, } } diff --git a/apps/web/lib/variants.ts b/apps/web/lib/variants.ts new file mode 100644 index 00000000..1bfebb7c --- /dev/null +++ b/apps/web/lib/variants.ts @@ -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", + }, + }, +} |