diff --git a/apps/web/app/(app)/onboarding/layout.tsx b/apps/web/app/(app)/old/onboarding/layout.tsx similarity index 100% rename from apps/web/app/(app)/onboarding/layout.tsx rename to apps/web/app/(app)/old/onboarding/layout.tsx diff --git a/apps/web/app/(app)/old/onboarding/page.tsx b/apps/web/app/(app)/old/onboarding/page.tsx new file mode 100644 index 000000000..883a031b7 --- /dev/null +++ b/apps/web/app/(app)/old/onboarding/page.tsx @@ -0,0 +1,18 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" + +export default function OnboardingPage() { + const router = useRouter() + + useEffect(() => { + router.replace("/old/onboarding/welcome?step=input") + }, [router]) + + return ( +
+
Loading...
+
+ ) +} diff --git a/apps/web/app/(app)/onboarding/setup/layout.tsx b/apps/web/app/(app)/old/onboarding/setup/layout.tsx similarity index 96% rename from apps/web/app/(app)/onboarding/setup/layout.tsx rename to apps/web/app/(app)/old/onboarding/setup/layout.tsx index 2ef7d4866..d68f43252 100644 --- a/apps/web/app/(app)/onboarding/setup/layout.tsx +++ b/apps/web/app/(app)/old/onboarding/setup/layout.tsx @@ -12,7 +12,7 @@ import { useRouter, useSearchParams } from "next/navigation" import { useOnboardingContext, type MemoryFormData } from "../layout" import { analytics } from "@/lib/analytics" -export const SETUP_STEPS = ["relatable", "integrations"] as const +export const SETUP_STEPS = ["integrations"] as const export type SetupStep = (typeof SETUP_STEPS)[number] interface SetupContextValue { @@ -41,7 +41,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) { const stepParam = searchParams.get("step") const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep) ? (stepParam as SetupStep) - : "relatable" + : "integrations" const hasTrackedInitialStep = useRef(false) const goToStep = useCallback( diff --git a/apps/web/app/(app)/old/onboarding/setup/page.tsx b/apps/web/app/(app)/old/onboarding/setup/page.tsx new file mode 100644 index 000000000..725a249e3 --- /dev/null +++ b/apps/web/app/(app)/old/onboarding/setup/page.tsx @@ -0,0 +1,45 @@ +"use client" + +import { AnimatePresence } from "motion/react" + +import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step" + +import { SetupHeader } from "@/components/onboarding/setup/header" +import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar" +import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { useIsMobile } from "@hooks/use-mobile" + +import { useSetupContext } from "./layout" + +export default function SetupPage() { + const { memoryFormData } = useSetupContext() + const isMobile = useIsMobile() + + return ( +
+ + + + +
+
+
+
+ + + +
+ + {!isMobile && ( + + + + )} +
+
+
+ + {isMobile && } +
+ ) +} diff --git a/apps/web/app/(app)/onboarding/welcome/layout.tsx b/apps/web/app/(app)/old/onboarding/welcome/layout.tsx similarity index 93% rename from apps/web/app/(app)/onboarding/welcome/layout.tsx rename to apps/web/app/(app)/old/onboarding/welcome/layout.tsx index 8a6853688..21d349b15 100644 --- a/apps/web/app/(app)/onboarding/welcome/layout.tsx +++ b/apps/web/app/(app)/old/onboarding/welcome/layout.tsx @@ -83,7 +83,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { if (isMountedRef.current) { setShowWelcomeContent(true) } - }, 1000) + }, 400) return () => clearTimeout(timer) } setShowWelcomeContent(true) @@ -97,7 +97,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { setTimeout(() => { if (isMountedRef.current) { analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" }) - router.replace("/onboarding/welcome?step=welcome") + router.replace("/old/onboarding/welcome?step=welcome") } }, 2000), ) @@ -109,7 +109,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { step: "username", trigger: "auto", }) - router.replace("/onboarding/welcome?step=username") + router.replace("/old/onboarding/welcome?step=username") } }, 2000), ) @@ -133,14 +133,14 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { const goToStep = useCallback( (step: WelcomeStep) => { analytics.onboardingStepViewed({ step, trigger: "user" }) - router.push(`/onboarding/welcome?step=${step}`) + router.push(`/old/onboarding/welcome?step=${step}`) }, [router], ) const goToSetup = useCallback( - (step = "relatable") => { - router.push(`/onboarding/setup?step=${step}`) + (step = "integrations") => { + router.push(`/old/onboarding/setup?step=${step}`) }, [router], ) diff --git a/apps/web/app/(app)/onboarding/welcome/page.tsx b/apps/web/app/(app)/old/onboarding/welcome/page.tsx similarity index 96% rename from apps/web/app/(app)/onboarding/welcome/page.tsx rename to apps/web/app/(app)/old/onboarding/welcome/page.tsx index ac3ea599a..fe12bf16a 100644 --- a/apps/web/app/(app)/onboarding/welcome/page.tsx +++ b/apps/web/app/(app)/old/onboarding/welcome/page.tsx @@ -12,7 +12,6 @@ import { OnboardingContentStep } from "@/components/onboarding/welcome/continue- import { InitialHeader } from "@/components/initial-header" import { Logo } from "@ui/assets/Logo" import NovaOrb from "@/components/nova/nova-orb" -import { AnimatedGradientBackground } from "@/components/animated-gradient-background" import { useWelcomeContext, @@ -212,7 +211,7 @@ export default function WelcomePage() { const showUserSupermemory = currentStep === "username" return ( -
+
- {currentStep === "input" && ( - - )} - {showWelcomeContent && (
diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 460fe92fb..597b3f3f9 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,18 +1,1115 @@ "use client" -import { useEffect } from "react" +import { + useState, + useRef, + useCallback, + useEffect, + useMemo, + type ReactNode, +} from "react" import { useRouter } from "next/navigation" +import { useAuth } from "@lib/auth-context" +import { Logo } from "@ui/assets/Logo" +import { motion, AnimatePresence } from "motion/react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { $fetch } from "@lib/api" +import { authClient } from "@lib/auth" +import NovaOrb from "@/components/nova/nova-orb" +import Image from "next/image" +import { IntegrationGridCard } from "@/components/integrations/integration-grid-card" +import { + CHROME_EXTENSION_URL, + RAYCAST_EXTENSION_URL, + ADD_MEMORY_SHORTCUT_URL, +} from "@repo/lib/constants" +import { + ChromeIcon, + AppleShortcutsIcon, + RaycastIcon, +} from "@/components/integration-icons" +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react" +import { analytics } from "@/lib/analytics" + +type DetectedSource = "x" | "linkedin" | "resume" | null +type Status = "idle" | "processing" | "done" | "error" +type DocStatus = + | "unknown" + | "queued" + | "extracting" + | "chunking" + | "embedding" + | "indexing" + | "done" + | "failed" + +function XIcon({ className }: { className?: string }) { + return ( + + ) +} + +function LinkedInIcon({ className }: { className?: string }) { + return ( + + ) +} + +function SubmitArrow() { + return ( + + Submit + + + ) +} + +function detectSource(value: string): DetectedSource { + const v = value.trim().toLowerCase() + if (!v) return null + if (v.includes("linkedin.com/in/") || v.includes("linkedin.com/pub/")) + return "linkedin" + if (v.includes("x.com/") || v.includes("twitter.com/") || v.startsWith("@")) + return "x" + if (/^[a-z0-9_]{1,50}$/i.test(v)) return "x" + return null +} + +function generateUsername(name: string) { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/(^_|_$)/g, "") || "user" + return `${base}${Math.floor(100000 + Math.random() * 900000)}` +} + +function generateOrgSlug(name: string) { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "org" + return `${base}-${Math.floor(100000 + Math.random() * 900000)}` +} + +const SOURCE_ICON: Record< + "x" | "linkedin", + React.FC<{ className?: string }> +> = { + x: XIcon, + linkedin: LinkedInIcon, +} + +const SOURCE_LABEL: Record<"x" | "linkedin", string> = { + x: "X profile detected — press Enter to continue", + linkedin: "LinkedIn profile detected — press Enter to continue", +} + +type SpotlightItem = { + id: string + title: string + description: string + icon: ReactNode + pro?: boolean + onOpen: () => void +} + +type SpotlightCategoryId = "coding" | "productivity" | "agents" + +const SPOTLIGHT_CATEGORY_TABS: { id: SpotlightCategoryId; label: string }[] = [ + { id: "coding", label: "Coding" }, + { id: "productivity", label: "Productivity" }, + { id: "agents", label: "Agents" }, +] + +const SPOTLIGHT_CATEGORY_ORDER: SpotlightCategoryId[] = + SPOTLIGHT_CATEGORY_TABS.map((t) => t.id) + +function spotlightPluginCornerIcon(src: string, alt: string) { + return ( + {alt} + ) +} + +const spotlightConnectionsIcon = ( +
+ + + +
+) + +function buildSpotlightCatalog( + router: ReturnType, +): Record { + const track = (integration: string) => + analytics.onboardingIntegrationClicked({ integration }) + + const openPluginsPanel = () => { + void router.push("/?view=integrations&plugins=true") + } + + return { + coding: [ + { + id: "mcp", + title: "Connect to AI", + description: + "Set up MCP to use your memory in Cursor, Claude, and more", + icon: ( + MCP + ), + onOpen: () => { + track("mcp") + void router.push("/?view=integrations") + }, + }, + { + id: "coding-claude-supermemory", + title: "Claude Supermemory", + description: + "Persistent memory for Claude Code — context and decisions across sessions.", + icon: spotlightPluginCornerIcon( + "/images/plugins/claude-code.svg", + "Claude Supermemory", + ), + pro: true, + onOpen: () => { + track("plugin_claude_supermemory") + openPluginsPanel() + }, + }, + { + id: "coding-opencode", + title: "OpenCode", + description: + "Memory layer for OpenCode — search past sessions and inject context.", + icon: spotlightPluginCornerIcon( + "/images/plugins/opencode.svg", + "OpenCode", + ), + pro: true, + onOpen: () => { + track("plugin_opencode") + openPluginsPanel() + }, + }, + { + id: "connections", + title: "Connections", + description: + "Link Notion, Google Drive, or OneDrive to import your docs", + icon: spotlightConnectionsIcon, + pro: true, + onOpen: () => { + track("connections") + void router.push("/?view=integrations&integration=connections") + }, + }, + ], + productivity: [ + { + id: "chrome", + title: "Chrome Extension", + description: + "Save any webpage, import bookmarks, sync ChatGPT memories", + icon: , + onOpen: () => { + window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer") + analytics.onboardingChromeExtensionClicked({ source: "onboarding" }) + }, + }, + { + id: "raycast", + title: "Raycast", + description: "Add and search memories from Raycast on Mac", + icon: , + onOpen: () => { + track("raycast") + window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer") + }, + }, + { + id: "shortcuts", + title: "Apple Shortcuts", + description: "Add memories directly from iPhone, iPad or Mac", + icon: , + onOpen: () => { + track("shortcuts") + window.open(ADD_MEMORY_SHORTCUT_URL, "_blank", "noopener,noreferrer") + }, + }, + { + id: "import", + title: "Import Bookmarks", + description: "Bring in X/Twitter bookmarks and turn them into memories", + icon: X, + onOpen: () => { + track("import_x") + void router.push("/?view=integrations&integration=import") + }, + }, + ], + agents: [ + { + id: "agents-openclaw", + title: "OpenClaw", + description: + "Multi-platform memory for OpenClaw — Telegram, WhatsApp, Discord, Slack, and more.", + icon: spotlightPluginCornerIcon( + "/images/plugins/openclaw.svg", + "OpenClaw", + ), + pro: true, + onOpen: () => { + track("plugin_openclaw") + openPluginsPanel() + }, + }, + { + id: "agents-hermes", + title: "Hermes", + description: + "Memory layer for the Hermes agent — recall, capture, and user profile.", + icon: spotlightPluginCornerIcon("/images/plugins/hermes.svg", "Hermes"), + onOpen: () => { + track("plugin_hermes") + openPluginsPanel() + }, + }, + { + id: "agents-claude-supermemory", + title: "Claude Supermemory", + description: + "Persistent memory for Claude Code — context and decisions across sessions.", + icon: spotlightPluginCornerIcon( + "/images/plugins/claude-code.svg", + "Claude Supermemory", + ), + pro: true, + onOpen: () => { + track("plugin_claude_supermemory") + openPluginsPanel() + }, + }, + { + id: "agents-opencode", + title: "OpenCode", + description: + "Memory layer for OpenCode — search past sessions and inject context.", + icon: spotlightPluginCornerIcon( + "/images/plugins/opencode.svg", + "OpenCode", + ), + pro: true, + onOpen: () => { + track("plugin_opencode") + openPluginsPanel() + }, + }, + { + id: "console-api", + title: "Console & API", + description: + "API keys, orgs, and the hosted API for production agent workloads", + icon: , + onOpen: () => { + track("console_api") + window.open( + "https://console.supermemory.ai", + "_blank", + "noopener,noreferrer", + ) + }, + }, + ], + } +} export default function OnboardingPage() { const router = useRouter() + const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() + + const [value, setValue] = useState("") + const [detected, setDetected] = useState(null) + const [resumeFile, setResumeFile] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const [status, setStatus] = useState("idle") + const [_docStatus, setDocStatus] = useState("queued") + const [memoriesCount, setMemoriesCount] = useState(0) + const [memorySnippets, setMemorySnippets] = useState([]) + const [docTitle, setDocTitle] = useState("") + const [errorMsg, setErrorMsg] = useState("") + const [stampLanded, setStampLanded] = useState(false) + const [visibleSnippets, setVisibleSnippets] = useState(0) + const inputRef = useRef(null) + const fileRef = useRef(null) + const pollingRef = useRef | null>(null) + const [spotlightCategory, setSpotlightCategory] = + useState("productivity") + const [pauseSpotlight, setPauseSpotlight] = useState(false) + + const spotlightCatalog = useMemo( + () => buildSpotlightCatalog(router), + [router], + ) + const categoryCards = spotlightCatalog[spotlightCategory] ?? [] + + const bumpSpotlightCategory = useCallback( + (delta: number) => { + const n = SPOTLIGHT_CATEGORY_ORDER.length + if (n === 0) return + const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(spotlightCategory) + const from = i >= 0 ? i : 0 + const next = (from + delta + n) % n + const id = SPOTLIGHT_CATEGORY_ORDER[next] + if (id) setSpotlightCategory(id) + }, + [spotlightCategory], + ) + + useEffect(() => { + if (status !== "processing") return + if (pauseSpotlight) return + const n = SPOTLIGHT_CATEGORY_ORDER.length + if (n <= 1) return + const t = setInterval(() => { + setSpotlightCategory((cur) => { + const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) + const from = i >= 0 ? i : 0 + const next = (from + 1) % n + return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur + }) + }, 8000) + return () => clearInterval(t) + }, [status, pauseSpotlight]) useEffect(() => { - router.replace("/onboarding/welcome?step=input") - }, [router]) + const t = setTimeout(() => inputRef.current?.focus(), 500) + return () => clearTimeout(t) + }, []) + + useEffect(() => { + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } + }, []) + + useEffect(() => { + if (status !== "done") return + setStampLanded(false) + setVisibleSnippets(0) + const t1 = setTimeout(() => setStampLanded(true), 400) + const t2 = setTimeout(() => setVisibleSnippets(1), 900) + const t3 = setTimeout(() => setVisibleSnippets(2), 1200) + const t4 = setTimeout(() => setVisibleSnippets(3), 1500) + return () => { + clearTimeout(t1) + clearTimeout(t2) + clearTimeout(t3) + clearTimeout(t4) + } + }, [status]) + + const handleChange = (v: string) => { + setValue(v) + setDetected(detectSource(v)) + } + + const ensureOrg = useCallback(async () => { + if (organizations && organizations.length > 0) return + const name = user?.name || user?.email || "Personal" + const slug = generateOrgSlug(name) + const result = await authClient.organization.create({ + name, + slug, + metadata: { signupSource: "consumer" }, + }) + await setActiveOrg(result.data?.slug ?? slug) + if (user?.name) { + await authClient.updateUser({ + displayUsername: user.name, + username: generateUsername(user.name), + }) + } + await refetchOrganizations() + }, [user, organizations, refetchOrganizations, setActiveOrg]) + + const pollDocument = useCallback((docId: string) => { + const maxAttempts = 60 + let attempt = 0 + + pollingRef.current = setInterval(async () => { + attempt++ + if (attempt > maxAttempts) { + if (pollingRef.current) clearInterval(pollingRef.current) + setErrorMsg("Processing is taking too long. Try again later.") + setStatus("error") + return + } + + try { + const res = await $fetch("@get/documents/:id", { + params: { id: docId }, + disableValidation: true, + }) + + if (!res.data) return + + const doc = res.data as { + status?: DocStatus + memories?: { memory: string; title?: string }[] + title?: string + } + + const s = doc.status ?? "queued" + setDocStatus(s) + + if (doc.memories) { + setMemoriesCount(doc.memories.length) + setMemorySnippets( + doc.memories + .slice(0, 3) + .map((m: { memory: string; title?: string }) => m.memory) + .filter(Boolean), + ) + } + if (doc.title) setDocTitle(doc.title) + + if (s === "done") { + if (pollingRef.current) clearInterval(pollingRef.current) + await new Promise((r) => setTimeout(r, 600)) + setStatus("done") + } else if (s === "failed") { + if (pollingRef.current) clearInterval(pollingRef.current) + setErrorMsg("Processing failed. You can skip and try later.") + setStatus("error") + } + } catch { + // keep polling on transient errors + } + }, 1500) + }, []) + + const handleSubmit = useCallback( + async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => { + setStatus("processing") + setSpotlightCategory("productivity") + setPauseSpotlight(false) + setDocStatus("queued") + setMemoriesCount(0) + setDocTitle("") + + try { + await ensureOrg() + + let docId: string | undefined + + if (source === "x" || source === "linkedin") { + const raw = value.trim() + const content = raw.startsWith("http") + ? raw + : source === "x" + ? `https://x.com/${raw.replace(/^@/, "")}` + : `https://${raw}` + const res = await $fetch("@post/documents", { + body: { + content, + metadata: { sm_source: "onboarding" }, + }, + }) + docId = (res.data as { id?: string } | undefined)?.id + } else if (source === "resume") { + const file = resumeFileOverride ?? resumeFile + if (!file) throw new Error("No resume file selected") + const formData = new FormData() + formData.append("file", file) + const uploadRes = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`, + { method: "POST", body: formData, credentials: "include" }, + ) + if (!uploadRes.ok) throw new Error("Resume upload failed") + const uploadData = await uploadRes.json() + docId = uploadData?.id + } + + if (docId) { + pollDocument(docId) + } else { + await new Promise((r) => setTimeout(r, 2000)) + setStatus("done") + } + } catch (err) { + console.error(err) + setErrorMsg("Something went wrong. You can skip and try later.") + setStatus("error") + } + }, + [value, resumeFile, ensureOrg, pollDocument], + ) + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const f = e.dataTransfer.files[0] + if (f?.type === "application/pdf") { + setResumeFile(f) + handleSubmit("resume", f) + } + } + + const canSubmit = detected && detected !== "resume" return ( -
-
Loading...
+ // biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF +
{ + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + + {isDragging && ( + +

+ Drop your PDF resume +

+
+ )} +
+ +
+ + +
+ +
+ + {/* ── IDLE ── */} + {status === "idle" && ( + + + +

+ Let NOVA know about you +

+ +
+
+ + {detected && detected !== "resume" && ( + + {(() => { + const Icon = SOURCE_ICON[detected as "x" | "linkedin"] + return + })()} + + )} + + + handleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmit) + handleSubmit(detected as "x" | "linkedin") + }} + placeholder="Paste an X handle, LinkedIn URL, or drop a PDF" + className={cn( + "w-full py-3 bg-[#070E1B] border rounded-xl text-white text-sm placeholder:text-[#525966] focus:outline-none transition-all", + detected && detected !== "resume" + ? "pl-8 pr-11" + : "px-4 pr-11", + detected + ? "border-[#2261CA]/50 focus:border-[#2261CA]" + : "border-[#52596633] focus:border-white/20", + )} + /> + + {canSubmit && ( + handleSubmit(detected as "x" | "linkedin")} + className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" + style={{ + background: + "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", + }} + > + + + )} +
+ + + {detected && detected !== "resume" && ( + + {SOURCE_LABEL[detected as "x" | "linkedin"]} + + )} + + + {!detected && ( + + {[ + { + label: "@yourhandle", + action: () => { + handleChange("@") + inputRef.current?.focus() + }, + }, + { + label: "linkedin.com/in/you", + action: () => { + handleChange("linkedin.com/in/") + inputRef.current?.focus() + }, + }, + { + label: "Drop a PDF resume", + action: () => fileRef.current?.click(), + }, + ].map((chip) => ( + + ))} + + )} +
+ + { + const f = e.target.files?.[0] + if (f) { + setResumeFile(f) + handleSubmit("resume", f) + } + }} + /> +
+ )} + + {/* ── PROCESSING ── */} + {status === "processing" && ( + + + +
+

+ Finishing your first save +

+

+ Most finish in under a minute. Below is optional — ways to add + more later. +

+
+ +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: pause category rotation on hover/focus within */} +
setPauseSpotlight(true)} + onMouseLeave={() => setPauseSpotlight(false)} + onFocus={() => setPauseSpotlight(true)} + onBlur={(e) => { + if ( + !e.currentTarget.contains(e.relatedTarget as Node | null) + ) { + setPauseSpotlight(false) + } + }} + > +
+ +
+ {SPOTLIGHT_CATEGORY_TABS.map((tab) => ( + + ))} +
+ +
+ +
+ {SPOTLIGHT_CATEGORY_TABS.map((tab) => ( +
+ + + + {categoryCards.map((card) => ( + + ))} + + +
+ + +
+
+ )} + + {/* ── DONE ── */} + {status === "done" && ( + +
+

+ It's in your memory +

+

+ Your first save is ready. When you want more, use Integrations + for browser, phone, editor, and AI tools — all in one place. +

+
+ + {/* Document card with stamp */} +
+ {/* Clickable document card */} + router.push("/?view=list")} + className="group w-full text-left bg-[#080E18] border border-[rgba(255,255,255,0.07)] rounded-2xl p-4 cursor-pointer hover:border-[rgba(255,255,255,0.14)] transition-colors" + > + {/* Faux document lines */} +
+
+
+
+
+
+
+
+
+

+ {docTitle || "Your document"} +

+ + {memoriesCount} memories + +
+

+ View in memories → +

+ + + {/* Stamp */} + +
+ {/* Ink ring ripple */} + {stampLanded && ( + + )} +
+ + + Memorized + +
+
+
+
+ + + + {/* Memory snippets */} +
+

+ Nova learned +

+ {memorySnippets.slice(0, 3).map((snippet, i) => ( + i + ? { opacity: 1, x: 0 } + : { opacity: 0, x: -8 } + } + transition={{ duration: 0.35, ease: "easeOut" }} + className="flex items-start gap-2 text-left" + > + +

+ {snippet} +

+
+ ))} +
+ + {/* CTAs */} +
+ + +
+ + )} + + {/* ── ERROR ── */} + {status === "error" && ( + +

{errorMsg}

+
+ + +
+
+ )} + +
) } diff --git a/apps/web/app/(app)/onboarding/setup/page.tsx b/apps/web/app/(app)/onboarding/setup/page.tsx deleted file mode 100644 index 350352331..000000000 --- a/apps/web/app/(app)/onboarding/setup/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client" - -import { motion, AnimatePresence } from "motion/react" - -import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question" -import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step" - -import { SetupHeader } from "@/components/onboarding/setup/header" -import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar" -import { AnimatedGradientBackground } from "@/components/animated-gradient-background" -import { useIsMobile } from "@hooks/use-mobile" - -import { useSetupContext, type SetupStep } from "./layout" - -function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) { - return ( - -

Unknown step

- -
- ) -} - -export default function SetupPage() { - const { memoryFormData, currentStep, goToStep } = useSetupContext() - const isMobile = useIsMobile() - - const renderStep = () => { - switch (currentStep) { - case "relatable": - return - case "integrations": - return - default: - return - } - } - - return ( -
- - - - -
-
-
-
- {renderStep()} -
- - {!isMobile && ( - - - - )} -
-
-
- - {isMobile && } -
- ) -} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 9ad372ed6..9f11aa80d 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -1,9 +1,18 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react" +import { AnimatePresence, motion } from "motion/react" import { useQueryState } from "nuqs" import { Header } from "@/components/header" -import { ChatSidebar } from "@/components/chat" +import { ChatSidebar, HomeChatComposer } from "@/components/chat" +import { DashboardView } from "@/components/dashboard-view" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" import { IntegrationsView } from "@/components/integrations-view" @@ -15,7 +24,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal" import type { HighlightItem } from "@/components/highlights-card" import { HotkeysProvider } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook" -import { AnimatePresence } from "motion/react" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" import { useProject } from "@/stores" @@ -26,11 +34,14 @@ import { useQuickNoteDraft, } from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" +import type { ModelId } from "@/lib/models" import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { useViewMode } from "@/lib/view-mode-context" +import type { MemoryOfDay } from "@/components/dashboard-view" import { ErrorBoundary } from "@/components/error-boundary" import { cn } from "@lib/utils" import { @@ -39,15 +50,36 @@ import { qParam, docParam, fullscreenParam, - chatParam, integrationParam, pluginsPanelParam, type IntegrationParamValue, } from "@/lib/search-params" +import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] +function subscribeViewportWidth(cb: () => void) { + window.addEventListener("resize", cb) + return () => window.removeEventListener("resize", cb) +} + +function getViewportWidth() { + return window.innerWidth +} + +const GRADIENT_TOP_WIDTH_MAX = 1440 + +function gradientTopPositionForWidth(width: number) { + const minW = 320 + const pctWide = 15 + const pctNarrow = 70 + const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) + const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) + const eased = t * t + return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` +} + function ViewErrorFallback() { return (
@@ -68,34 +100,28 @@ function ViewErrorFallback() { export default function NewPage() { const isMobile = useIsMobile() const { user, session } = useAuth() - const { - selectedProject, - isNovaSpaces, - novaContainerTags, - selectedProjects, - setSelectedProjects, - } = useProject() + + const { selectedProject, selectedProjects } = useProject() const selectedProjectTag = selectedProjects[0] - const isNovaContext = - isNovaSpaces || - (selectedProjectTag !== undefined && - novaContainerTags.includes(selectedProjectTag)) const { allProjects } = useContainerTags() - const emptyStateSpaceName = - !isNovaSpaces && selectedProjectTag - ? selectedProjectTag === DEFAULT_PROJECT_ID - ? "My Space" - : (allProjects.find((p) => p.containerTag === selectedProjectTag) - ?.name ?? selectedProjectTag) - : undefined - - const handleSwitchToAllSpacesFromEmptyState = useCallback(() => { - analytics.spaceSwitched({ space_id: "nova_spaces" }) - setSelectedProjects([]) - }, [setSelectedProjects]) + const dashboardSpaceLabel = useMemo( + () => + getChatSpaceDisplayLabel({ + selectedProject, + allProjects, + }), + [selectedProject, allProjects], + ) + const emptyStateSpaceName = selectedProjectTag + ? selectedProjectTag === DEFAULT_PROJECT_ID + ? "My Space" + : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ?? + selectedProjectTag) + : undefined const { viewMode, setViewMode } = useViewMode() const queryClient = useQueryClient() + const [highlightsForceAt, setHighlightsForceAt] = useState(0) // Chrome extension auth: send session token via postMessage so the content script can store it useEffect(() => { @@ -122,12 +148,14 @@ export default function NewPage() { "fullscreen", fullscreenParam, ) - const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam) const [integrationFromUrl, setIntegration] = useQueryState( "integration", integrationParam, ) - const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam) + const [pluginsPanelFromUrl, setPluginsPanel] = useQueryState( + "plugins", + pluginsPanelParam, + ) useEffect(() => { if (integrationFromUrl || pluginsPanelFromUrl === true) { @@ -138,6 +166,10 @@ export default function NewPage() { // Ephemeral local state (not worth URL-encoding) const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) + const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedMessageSource, setQueuedMessageSource] = useState< + "highlight" | "home" + >("highlight") const [selectedDocument, setSelectedDocument] = useState(null) @@ -177,6 +209,8 @@ export default function NewPage() { const resetDraft = useQuickNoteDraftReset(selectedProject) const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") + const quickNoteDraftRef = useRef(quickNoteDraft) + quickNoteDraftRef.current = quickNoteDraft const { noteMutation, bulkDeleteMutation } = useDocumentMutations({ onClose: () => { @@ -247,20 +281,31 @@ export default function NewPage() { const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours + const handleResetHighlights = useCallback(async () => { + toast.success("Refreshing daily brief…") + try { + await caches.delete(HIGHLIGHTS_CACHE_NAME) + } catch {} + setHighlightsForceAt(Date.now()) + }, []) + const { data: highlightsData, isLoading: isLoadingHighlights } = useQuery({ - queryKey: ["space-highlights", selectedProject], + queryKey: ["space-highlights", selectedProject, highlightsForceAt], queryFn: async (): Promise => { const spaceId = selectedProject || "sm_project_default" + const forceRefresh = highlightsForceAt > 0 const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` - const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) - const cached = await cache.match(cacheKey) - if (cached) { - const age = - Date.now() - Number(cached.headers.get("x-cached-at") || 0) - if (age < HIGHLIGHTS_MAX_AGE) { - return cached.json() + if (!forceRefresh) { + const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cached = await cache.match(cacheKey) + if (cached) { + const age = + Date.now() - Number(cached.headers.get("x-cached-at") || 0) + if (age < HIGHLIGHTS_MAX_AGE) { + return cached.json() + } } } @@ -276,6 +321,7 @@ export default function NewPage() { questionsCount: 4, includeHighlights: true, includeQuestions: true, + forceRefresh, }), }, ) @@ -286,13 +332,21 @@ export default function NewPage() { const data = await response.json() - const cacheResponse = new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - "x-cached-at": String(Date.now()), - }, - }) - await cache.put(cacheKey, cacheResponse) + // Update browser cache with fresh data (works for both normal and forced refresh) + try { + const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cacheResponse = new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "x-cached-at": String(Date.now()), + }, + }) + await freshCache.put(cacheKey, cacheResponse) + } catch {} + + // Reset force flag after the forced fetch completes so future project-switches + // use the normal cache path instead of always bypassing it. + if (forceRefresh) setHighlightsForceAt(0) return data }, @@ -300,6 +354,37 @@ export default function NewPage() { refetchOnWindowFocus: false, }) + const { data: memoryOfDay = null } = useQuery({ + queryKey: [ + "memory-of-day", + user?.id, + new Date().toISOString().slice(0, 10), + ], + queryFn: async (): Promise => { + const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}` + try { + const stored = localStorage.getItem(cacheKey) + if (stored) return JSON.parse(stored) as MemoryOfDay + } catch {} + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`, + { credentials: "include" }, + ) + if (!response.ok) return null + const data = (await response.json()) as MemoryOfDay | null + if (data) { + try { + localStorage.setItem(cacheKey, JSON.stringify(data)) + } catch {} + } + return data + }, + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!user, + }) + useHotkeys("c", () => { analytics.addDocumentModalOpened() setAddDoc("note") @@ -324,7 +409,7 @@ export default function NewPage() { const handleQuickNoteSave = useCallback( (content: string) => { if (content.trim()) { - const hadPreviousContent = quickNoteDraft.trim().length > 0 + const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0 noteMutation.mutate( { content, project: selectedProject }, { @@ -339,7 +424,7 @@ export default function NewPage() { ) } }, - [selectedProject, noteMutation, quickNoteDraft], + [selectedProject, noteMutation], ) const handleFullScreenSave = useCallback( @@ -375,11 +460,29 @@ export default function NewPage() { const handleHighlightsChat = useCallback( (seed: string) => { setQueuedChatSeed(seed) - setIsChatOpen(true) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + void setViewMode("chat") }, - [setIsChatOpen], + [setViewMode], ) + const handleHomeChatStart = useCallback( + (message: string, model: ModelId) => { + setQueuedChatSeed(message) + setQueuedChatModel(model) + setQueuedMessageSource("home") + void setViewMode("chat") + }, + [setViewMode], + ) + + const consumeQueuedChat = useCallback(() => { + setQueuedChatSeed(null) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + }, []) + const handleHighlightsShowRelated = useCallback( (query: string) => { analytics.searchOpened({ source: "highlight_related" }) @@ -401,6 +504,11 @@ export default function NewPage() { [setViewMode, setIntegration], ) + const handleOpenPlugins = useCallback(() => { + void setViewMode("integrations") + void setPluginsPanel(true) + }, [setViewMode, setPluginsPanel]) + const handleAddMemory = useCallback( (tab: "note" | "link") => { analytics.addDocumentModalOpened() @@ -409,120 +517,193 @@ export default function NewPage() { [setAddDoc], ) - const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile + const viewportWidth = useSyncExternalStore( + subscribeViewportWidth, + getViewportWidth, + () => GRADIENT_TOP_WIDTH_MAX, + ) + const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) + + const isChatView = viewMode === "chat" const isGraphMode = viewMode === "graph" && !isMobile + const isMemoriesDesktop = viewMode === "list" && !isMobile + const isHomeDesktop = viewMode === "dashboard" && !isMobile + const showNovaBackdrop = isGraphMode || isMemoriesDesktop + const isDashboardShell = + viewMode === "dashboard" || (viewMode === "graph" && isMobile) return (
- - {isGraphMode && ( -
+ {showNovaBackdrop && ( + <> + +
+
+ )}
{ analytics.addDocumentModalOpened() setAddDoc("note") }} - onOpenChat={() => setIsChatOpen(true)} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) setIsSearchOpen(true) }} /> -
-
- }> - {viewMode === "integrations" ? ( -
- -
- ) : viewMode === "graph" && !isMobile ? ( -
- -
- ) : ( -
- -
+ + +
-
- - - setIsChatOpen(open)} - queuedMessage={queuedChatSeed} - onConsumeQueuedMessage={() => setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} + > + }> + {isChatView ? ( +
+ { + if (!open) void setViewMode("dashboard") + }} + queuedMessage={queuedChatSeed} + onConsumeQueuedMessage={consumeQueuedChat} + queuedMessageSource={queuedMessageSource} + initialSelectedModel={queuedChatModel} + emptyStateSuggestions={highlightsData?.questions} + /> +
+ ) : viewMode === "integrations" ? ( +
+ +
+ ) : viewMode === "graph" && !isMobile ? ( +
+ +
+ ) : viewMode === "list" ? ( +
+ +
+ ) : ( + + + Graph view is available on desktop. + {" "} + Use a larger screen for the full graph, or keep + working from this home view. +
+ ) : undefined + } + highlights={highlightsData?.highlights ?? []} + isLoadingHighlights={isLoadingHighlights} + onAddMemory={handleAddMemory} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + onOpenIntegrations={handleOpenIntegrations} + onOpenPlugins={handleOpenPlugins} + onNavigateToMemories={() => void setViewMode("list")} + onNavigateToGraph={() => void setViewMode("graph")} + onOpenDocument={handleOpenDocument} + onHighlightsChat={handleHighlightsChat} + onHighlightsShowRelated={handleHighlightsShowRelated} + onResetHighlights={handleResetHighlights} + memoryOfDay={memoryOfDay} /> - - + )} + +
+
+
+ + {isDashboardShell && ( +
+
+
-
- - {isMobile && ( - setIsChatOpen(open)} - queuedMessage={queuedChatSeed} - onConsumeQueuedMessage={() => setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} - /> )} { analytics.addDocumentModalOpened() diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 9d47b8389..4f092cbb9 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -6,7 +6,7 @@ import { motion } from "motion/react" import NovaOrb from "@/components/nova/nova-orb" import { useState, useEffect, useRef } from "react" import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import Account from "@/components/settings/account" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" @@ -16,7 +16,11 @@ import { useRouter } from "next/navigation" import { useIsMobile } from "@hooks/use-mobile" import { useLocalStorageUsername } from "@hooks/use-local-storage-username" import { analytics } from "@/lib/analytics" -import { Sun } from "lucide-react" +import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react" +import { authClient } from "@lib/auth" +import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { useResetOrganization } from "@/hooks/use-reset-organization" +import { useDeleteUserAccount } from "@/hooks/use-account-settings" const TABS = ["account", "integrations", "connections", "support"] as const type SettingsTab = (typeof TABS)[number] @@ -28,6 +32,14 @@ type NavItem = { icon: React.ReactNode } +type DangerItem = { + id: "logout" | "reset" | "delete" + label: string + description: string + icon: React.ReactNode + color: "neutral" | "amber" | "red" +} + const NAV_ITEMS: NavItem[] = [ { id: "account", @@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [ }, ] +const DANGER_ITEMS: DangerItem[] = [ + { + id: "logout", + label: "Log out", + description: "Sign out of your account on this device", + icon: , + color: "neutral", + }, + { + id: "reset", + label: "Reset data", + description: "Erase all memories, connections and spaces", + icon: , + color: "amber", + }, + { + id: "delete", + label: "Delete account", + description: "Permanently delete your account and all data", + icon: , + color: "red", + }, +] + +const DANGER_COLORS: Record< + DangerItem["color"], + { idle: string; hover: string; icon: string } +> = { + neutral: { + idle: "text-white/50", + hover: "hover:text-white", + icon: "text-white/40", + }, + amber: { + idle: "text-[#7A6030]", + hover: "hover:text-[#C7991B]", + icon: "text-[#7A6030]", + }, + red: { + idle: "text-[#6B2A2A]", + hover: "hover:text-[#C73B1B]", + icon: "text-[#6B2A2A]", + }, +} + function parseHashToTab(hash: string): SettingsTab { const cleaned = hash.replace("#", "").toLowerCase() return TABS.includes(cleaned as SettingsTab) @@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) { } export default function SettingsPage() { - const { user } = useAuth() + const { user, org } = useAuth() const [activeTab, setActiveTab] = useState("account") const hasInitialized = useRef(false) const router = useRouter() const isMobile = useIsMobile() const localStorageUsername = useLocalStorageUsername() + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) + const [resetConfirmation, setResetConfirmation] = useState("") + const resetOrganization = useResetOrganization() + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") + const deleteUserAccount = useDeleteUserAccount() + + const handleLogout = async () => { + await authClient.signOut() + router.push("/login") + } + + const handleDeleteAccount = async () => { + if (deleteEmailConfirm !== user?.email) return + deleteUserAccount.mutate( + { confirmation: deleteEmailConfirm }, + { + onSuccess: () => { + setIsDeleteDialogOpen(false) + setDeleteEmailConfirm("") + router.push("/login") + }, + }, + ) + } + useEffect(() => { if (hasInitialized.current) return hasInitialized.current = true @@ -277,6 +361,55 @@ export default function SettingsPage() { )} ))} + + {/* Divider */} + {!isMobile &&
} + + {DANGER_ITEMS.map((item) => { + const colors = DANGER_COLORS[item.color] + const handleClick = () => { + if (item.id === "logout") handleLogout() + else if (item.id === "reset") setIsResetDialogOpen(true) + else if (item.id === "delete") setIsDeleteDialogOpen(true) + } + return ( + + ) + })}
@@ -303,6 +436,169 @@ export default function SettingsPage() {
+ + {/* Reset data dialog */} + {(() => { + const confirmText = org?.name || user?.name || "" + return ( + { + setIsResetDialogOpen(open) + if (!open) setResetConfirmation("") + }} + > + +
+
+

+ Reset all data? +

+

+ This permanently removes: +

+
    +
  • All documents and memories
  • +
  • All connections (Google Drive, Notion, etc.)
  • +
  • All custom spaces (default space stays)
  • +
  • Organization settings and filters
  • +
+

+ Your account and billing plan stay intact.{" "} + + This cannot be undone. + +

+
+
+

+ Type{" "} + + {confirmText || "your name"} + {" "} + to confirm: +

+ setResetConfirmation(e.target.value)} + placeholder={confirmText || "Your name"} + autoComplete="off" + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors" + /> +
+
+ + + + +
+
+
+
+ ) + })()} + + {/* Delete account dialog */} + { + setIsDeleteDialogOpen(open) + if (!open) setDeleteEmailConfirm("") + }} + > + +
+
+

+ Delete your account? +

+

+ Permanently deletes all your data and cancels any active + subscriptions.{" "} + + This cannot be undone. + +

+
+
+

+ Type your email{" "} + {user?.email} to + confirm: +

+ setDeleteEmailConfirm(e.target.value)} + placeholder={user?.email ?? "your@email.com"} + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors" + /> +
+
+ + + + +
+
+
+
) } diff --git a/apps/web/components/animated-gradient-background.tsx b/apps/web/components/animated-gradient-background.tsx index 8b37c8c4c..4a749d567 100644 --- a/apps/web/components/animated-gradient-background.tsx +++ b/apps/web/components/animated-gradient-background.tsx @@ -8,55 +8,51 @@ export function AnimatedGradientBackground({ animateFromBottom?: boolean }) { return ( -
+
extractHighlightDocumentIdsFromMessages(messages), + [messages], + ) + + return ( +
+
+

Memory map

+

+ {highlightIds.length > 0 + ? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova` + : "Memories used by Nova will be highlighted here"} +

+
+
+
+ 0} + maxNodes={160} + /> +
+
+ ) +} diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx new file mode 100644 index 000000000..e1042cac6 --- /dev/null +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import ChatInput from "./input" +import ChatModelSelector from "./model-selector" +import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" +import { useProject } from "@/stores" +import { useContainerTags } from "@/hooks/use-container-tags" +import { dmSansClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import type { ModelId } from "@/lib/models" + +export function HomeChatComposer({ + onStartChat, + className, +}: { + onStartChat: (message: string, model: ModelId) => void + className?: string +}) { + const [input, setInput] = useState("") + const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") + const { selectedProject } = useProject() + const { allProjects } = useContainerTags() + const chatSpaceLabel = useMemo( + () => + getChatSpaceDisplayLabel({ + selectedProject, + allProjects, + }), + [selectedProject, allProjects], + ) + + const send = useCallback(() => { + const t = input.trim() + if (!t) return + onStartChat(t, selectedModel) + setInput("") + }, [input, onStartChat, selectedModel]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + send() + } + } + + return ( +
+
+ setInput(e.target.value)} + onSend={send} + onStop={() => {}} + onKeyDown={handleKeyDown} + isResponding={false} + showStatusStrip={false} + stackedToolbar={ + <> + +
+ + {chatSpaceLabel} + +
+ + } + /> +
+
+ ) +} diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 0a0503f3e..4f67a318d 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -3,21 +3,21 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useQueryState } from "nuqs" import type { UIMessage } from "@ai-sdk/react" -import { motion, AnimatePresence } from "motion/react" +import { motion } 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 { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@ui/components/dialog" + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@ui/components/sheet" import { ScrollArea } from "@ui/components/scroll-area" import { + ArrowLeft, Check, ChevronDownIcon, HistoryIcon, @@ -41,6 +41,7 @@ import { modelNames, type ModelId } from "@/lib/models" import { SuperLoader } from "../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage } from "./message/agent-message" +import { ChatGraphContextRail } from "./chat-graph-context-rail" import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" @@ -99,20 +100,69 @@ function ChatEmptyStatePlaceholder({ ) } +export function ChatLaunchFab({ + onOpen, + isMobile, +}: { + onOpen: () => void + isMobile: boolean +}) { + return ( + + + + Chat with Nova + + + ) +} + export function ChatSidebar({ isChatOpen, setIsChatOpen, queuedMessage, onConsumeQueuedMessage, + queuedMessageSource = "highlight", + initialSelectedModel = null, emptyStateSuggestions, + layout = "sidebar", }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void queuedMessage?: string | null onConsumeQueuedMessage?: () => void + queuedMessageSource?: "highlight" | "home" + initialSelectedModel?: ModelId | null emptyStateSuggestions?: string[] + layout?: "sidebar" | "page" }) { const isMobile = useIsMobile() + const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState("claude-sonnet-4.6") @@ -183,6 +233,7 @@ export function ChatSidebar({ useEffect(() => { if (isMobile) return if (viewMode === "graph") return + if (layout === "page") return const handleWindowScroll = () => { const scrollThreshold = 80 @@ -196,7 +247,7 @@ export function ChatSidebar({ handleWindowScroll() return () => window.removeEventListener("scroll", handleWindowScroll) - }, [isMobile, viewMode]) + }, [isMobile, viewMode, layout]) const { messages, @@ -251,6 +302,7 @@ export function ChatSidebar({ const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return + if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) sendMessage({ text: input }) setInput("") @@ -264,10 +316,6 @@ export function ChatSidebar({ } } - const toggleChat = () => { - setIsChatOpen(!isChatOpen) - } - const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) @@ -356,12 +404,17 @@ export function ChatSidebar({ (m: { id: string role: string - parts: unknown + parts: Array<{ type: string }> createdAt: string }) => ({ id: m.id, role: m.role, - parts: m.parts || [], + // Strip tool parts — persisted format doesn't round-trip through + // convertToModelMessages correctly and causes tool_use/tool_result + // mismatch errors. Text history is sufficient for context. + parts: (m.parts || []).filter( + (p) => p.type === "text" || p.type === "reasoning", + ), createdAt: new Date(m.createdAt), }), ) @@ -378,6 +431,15 @@ export function ChatSidebar({ [setThreadId], ) + // Auto-restore thread from URL on mount (e.g. reload or direct link) + const didAutoLoadRef = useRef(false) + useEffect(() => { + if (didAutoLoadRef.current) return + if (!threadId) return + didAutoLoadRef.current = true + loadThread(threadId) + }, [threadId, loadThread]) + const deleteThread = useCallback( async (threadId: string) => { try { @@ -441,11 +503,22 @@ export function ChatSidebar({ sentQueuedMessageRef.current !== queuedMessage ) { sentQueuedMessageRef.current = queuedMessage - analytics.chatMessageSent({ source: "highlight" }) + if (!threadId) setThreadId(fallbackChatId) + analytics.chatMessageSent({ source: queuedMessageSource }) sendMessage({ text: queuedMessage }) onConsumeQueuedMessage?.() } - }, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage]) + }, [ + isChatOpen, + queuedMessage, + queuedMessageSource, + status, + sendMessage, + onConsumeQueuedMessage, + fallbackChatId, + setThreadId, + threadId, + ]) // Reset the sent message ref when queued message is consumed useEffect(() => { @@ -497,431 +570,477 @@ export function ChatSidebar({ } }, [checkIfScrolledToBottom]) - return ( - - {!isChatOpen ? ( - - { + setIsHistoryOpen(open) + if (open) { + fetchThreads() + analytics.chatHistoryViewed?.() + } else { + setConfirmingDeleteId(null) + } + }} + > + button]:text-[#FAFAFA]", + dmSansClassName(), + )} + > + + Chat History + + Space: {chatSpaceLabel} + + + +
+ {isLoadingThreads ? ( +
+ +
+ ) : threads.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {threads.map((thread) => { + const isActive = thread.id === currentChatId + return ( + + +
+ ) : ( + + )} + + ) + })} +
)} - style={{ - background: isMobile - ? "linear-gradient(135deg, #12161C 0%, #0A0D12 100%)" - : "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", +
+ +
+ +
+ + + ) + + const chatToolbarActions = ( +
+ + +
+ ) + + const pageDesktopToolbarRow = isPageDesktop ? ( +
+ {chatToolbarActions} +
+ ) : null + + const shell = ( + <> + {showHeaderRow ? ( +
-
-
- -
- - {chatSpaceLabel} - -
-
-
- { - setIsHistoryOpen(open) - if (open) { - fetchThreads() - analytics.chatHistoryViewed?.() - } else { - setConfirmingDeleteId(null) - } - }} - > - - - - - - Chat History - - Space: {chatSpaceLabel} - - - - {isLoadingThreads ? ( -
- -
- ) : threads.length === 0 ? ( -
- No conversations yet -
- ) : ( -
- {threads.map((thread) => { - const isActive = thread.id === currentChatId - return ( - - -
- ) : ( - - )} - - ) - })} -
- )} - - - - +
+ {layout === "page" && isMobile && ( - {/* - {isMobile ? ( - - ) : ( - - )} - */} -
+ )} + {!isStackedInput && ( + <> + +
+ + {chatSpaceLabel} + +
+ + )}
+ {chatToolbarActions} +
+ ) : null} +
+ {isInputExpanded && (
- {isInputExpanded && ( -
- )} - {messages.length === 0 && ( - { - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - sendMessage({ text: suggestion }) - }} - suggestions={emptyStateSuggestions} - /> + "absolute inset-0 z-10! pointer-events-none", + isPageDesktop ? "rounded-none" : "rounded-2xl", )} + style={{ backgroundColor: "#000000E5" }} + /> + )} + {messages.length === 0 && ( + { + analytics.chatSuggestedQuestionClicked() + analytics.chatMessageSent({ source: "suggested" }) + sendMessage({ text: suggestion }) + }} + suggestions={emptyStateSuggestions} + /> + )} +
0 + ? cn( + "flex flex-col space-y-3 min-h-full justify-end", + isPageDesktop ? "pt-2" : "pt-14", + ) + : "" + } + > + {messages.map((message, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
0 - ? "flex flex-col space-y-3 min-h-full justify-end pt-14" - : "", + "flex gap-2 w-full", + message.role === "user" ? "justify-end" : "justify-start", )} + onMouseEnter={() => + message.role === "assistant" && setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } > - {messages.map((message, index) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions -
- message.role === "assistant" && - setHoveredMessageId(message.id) - } - onMouseLeave={() => - message.role === "assistant" && setHoveredMessageId(null) - } - > - {message.role === "user" ? ( - - ) : ( - - )} -
- ))} - {(status === "submitted" || status === "streaming") && ( -
- -
+ {message.role === "user" ? ( + + ) : ( + )}
-
- - {!isScrolledToBottom && messages.length > 0 && ( -
- + ))} + {(status === "submitted" || status === "streaming") && ( +
+
)} +
+
- {chatStreamError && ( -
0 && ( +
+ +
+ )} + + {chatStreamError && ( +
+
+
+

+ {chatStreamError.title} +

+

+ {chatStreamError.body} +

+ {chatStreamError.otherModels.length > 0 && ( +
+ {chatStreamError.otherModels.map((id) => { + const m = modelNames[id] + return ( + + ) + })} +
)} +
+ - ) - })} -
+ + +
+
+ )} + +
+ setInput(e.target.value)} + onSend={handleSend} + onStop={stop} + onKeyDown={handleKeyDown} + isResponding={status === "submitted" || status === "streaming"} + activeStatus={ + status === "submitted" + ? "Thinking..." + : status === "streaming" + ? "Structuring response..." + : "Waiting for input..." + } + onExpandedChange={setIsInputExpanded} + chainOfThoughtComponent={ + messages.length > 0 ? : null + } + stackedToolbar={ + isStackedInput ? ( + <> + +
- -
-
- )} + + {chatSpaceLabel} + +
+ + ) : undefined + } + /> +
+ + ) - setInput(e.target.value)} - onSend={handleSend} - onStop={stop} - onKeyDown={handleKeyDown} - isResponding={status === "submitted" || status === "streaming"} - activeStatus={ - status === "submitted" - ? "Thinking..." - : status === "streaming" - ? "Structuring response..." - : "Waiting for input..." - } - onExpandedChange={setIsInputExpanded} - chainOfThoughtComponent={ - messages.length > 0 ? ( - - ) : null - } - /> -
+ return ( + + style={ + isMobile + ? undefined + : isPageDesktop + ? undefined + : { + height: `calc(100vh - ${heightOffset}px)`, + } + } + initial={ + isMobile + ? { y: "100%", opacity: 0 } + : layout === "page" + ? { opacity: 0, y: 20 } + : { x: "100px", opacity: 0 } + } + animate={{ x: 0, y: 0, opacity: 1 }} + exit={ + isMobile + ? { y: "100%", opacity: 0 } + : layout === "page" + ? { opacity: 0, y: 12 } + : { x: "100px", opacity: 0 } + } + transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }} + > + {chatHistorySheet} + {isPageDesktop ? ( +
+ +
+ {pageDesktopToolbarRow} +
+ {shell} +
+
+
+ ) : ( + shell + )} +
) } + +export { HomeChatComposer } from "./home-chat-composer" diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 40c1949d2..d88e34faa 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { useRef, useState } from "react" +import { type ReactNode, useRef, useState } from "react" import { motion } from "motion/react" import { SendButton, StopButton } from "./actions" @@ -18,6 +18,10 @@ interface ChatInputProps { activeStatus?: string chainOfThoughtComponent?: React.ReactNode onExpandedChange?: (expanded: boolean) => void + /** Model + space controls on one row with send; textarea full-width above */ + stackedToolbar?: ReactNode + /** Nova status row + chain-of-thought toggle (off for e.g. home composer) */ + showStatusStrip?: boolean } export default function ChatInput({ @@ -30,6 +34,8 @@ export default function ChatInput({ activeStatus, chainOfThoughtComponent, onExpandedChange, + stackedToolbar, + showStatusStrip = true, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) @@ -52,82 +58,122 @@ export default function ChatInput({ -
- {chainOfThoughtComponent} -
- + + ) : null} + {stackedToolbar ? ( +
+