From 170133cf83f4f87163f434930bfb612ad61069da Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:13:32 +0000 Subject: [PATCH] feat(web): make brain onboarding the default flow (#1067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(web): make brain onboarding the default flow - Replace legacy /onboarding with the brain onboarding flow (about -> sources -> ingest -> team -> done) - Add brain-home dashboard page and widgets - Old /onboarding/brain deep links fall through to the existing catch-all redirect feat(onboarding): create org on About step + remove skip - Create the organization on About -> Continue (authClient.organization.create + setActiveOrg), persisting brain context to org metadata; fixes the no-org redirect loop - Show a Creating… state on Continue while the org is created - Remove Skip on the required About step (name + workspace) - Add generateOrgSlug/generateUsername helpers to onboarding-brain/types --- apps/web/app/(app)/brain-home/page.tsx | 992 +++++++++++ apps/web/app/(app)/onboarding/page.tsx | 1577 +++-------------- apps/web/app/(app)/page.tsx | 2 + apps/web/components/brain-home/data.ts | 466 +++++ apps/web/components/brain-home/widgets.tsx | 671 +++++++ .../onboarding-brain/onboarding-confetti.tsx | 53 + .../web/components/onboarding-brain/shell.tsx | 137 ++ .../onboarding-brain/step-about.tsx | 473 +++++ .../onboarding-brain/step-ingest.tsx | 480 +++++ .../onboarding-brain/step-sources.tsx | 587 ++++++ .../components/onboarding-brain/step-team.tsx | 331 ++++ apps/web/components/onboarding-brain/types.ts | 131 ++ apps/web/package.json | 7 +- bun.lock | 6 + 14 files changed, 4587 insertions(+), 1326 deletions(-) create mode 100644 apps/web/app/(app)/brain-home/page.tsx create mode 100644 apps/web/components/brain-home/data.ts create mode 100644 apps/web/components/brain-home/widgets.tsx create mode 100644 apps/web/components/onboarding-brain/onboarding-confetti.tsx create mode 100644 apps/web/components/onboarding-brain/shell.tsx create mode 100644 apps/web/components/onboarding-brain/step-about.tsx create mode 100644 apps/web/components/onboarding-brain/step-ingest.tsx create mode 100644 apps/web/components/onboarding-brain/step-sources.tsx create mode 100644 apps/web/components/onboarding-brain/step-team.tsx create mode 100644 apps/web/components/onboarding-brain/types.ts diff --git a/apps/web/app/(app)/brain-home/page.tsx b/apps/web/app/(app)/brain-home/page.tsx new file mode 100644 index 00000000..187ed230 --- /dev/null +++ b/apps/web/app/(app)/brain-home/page.tsx @@ -0,0 +1,992 @@ +"use client" + +import { Suspense } from "react" +import { useSearchParams } from "next/navigation" +import { GoogleDrive, Notion } from "@ui/assets/icons" +import { cn } from "@lib/utils" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" +import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react" +import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { Header } from "@/components/header" +import { HomeChatComposer } from "@/components/chat/home-chat-composer" +import { + RecommendedRail, + SourcesHealthRail, +} from "@/components/brain-home/widgets" + +const modalCardStyle = { + boxShadow: + "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", +} + +const inputBevelStyle = { + boxShadow: + "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", +} + +type Space = { + id: string + name: string + icon: React.ReactNode + itemCount: number + lastSync: string +} + +const _SPACES: Space[] = [ + { + id: "drive", + name: "My Drive", + icon: , + itemCount: 287, + lastSync: "2h ago", + }, + { + id: "notion", + name: "Company Notion", + icon: , + itemCount: 142, + lastSync: "12m ago", + }, + { + id: "gmail", + name: "Gmail", + icon: , + itemCount: 1843, + lastSync: "Just now", + }, +] + +type Stat = { + label: string + value: string + delta?: string + deltaTint?: "up" | "down" | "neutral" +} + +const STATS: Stat[] = [ + { + label: "Items ingested", + value: "2,272", + delta: "+148 this week", + deltaTint: "up", + }, + { + label: "Agent sessions", + value: "184", + delta: "+42 vs last wk", + deltaTint: "up", + }, + { + label: "Active contributors", + value: "4", + delta: "of 6 invited", + deltaTint: "neutral", + }, + { + label: "Top space", + value: "Gmail", + delta: "1,843 items", + deltaTint: "neutral", + }, +] + +type AgentSeries = { + key: string + label: string + color: string +} + +const AGENT_SERIES: AgentSeries[] = [ + { key: "cursor", label: "Cursor", color: "#4BA0FA" }, + { key: "claudeCode", label: "Claude Code", color: "#FF8A47" }, + { key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" }, + { key: "chatgpt", label: "ChatGPT", color: "#10A37F" }, + { key: "other", label: "Other", color: "#525D6E" }, +] + +const SESSIONS_BY_DAY: { + label: string + values: Record +}[] = [ + { + label: "Mon", + values: { + cursor: 8, + claudeCode: 4, + claudeDesktop: 3, + chatgpt: 2, + other: 1, + }, + }, + { + label: "Tue", + values: { + cursor: 10, + claudeCode: 6, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Wed", + values: { + cursor: 12, + claudeCode: 9, + claudeDesktop: 5, + chatgpt: 4, + other: 1, + }, + }, + { + label: "Thu", + values: { + cursor: 9, + claudeCode: 5, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Fri", + values: { + cursor: 14, + claudeCode: 10, + claudeDesktop: 6, + chatgpt: 4, + other: 2, + }, + }, + { + label: "Sat", + values: { + cursor: 5, + claudeCode: 3, + claudeDesktop: 2, + chatgpt: 1, + other: 1, + }, + }, + { + label: "Sun", + values: { + cursor: 17, + claudeCode: 12, + claudeDesktop: 7, + chatgpt: 4, + other: 1, + }, + }, +] + +const RECENT_ANSWERS = [ + { + question: "What did we decide about pricing?", + answer: "Pro stays at $20/mo. No overages, top-up only.", + who: "You", + when: "2h ago", + }, + { + question: "What's blocking the Acme deal?", + answer: "MSA review — Jane drafting a net-30 counter.", + who: "You", + when: "4h ago", + }, + { + question: "Who owns the onboarding rewrite?", + answer: "Design team. Ship target Friday.", + who: "Sarah Kim", + when: "Yesterday", + }, +] + +const WEEKLY_DIGEST = { + range: "Mon – Fri", + headline: + "Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.", + bullets: [ + { + label: "5 deadlines coming up next week", + tint: "text-[#4BA0FA]", + }, + { + label: "2 conflicts surfaced between Notion and Slack", + tint: "text-[#FF8A47]", + }, + { + label: "Sarah Kim led contributions with 14 docs", + tint: "text-[#A1A1AA]", + }, + ], +} + +const TEAM_THIS_WEEK = [ + { + name: "Sarah Kim", + email: "sarah@acme.com", + contributions: 14, + summary: "14 docs · 3 spaces", + color: "#FF8A47", + }, + { + name: "Jane Doe", + email: "jane@acme.com", + contributions: 11, + summary: "11 docs · 2 spaces", + color: "#4BA0FA", + }, + { + name: "Mahesh S.", + email: "mahesh@acme.com", + contributions: 9, + summary: "9 docs · 1 space", + color: "#A1A1AA", + }, + { + name: "Alex Chen", + email: "alex@acme.com", + contributions: 7, + summary: "7 docs · 2 spaces", + color: "#10A37F", + }, +] + +const TEAM_RECENT = [ + { who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" }, + { who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" }, + { who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" }, + { + who: "Sarah Kim", + what: "connected Notion to Sales space", + when: "Yesterday", + }, +] + +const ACTIVITY = [ + { + when: "2m", + text: "23 new docs ingested from Drive", + source: "drive", + }, + { + when: "14m", + text: "Jane added a memo to acme-deal-q2", + source: "notion", + }, + { + when: "1h", + text: "Decision surfaced: pricing locked at $20 Pro", + source: "slack", + }, + { + when: "3h", + text: "14 Notion pages updated by Sarah Kim", + source: "notion", + }, + { + when: "5h", + text: "New entity formed: pricing-v3", + source: "system", + }, + { + when: "Yesterday", + text: "MSA contract uploaded · auto-tagged contracts/MSA", + source: "drive", + }, +] + +const _RECENT_ITEMS: Record = { + drive: [ + { title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" }, + { title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" }, + { title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" }, + { title: "Customer feedback Q1.docx", meta: "Yesterday" }, + { title: "Brand assets / logos", meta: "2 days ago" }, + ], + notion: [ + { title: "Engineering / Weekly sync", meta: "Updated 12m ago" }, + { title: "Sales / Acme account", meta: "Updated 1h ago" }, + { title: "Brand / Voice & tone", meta: "Yesterday" }, + { title: "Onboarding rewrite spec", meta: "2 days ago" }, + ], + gmail: [ + { title: "Re: Acme contract review", meta: "Just now · jane@acme.com" }, + { title: "Customer feedback digest", meta: "10m ago" }, + { title: "Re: Pricing decision", meta: "1h ago" }, + { title: "Onboarding ship plan", meta: "3h ago" }, + ], +} + +export default function BrainHomePage() { + return ( + + + + ) +} + +function BrainHomeInner() { + const searchParams = useSearchParams() + const empty = searchParams?.get("empty") === "1" + + return ( +
+
+ +
+
+
+ +
{}} onOpenSearch={() => {}} /> + +
+
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+
+ {}} /> +
+
+
+ ) +} + +function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) { + const EMPTY_STATS: Stat[] = [ + { + label: "Items ingested", + value: "—", + delta: "Connect a source", + deltaTint: "neutral", + }, + { + label: "Agent sessions", + value: "—", + delta: "Install an agent", + deltaTint: "neutral", + }, + { + label: "Active contributors", + value: "1", + delta: "Just you so far", + deltaTint: "neutral", + }, + { + label: "Top space", + value: "—", + delta: "Nothing connected", + deltaTint: "neutral", + }, + ] + const visible = empty ? EMPTY_STATS : stats + return ( +
+ {visible.map((s) => ( +
+

+ {s.label} +

+

+ {s.value} +

+ {s.delta && ( +

+ {s.delta} +

+ )} +
+ ))} +
+ ) +} + +function BrainActivityChart({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+
+

+ Sessions by agent +

+

+ 0 sessions yet — once you install Cursor, Claude Code, or any + MCP-capable agent, usage shows up here. +

+
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => ( +
+
+ + {d} + +
+ ))} +
+ +
+ ) + } + const totalsByAgent = AGENT_SERIES.map((s) => ({ + ...s, + total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0), + })) + const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0) + const dayTotals = SESSIONS_BY_DAY.map((d) => + AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0), + ) + const max = Math.max(...dayTotals) + + return ( +
+
+
+
+

+ Sessions by agent +

+

+ {grandTotal} agent sessions, last 7 days — which tool the team + reached for. +

+
+
+ {totalsByAgent.map((s) => ( + + + {s.label}{" "} + {s.total} + + ))} +
+
+ +
+ {SESSIONS_BY_DAY.map((d, dayIdx) => { + const BAR_AREA_PX = 140 + const totalForDay = dayTotals[dayIdx] ?? 0 + const barHeightPx = Math.max( + 6, + Math.round((totalForDay / max) * BAR_AREA_PX), + ) + return ( +
+
+
+ {AGENT_SERIES.map((series, sIdx) => { + const val = d.values[series.key] ?? 0 + if (val === 0) return null + const segHeight = + totalForDay > 0 ? (val / totalForDay) * 100 : 0 + return ( +
+ ) + })} +
+
+ + {d.label} + +
+ ) + })} +
+
+ ) +} + +function _TabButton({ + active, + onClick, + icon, + label, + count, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string + count: number +}) { + return ( + + ) +} + +function SectionHeader({ title, cta }: { title: string; cta?: string }) { + return ( +
+

+ {title} +

+ {cta && ( + + )} +
+ ) +} + +function BrainPulseBlock({ empty }: { empty?: boolean }) { + return ( +
+ + +
+ ) +} + +function RecentAnswersCard({ empty }: { empty?: boolean }) { + return ( +
+
+

+ Recent answers +

+ {!empty && ( + + )} +
+ {empty ? ( +
+

+ No questions asked yet. +

+

+ Ask the brain something below — answers and the team's history will + show up here. +

+
+ ) : ( +
    + {RECENT_ANSWERS.map((a) => ( +
  • +

    + {a.question} +

    +

    + → {a.answer} +

    +

    + {a.who} · {a.when} +

    +
  • + ))} +
+ )} +
+ ) +} + +function WeeklyDigestCard({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+
+

+ This week +

+

+ Your brain is just getting started. +

+

+ Once sources are connected and the team starts asking questions, + you'll see decisions, deadlines, and contributor highlights here. +

+ +
+ ) + } + return ( +
+
+
+

+ This week +

+

+ {WEEKLY_DIGEST.range} +

+
+ +

+ {WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) => + /^\d+$/.test(piece) ? ( + + {piece} + + ) : ( + {piece} + ), + )} +

+ +
    + {WEEKLY_DIGEST.bullets.map((b) => ( +
  • + + + {b.label} + +
  • + ))} +
+ + +
+ ) +} + +function ActivityFeedBlock({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+ +
+
+ +
+
+

+ Nothing happening yet. +

+

+ As sources sync and the team starts using the brain, every event + will show up here in real time. +

+
+
+
+ ) + } + const sourceIcon = (s: string) => { + if (s === "drive") return + if (s === "notion") return + if (s === "slack") + return + return + } + return ( +
+ +
+ {ACTIVITY.map((a, i) => ( +
+
+ {sourceIcon(a.source)} +
+

+ {a.text} +

+ + {a.when} + +
+ ))} +
+
+ ) +} + +function TeamActivityRail({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+

+ Just you so far +

+

+ A brain gets sharper as more people contribute. Invite teammates to + see who's adding what. +

+ +
+ ) + } + return ( +
+
+

+ Team activity +

+ + This week + +
+

+ What your team's been adding to the brain. +

+ +
+ {TEAM_THIS_WEEK.map((member) => ( +
+
+ {member.name[0]} +
+
+

+ {member.name} +

+

+ {member.summary} +

+
+ + {member.contributions} + +
+ ))} +
+ +
+

+ Recent +

+
    + {TEAM_RECENT.map((event, i) => ( +
  • + {event.who} {event.what} + · {event.when} +
  • + ))} +
+
+ + +
+ ) +} diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 3d67b3ba..42bfce24 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,1372 +1,299 @@ "use client" -import { - useState, - useRef, - useCallback, - useEffect, - useMemo, - type Dispatch, - type RefObject, - type ReactNode, - type SetStateAction, -} from "react" -import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { toast } from "sonner" 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 { BrainShell } from "@/components/onboarding-brain/shell" import { - CHROME_EXTENSION_URL, - RAYCAST_EXTENSION_URL, - ADD_MEMORY_SHORTCUT_URL, -} from "@repo/lib/constants" + StepAbout, + type AboutValues, +} from "@/components/onboarding-brain/step-about" import { - ChromeIcon, - AppleShortcutsIcon, - RaycastIcon, -} from "@/components/integration-icons" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" + StepSources, + type SourcesValues, +} from "@/components/onboarding-brain/step-sources" +import { StepIngest } from "@/components/onboarding-brain/step-ingest" import { - Sparkles, - ChevronLeft, - ChevronRight, - AlertCircle, - CheckCircle2, - Loader2, -} from "lucide-react" -import { analytics, type OnboardingStep } from "@/lib/analytics" -import { consumePendingConnectUrl } from "@/lib/constants" + StepTeam, + type TeamValues, +} from "@/components/onboarding-brain/step-team" +import { + BRAIN_STEPS, + type BrainMetadata, + type BrainMode, + type BrainStep, + containerTagFromWorkspace, + detectModeFromEmail, + generateOrgSlug, + generateUsername, + workspaceDomainFromEmail, + workspaceNameFromEmail, +} from "@/components/onboarding-brain/types" -type DetectedSource = "x" | "linkedin" | "resume" | null -type Status = "idle" | "processing" | "done" | "error" -type AccountLookupStatus = "checking" | "found" | "not_found" | "error" -type AccountLookup = { - source: "x" | "linkedin" - status: AccountLookupStatus - message: string -} -type DocStatus = - | "unknown" - | "queued" - | "extracting" - | "chunking" - | "embedding" - | "indexing" - | "done" - | "failed" +const STORAGE_KEY = "supermemory-brain-onboarding-v1" -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 - checking account", - linkedin: "LinkedIn profile detected - checking account", -} - -const SOURCE_NAME: Record<"x" | "linkedin", string> = { - x: "X", - linkedin: "LinkedIn", -} - -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=plugins") - } - - 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("/?add=connect") - }, - }, - ], - 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=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", - ) - }, - }, - ], - } -} - -function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { - return source === "x" || source === "linkedin" -} - -const STATUS_TO_STEP: Record = { - idle: "profile_input", - processing: "processing", - done: "done", - error: "error", -} - -function useSpotlightAutoRotation( - status: Status, - pauseSpotlight: boolean, - setSpotlightCategory: Dispatch>, -) { - 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, setSpotlightCategory]) -} - -function useInitialInputFocus(inputRef: RefObject) { - useEffect(() => { - const t = setTimeout(() => inputRef.current?.focus(), 500) - return () => clearTimeout(t) - }, [inputRef]) -} - -function useAccountLookup({ - detected, - status, - value, -}: { - detected: DetectedSource - status: Status - value: string -}) { - const [accountLookup, setAccountLookup] = useState(null) - - useEffect(() => { - if (status !== "idle") return - - const source = isAccountSource(detected) ? detected : null - const trimmedValue = value.trim() - - if (!source || !trimmedValue) { - setAccountLookup(null) - return - } - - const controller = new AbortController() - setAccountLookup({ - source, - status: "checking", - message: SOURCE_LABEL[source], - }) - - const timeout = setTimeout(async () => { - try { - const params = new URLSearchParams({ - source, - value: trimmedValue, - }) - const response = await fetch( - `/api/onboarding/account-status?${params.toString()}`, - { signal: controller.signal }, - ) - const data: { - found?: boolean - handle?: string - reason?: string - verified?: boolean - } = await response.json().catch(() => ({})) - - if (controller.signal.aborted) return - - if (response.ok && data.found === true) { - const account = - source === "x" && data.handle ? ` @${data.handle}` : "" - setAccountLookup({ - source, - status: "found", - message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`, - }) - return - } - - if ( - (response.ok && data.found === false) || - data.reason === "invalid" - ) { - setAccountLookup({ - source, - status: "not_found", - message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`, - }) - return - } - - setAccountLookup({ - source, - status: "error", - message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, - }) - } catch (err) { - if (controller.signal.aborted) return - console.error(err) - setAccountLookup({ - source, - status: "error", - message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, - }) - } - }, 450) - - return () => { - clearTimeout(timeout) - controller.abort() - } - }, [detected, status, value]) - - return accountLookup -} - -function usePollingCleanup( - pollingRef: RefObject | null>, -) { - useEffect(() => { - return () => { - if (pollingRef.current) clearInterval(pollingRef.current) - } - }, [pollingRef]) -} - -function useDoneAnimation( - status: Status, - setStampLanded: Dispatch>, - setVisibleSnippets: Dispatch>, -) { - 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, setStampLanded, setVisibleSnippets]) -} - -export default function OnboardingPage() { +export default function BrainOnboardingPage() { const router = useRouter() - const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() + const params = useSearchParams() + const { user, org, organizations, setActiveOrg, refetchOrganizations } = + 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 skippingRef = useRef(false) - const completedTrackedRef = useRef(false) - const [isSkipping, setIsSkipping] = useState(false) - const [spotlightCategory, setSpotlightCategory] = - useState("productivity") + const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about" + const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl) + ? stepFromUrl + : "about" - /** Navigate home, or back to the plugin connect page if one is pending. */ - const goHomeOrPendingConnect = useCallback(() => { - const pendingPath = consumePendingConnectUrl() - router.push(pendingPath ?? "/") - }, [router]) - const [pauseSpotlight, setPauseSpotlight] = useState(false) + const [step, setStep] = useState(initialStep) - const spotlightCatalog = useMemo( - () => buildSpotlightCatalog(router), + const detectedMode = useMemo( + () => detectModeFromEmail(user?.email), + [user?.email], + ) + const suggestedWorkspaceName = useMemo( + () => workspaceNameFromEmail(user?.email), + [user?.email], + ) + const domain = useMemo( + () => workspaceDomainFromEmail(user?.email), + [user?.email], + ) + + const [mode, setMode] = useState(detectedMode) + const [about, setAbout] = useState({ + name: user?.name ?? "", + about: "", + workspaceName: suggestedWorkspaceName, + workspaceDomain: domain ?? "", + }) + const [sources, setSources] = useState({ + connected: {}, + driveScope: "selective", + }) + const [team, setTeam] = useState({ + invites: [], + visibility: "team-private", + suggestChanges: false, + }) + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return + const cached = JSON.parse(raw) as { + mode?: BrainMode + about?: AboutValues + sources?: SourcesValues + team?: TeamValues + } + if (cached.mode) setMode(cached.mode) + if (cached.about) setAbout((a) => ({ ...a, ...cached.about })) + if (cached.sources) setSources((s) => ({ ...s, ...cached.sources })) + if (cached.team) setTeam((t) => ({ ...t, ...cached.team })) + } catch {} + }, []) + + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ mode, about, sources, team }), + ) + } catch {} + }, [mode, about, sources, team]) + + const setStepAndUrl = useCallback( + (next: BrainStep) => { + setStep(next) + const url = new URL(window.location.href) + url.searchParams.set("step", next) + router.replace(url.pathname + url.search, { scroll: false }) + }, [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], + const containerTag = useMemo( + () => + containerTagFromWorkspace( + about.workspaceName || suggestedWorkspaceName, + mode, + ), + [about.workspaceName, suggestedWorkspaceName, mode], ) - useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory) - useInitialInputFocus(inputRef) - const accountLookup = useAccountLookup({ detected, status, value }) - usePollingCleanup(pollingRef) - useDoneAnimation(status, setStampLanded, setVisibleSnippets) + const isScale = useMemo(() => { + const plan = (org?.metadata as Record | undefined)?.plan + return plan === "scale" || plan === "scale_yearly" + }, [org]) - // biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only - useEffect(() => { - analytics.onboardingStepViewed({ - step: STATUS_TO_STEP[status], - trigger: "auto", - }) - if (status === "done" && !completedTrackedRef.current) { - completedTrackedRef.current = true - analytics.onboardingCompleted({ - source: isAccountSource(detected) ? detected : undefined, - memories_count: memoriesCount, - }) + const finish = useCallback(async () => { + try { + localStorage.removeItem(STORAGE_KEY) + } catch {} + router.push("/?onboarded=1") + }, [router]) + + const goNext = useCallback(() => { + const idx = BRAIN_STEPS.indexOf(step) + const next = BRAIN_STEPS[idx + 1] + if (!next) { + finish() + return } - }, [status]) + setStepAndUrl(next) + }, [step, setStepAndUrl, finish]) - const handleChange = (v: string) => { - setValue(v) - setDetected(detectSource(v)) - } + const [creatingOrg, setCreatingOrg] = useState(false) + const creatingOrgRef = useRef(false) const ensureOrg = useCallback(async () => { if (organizations && organizations.length > 0) return - const name = user?.name || user?.email || "Personal" + const name = (about.workspaceName || suggestedWorkspaceName).trim() const slug = generateOrgSlug(name) + const metadata: BrainMetadata & { signupSource: string } = { + signupSource: "consumer", + brainOnboardingVersion: "v1", + brainMode: mode, + brainWorkspaceName: name, + brainWorkspaceDomain: + mode === "team" ? about.workspaceDomain || domain : null, + brainContainerTag: containerTag, + ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), + } const result = await authClient.organization.create({ name, slug, - metadata: { signupSource: "consumer" }, + metadata, }) await setActiveOrg(result.data?.slug ?? slug) - if (user?.name) { + if (about.name.trim()) { await authClient.updateUser({ - displayUsername: user.name, - username: generateUsername(user.name), + name: about.name.trim(), + displayUsername: about.name.trim(), + username: generateUsername(about.name), }) } await refetchOrganizations() - }, [user, organizations, refetchOrganizations, setActiveOrg]) + }, [ + organizations, + about, + suggestedWorkspaceName, + mode, + domain, + containerTag, + setActiveOrg, + refetchOrganizations, + ]) - const handleSkip = useCallback(async () => { - if (skippingRef.current) return - skippingRef.current = true - setIsSkipping(true) - analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] }) + const handleAboutContinue = useCallback(async () => { + if (creatingOrgRef.current) return + creatingOrgRef.current = true + setCreatingOrg(true) try { await ensureOrg() - const pendingPath = consumePendingConnectUrl() - router.push(pendingPath ?? "/") - } catch (err) { - console.error(err) - skippingRef.current = false - setIsSkipping(false) + goNext() + } catch (e) { + console.error("Failed to create organization:", e) + toast.error("Couldn't create your workspace. Please try again.") + } finally { + creatingOrgRef.current = false + setCreatingOrg(false) } - }, [ensureOrg, router, status]) + }, [ensureOrg, goNext]) - const pollDocument = useCallback((docId: string) => { - const maxAttempts = 60 - let attempt = 0 + const [sendingInvites, setSendingInvites] = useState(false) + const sendingInvitesRef = useRef(false) - 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) => { - analytics.onboardingProfileSubmitted({ source }) - 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 handleTeamContinue = useCallback(async () => { + if (sendingInvitesRef.current) return + const pending = team.invites.filter((i) => i.email.trim()) + if (pending.length === 0) { + goNext() + return } - } + sendingInvitesRef.current = true + setSendingInvites(true) + try { + if (!org?.id) throw new Error("No active organization") + const results = await Promise.allSettled( + pending.map((inv) => + authClient.organization.inviteMember({ + email: inv.email.trim().toLowerCase(), + role: inv.role, + organizationId: org.id, + resend: true, + }), + ), + ) + const failed = results.filter( + (r) => + r.status === "rejected" || + (r.status === "fulfilled" && Boolean(r.value?.error)), + ).length + if (failed > 0) { + toast.error( + `${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`, + ) + } else { + toast.success( + `Sent ${pending.length} invite${pending.length === 1 ? "" : "s"}.`, + ) + } + } catch (e) { + console.error("Failed to send invites:", e) + toast.error("Couldn't send invites. You can invite teammates later.") + } finally { + sendingInvitesRef.current = false + setSendingInvites(false) + goNext() + } + }, [team.invites, org, goNext]) - const hasDetectedAccount = detected === "x" || detected === "linkedin" - const currentAccountLookup = - accountLookup?.source === detected ? accountLookup : null - const isCheckingAccount = - hasDetectedAccount && - (!currentAccountLookup || currentAccountLookup.status === "checking") - const canSubmit = Boolean( - hasDetectedAccount && - currentAccountLookup && - currentAccountLookup.status !== "checking" && - currentAccountLookup.status !== "not_found", - ) + const mcpUrl = "https://mcp.supermemory.ai/mcp" return ( - // 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", - )} - /> - - - {hasDetectedAccount && detected !== "resume" && ( - - {isCheckingAccount ? ( - - ) : canSubmit ? ( - - handleSubmit(detected as "x" | "linkedin") - } - className="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%)", - }} - > - - - ) : null} - - )} - -
- - - {detected && detected !== "resume" && ( - - {currentAccountLookup?.status === "found" && ( - - )} - {currentAccountLookup?.status === "not_found" && ( - - )} - {currentAccountLookup?.status === "error" && ( - - )} - - {currentAccountLookup?.message ?? - 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}

-
- - -
-
- )} - -
-
+ {step === "about" && ( + + )} + {step === "sources" && ( + + )} + {step === "ingest" && } + {step === "team" && ( + router.push("/settings/billing")} + /> + )} + ) } diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 5c88b506..82948b2a 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -25,6 +25,7 @@ import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" import { RaycastDetail } from "@/components/integrations/raycast-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" import { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" import { DocumentsCommandPalette } from "@/components/documents-command-palette" @@ -584,6 +585,7 @@ export default function NewPage() { return ( +
+}[] = [ + { + label: "Mon", + values: { + cursor: 8, + claudeCode: 4, + claudeDesktop: 3, + chatgpt: 2, + other: 1, + }, + }, + { + label: "Tue", + values: { + cursor: 10, + claudeCode: 6, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Wed", + values: { + cursor: 12, + claudeCode: 9, + claudeDesktop: 5, + chatgpt: 4, + other: 1, + }, + }, + { + label: "Thu", + values: { + cursor: 9, + claudeCode: 5, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Fri", + values: { + cursor: 14, + claudeCode: 10, + claudeDesktop: 6, + chatgpt: 4, + other: 2, + }, + }, + { + label: "Sat", + values: { + cursor: 5, + claudeCode: 3, + claudeDesktop: 2, + chatgpt: 1, + other: 1, + }, + }, + { + label: "Sun", + values: { + cursor: 17, + claudeCode: 12, + claudeDesktop: 7, + chatgpt: 4, + other: 1, + }, + }, +] + +export const RECENT_ANSWERS = [ + { + question: "What did we decide about pricing?", + answer: "Pro stays at $20/mo. No overages, top-up only.", + who: "You", + when: "2h ago", + }, + { + question: "What's blocking the Acme deal?", + answer: "MSA review — Jane drafting a net-30 counter.", + who: "You", + when: "4h ago", + }, + { + question: "Who owns the onboarding rewrite?", + answer: "Design team. Ship target Friday.", + who: "Sarah Kim", + when: "Yesterday", + }, +] + +export const WEEKLY_DIGEST = { + range: "Mon – Fri", + headline: + "Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.", + bullets: [ + { label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" }, + { + label: "2 conflicts surfaced between Notion and Slack", + tint: "text-[#FF8A47]", + }, + { + label: "Sarah Kim led contributions with 14 docs", + tint: "text-[#A1A1AA]", + }, + ], +} + +export const TEAM_THIS_WEEK = [ + { + name: "Sarah Kim", + email: "sarah@acme.com", + contributions: 14, + summary: "14 docs · 3 spaces", + color: "#FF8A47", + }, + { + name: "Jane Doe", + email: "jane@acme.com", + contributions: 11, + summary: "11 docs · 2 spaces", + color: "#4BA0FA", + }, + { + name: "Mahesh S.", + email: "mahesh@acme.com", + contributions: 9, + summary: "9 docs · 1 space", + color: "#A1A1AA", + }, + { + name: "Alex Chen", + email: "alex@acme.com", + contributions: 7, + summary: "7 docs · 2 spaces", + color: "#10A37F", + }, +] + +export const TEAM_RECENT = [ + { who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" }, + { who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" }, + { who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" }, + { + who: "Sarah Kim", + what: "connected Notion to Sales space", + when: "Yesterday", + }, +] + +export type ActivityEvent = { + when: string + text: string + source: "drive" | "notion" | "slack" | "system" | "answer" | "decision" +} + +export const ACTIVITY: ActivityEvent[] = [ + { when: "2m", text: "23 new docs ingested from Drive", source: "drive" }, + { when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" }, + { + when: "1h", + text: "Decision surfaced: pricing locked at $20 Pro", + source: "slack", + }, + { + when: "3h", + text: "14 Notion pages updated by Sarah Kim", + source: "notion", + }, + { when: "5h", text: "New entity formed: pricing-v3", source: "system" }, + { + when: "Yesterday", + text: "MSA contract uploaded · auto-tagged contracts/MSA", + source: "drive", + }, +] + +export type TimelineKind = + | "answer" + | "decision" + | "ingest" + | "mention" + | "system" + +export type TimelineEvent = { + kind: TimelineKind + when: string + title: string + detail?: string + source?: string + who?: string +} + +export const TIMELINE_EVENTS: TimelineEvent[] = [ + { + kind: "answer", + when: "2m ago", + title: "What did we decide about pricing?", + detail: "Pro stays at $20/mo. No overages, top-up only.", + source: "You asked", + }, + { + kind: "ingest", + when: "14m ago", + title: "23 new docs ingested from Drive", + detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.", + source: "Drive", + }, + { + kind: "decision", + when: "1h ago", + title: "Decision: pricing locked at $20 Pro", + detail: + "Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.", + source: "Slack · #leadership", + }, + { + kind: "answer", + when: "4h ago", + title: "What's blocking the Acme deal?", + detail: "MSA review — Jane drafting a net-30 counter.", + source: "You asked", + }, + { + kind: "mention", + when: "Today", + title: "onboarding-rewrite mentioned in 3 docs", + detail: "Owner: design team · Ship target: Friday.", + source: "Drive", + who: "Mahesh", + }, + { + kind: "system", + when: "5h ago", + title: "New entity formed: pricing-v3", + detail: "Linked across 4 sources · auto-tagged by the brain.", + }, + { + kind: "ingest", + when: "Yesterday", + title: "MSA contract uploaded · auto-tagged contracts/MSA", + source: "Drive", + }, + { + kind: "answer", + when: "Yesterday", + title: "Who owns the onboarding rewrite?", + detail: "Design team. Ship target Friday.", + source: "Sarah Kim asked", + }, + { + kind: "ingest", + when: "Yesterday", + title: "14 Notion pages updated by Sarah Kim", + source: "Notion", + who: "Sarah Kim", + }, +] + +export type SourceStatus = "healthy" | "syncing" | "warning" | "error" + +export type ConnectedSource = { + id: string + name: string + iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github" + status: SourceStatus + delta: string + when: string + statusMessage?: string +} + +export const CONNECTED_SOURCES: ConnectedSource[] = [ + { + id: "drive", + name: "Google Drive", + iconKey: "drive", + status: "healthy", + delta: "+24 items", + when: "12m ago", + }, + { + id: "notion", + name: "Company Notion", + iconKey: "notion", + status: "warning", + delta: "Sync stalled", + when: "4h ago", + statusMessage: "Re-auth · token expired", + }, + { + id: "gmail", + name: "Gmail", + iconKey: "gmail", + status: "syncing", + delta: "Ingesting…", + when: "Just now", + }, +] + +export type RecommendedSource = { + id: string + name: string + iconKey: "slack" | "linear" | "granola" | "github" | "calendar" + reason: string +} + +export const RECOMMENDED_SOURCES: RecommendedSource[] = [ + { + id: "slack", + name: "Slack", + iconKey: "slack", + reason: "Decisions and threads — most teams connect this.", + }, + { + id: "github", + name: "GitHub", + iconKey: "github", + reason: "PRs, issues, READMEs from your repos.", + }, + { + id: "linear", + name: "Linear", + iconKey: "linear", + reason: "Track project decisions and blockers.", + }, +] + +export type LiveSession = { + name: string + avatarColor: string + status: "live" | "idle" | "away" + tool: string + toolColor: string + doing?: string + elapsed?: string +} + +export const LIVE_SESSIONS: LiveSession[] = [ + { + name: "Sarah Kim", + avatarColor: "#FF8A47", + status: "live", + tool: "Cursor", + toolColor: "#4BA0FA", + doing: "Refactoring auth flow with brain context", + elapsed: "3m elapsed", + }, + { + name: "Jane Doe", + avatarColor: "#4BA0FA", + status: "live", + tool: "Claude Code", + toolColor: "#FF8A47", + doing: "MSA review · pulling Acme history", + elapsed: "18m elapsed", + }, + { + name: "You", + avatarColor: "#A1A1AA", + status: "idle", + tool: "Cursor", + toolColor: "#4BA0FA", + elapsed: "Idle · 12m", + }, +] + +export const TODAY_SESSIONS_SUMMARY: { + name: string + avatarColor: string + tool: string + count: number + lastAt: string +}[] = [ + { + name: "Alex Chen", + avatarColor: "#10A37F", + tool: "ChatGPT", + count: 4, + lastAt: "2h ago", + }, + { + name: "Mahesh S.", + avatarColor: "#A1A1AA", + tool: "Claude Desktop", + count: 7, + lastAt: "3h ago", + }, + { + name: "Sarah Kim", + avatarColor: "#FF8A47", + tool: "Cursor", + count: 11, + lastAt: "Now", + }, +] + +export type ChatMessage = { role: "user" | "brain"; text: string } + +export const SAMPLE_CHAT: ChatMessage[] = [ + { + role: "user", + text: "What did we decide about pricing this quarter?", + }, + { + role: "brain", + text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.", + }, + { + role: "user", + text: "Who's working on the Acme MSA?", + }, + { + role: "brain", + text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).", + }, +] diff --git a/apps/web/components/brain-home/widgets.tsx b/apps/web/components/brain-home/widgets.tsx new file mode 100644 index 00000000..69ff83f3 --- /dev/null +++ b/apps/web/components/brain-home/widgets.tsx @@ -0,0 +1,671 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { GoogleDrive, Notion } from "@ui/assets/icons" +import { + AlertCircle, + ArrowRight, + Calendar, + CheckCircle2, + FileText, + Github, + Loader2, + MessageSquare, + Plus, + Quote, + Sparkles, + Target, +} from "lucide-react" +import { + ACTIVITY, + AGENT_SERIES, + CONNECTED_SOURCES, + RECENT_ANSWERS, + RECOMMENDED_SOURCES, + SESSIONS_BY_DAY, + STATS, + TEAM_RECENT, + TEAM_THIS_WEEK, + WEEKLY_DIGEST, + type ActivityEvent, + type ConnectedSource, + type RecommendedSource, + type Stat, +} from "./data" + +function GmailIcon({ className }: { className?: string }) { + return ( + + ) +} + +export const modalCardStyle = { + boxShadow: + "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", +} + +export const inputBevelStyle = { + boxShadow: + "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", +} + +export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) { + return ( +
+ {stats.map((s) => ( +
+

+ {s.label} +

+

+ {s.value} +

+ {s.delta && ( +

+ {s.delta} +

+ )} +
+ ))} +
+ ) +} + +export function SectionHeader({ title, cta }: { title: string; cta?: string }) { + return ( +
+

+ {title} +

+ {cta && ( + + )} +
+ ) +} + +export function RecentAnswersCard() { + return ( +
+
+

+ Recent answers +

+ +
+
    + {RECENT_ANSWERS.map((a) => ( +
  • +

    + {a.question} +

    +

    + → {a.answer} +

    +

    + {a.who} · {a.when} +

    +
  • + ))} +
+
+ ) +} + +export function WeeklyDigestCard() { + return ( +
+
+
+

+ This week +

+

+ {WEEKLY_DIGEST.range} +

+
+

+ {WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) => + /^\d+$/.test(piece) ? ( + + {piece} + + ) : ( + {piece} + ), + )} +

+
    + {WEEKLY_DIGEST.bullets.map((b) => ( +
  • + + + {b.label} + +
  • + ))} +
+ +
+ ) +} + +export function BrainPulseBlock() { + return ( +
+ + +
+ ) +} + +export function BrainActivityChart() { + const totalsByAgent = AGENT_SERIES.map((s) => ({ + ...s, + total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0), + })) + const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0) + const dayTotals = SESSIONS_BY_DAY.map((d) => + AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0), + ) + const max = Math.max(...dayTotals) + return ( +
+
+
+
+

+ Sessions by agent +

+

+ {grandTotal} agent sessions, last 7 days — which tool the team + reached for. +

+
+
+ {totalsByAgent.map((s) => ( + + + {s.label}{" "} + {s.total} + + ))} +
+
+
+ {SESSIONS_BY_DAY.map((d, dayIdx) => { + const BAR_AREA_PX = 140 + const totalForDay = dayTotals[dayIdx] ?? 0 + const barHeightPx = Math.max( + 6, + Math.round((totalForDay / max) * BAR_AREA_PX), + ) + return ( +
+
+
+ {AGENT_SERIES.map((series, sIdx) => { + const val = d.values[series.key] ?? 0 + if (val === 0) return null + const segHeight = + totalForDay > 0 ? (val / totalForDay) * 100 : 0 + return ( +
+ ) + })} +
+
+ + {d.label} + +
+ ) + })} +
+
+ ) +} + +function activitySourceIcon(s: ActivityEvent["source"]) { + if (s === "drive") return + if (s === "notion") return + if (s === "slack") + return + if (s === "answer") return + if (s === "decision") + return + return +} + +export function ActivityFeedBlock() { + return ( +
+ +
+ {ACTIVITY.map((a, i) => ( +
+
+ {activitySourceIcon(a.source)} +
+

+ {a.text} +

+ + {a.when} + +
+ ))} +
+
+ ) +} + +export function TeamActivityRail() { + return ( +
+
+

+ Team activity +

+ + This week + +
+

+ What your team's been adding to the brain. +

+
+ {TEAM_THIS_WEEK.map((member) => ( +
+
+ {member.name[0]} +
+
+

+ {member.name} +

+

+ {member.summary} +

+
+ + {member.contributions} + +
+ ))} +
+
+

+ Recent +

+
    + {TEAM_RECENT.map((event, i) => ( +
  • + {event.who} {event.what} + · {event.when} +
  • + ))} +
+
+ +
+ ) +} + +export function BrainHomeBackground() { + return ( +
+
+
+
+
+ ) +} + +export function Quotation({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ) +} + +function connectedSourceIcon(key: ConnectedSource["iconKey"]) { + if (key === "drive") return + if (key === "notion") return + if (key === "gmail") return + if (key === "github") return + return +} + +function sourceStatusMeta(status: ConnectedSource["status"]) { + if (status === "healthy") + return { + dot: "bg-[#4BA0FA]", + tint: "text-[#4BA0FA]", + icon: , + } + if (status === "syncing") + return { + dot: "bg-[#A1A1AA]", + tint: "text-[#A1A1AA]", + icon: , + } + if (status === "warning") + return { + dot: "bg-[#FF8A47]", + tint: "text-[#FF8A47]", + icon: , + } + return { + dot: "bg-[#FF5C5C]", + tint: "text-[#FF5C5C]", + icon: , + } +} + +export function SourcesHealthRail({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+

+ Sources health +

+

+ Nothing connected yet — Drive, Notion, Gmail and more are ready when + you are. +

+ +
+ ) + } + const issueCount = CONNECTED_SOURCES.filter( + (s) => s.status === "warning" || s.status === "error", + ).length + return ( +
+
+

+ Sources health +

+ {issueCount > 0 ? ( + + {issueCount} need attention + + ) : ( + + All healthy + + )} +
+

+ Is the brain getting fed? +

+ +
    + {CONNECTED_SOURCES.map((s) => { + const meta = sourceStatusMeta(s.status) + return ( +
  • +
    + {connectedSourceIcon(s.iconKey)} +
    +
    +
    +

    + {s.name} +

    + + {s.when} + +
    +

    + {meta.icon} + {s.statusMessage ?? s.delta} +

    +
    +
  • + ) + })} +
+
+ ) +} + +function recommendedIcon(key: RecommendedSource["iconKey"]) { + if (key === "slack") + return + if (key === "github") return + if (key === "linear") return + if (key === "granola") return + if (key === "calendar") return + return +} + +export function RecommendedRail() { + return ( +
+

+ Add more to the brain +

+

+ Teams like yours connect these next. +

+ +
    + {RECOMMENDED_SOURCES.map((r) => ( +
  • +
    + {recommendedIcon(r.iconKey)} +
    +
    +
    +

    + {r.name} +

    + +
    +

    + {r.reason} +

    +
    +
  • + ))} +
+
+ ) +} diff --git a/apps/web/components/onboarding-brain/onboarding-confetti.tsx b/apps/web/components/onboarding-brain/onboarding-confetti.tsx new file mode 100644 index 00000000..2aac5ac6 --- /dev/null +++ b/apps/web/components/onboarding-brain/onboarding-confetti.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useQueryState } from "nuqs" + +const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"] + +export function OnboardingConfetti() { + const [onboarded, setOnboarded] = useQueryState("onboarded") + const fired = useRef(false) + + useEffect(() => { + if (onboarded !== "1" || fired.current) return + fired.current = true + setOnboarded(null) + + const reduceMotion = window.matchMedia?.( + "(prefers-reduced-motion: reduce)", + ).matches + if (reduceMotion) return + + let raf = 0 + const run = async () => { + const confetti = (await import("canvas-confetti")).default + const end = Date.now() + 1400 + const frame = () => { + confetti({ + particleCount: 4, + angle: 60, + spread: 70, + startVelocity: 55, + origin: { x: 0, y: 0.7 }, + colors: COLORS, + }) + confetti({ + particleCount: 4, + angle: 120, + spread: 70, + startVelocity: 55, + origin: { x: 1, y: 0.7 }, + colors: COLORS, + }) + if (Date.now() < end) raf = requestAnimationFrame(frame) + } + frame() + } + run() + + return () => cancelAnimationFrame(raf) + }, [onboarded, setOnboarded]) + + return null +} diff --git a/apps/web/components/onboarding-brain/shell.tsx b/apps/web/components/onboarding-brain/shell.tsx new file mode 100644 index 00000000..9b2d9b30 --- /dev/null +++ b/apps/web/components/onboarding-brain/shell.tsx @@ -0,0 +1,137 @@ +"use client" + +import { LogoFull } from "@ui/assets/Logo" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { motion } from "motion/react" +import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types" + +interface ShellProps { + step: BrainStep + domain?: string | null + children: React.ReactNode +} + +export function BrainShell({ step, children }: ShellProps) { + const visibleSteps: BrainStep[] = BRAIN_STEPS + + return ( +
+
+ +
+ +
+ + + +
+ +
+
{children}
+
+
+ ) +} + +function StepIndicator({ + step, + visibleSteps, +}: { + step: BrainStep + visibleSteps: BrainStep[] +}) { + const currentIdx = visibleSteps.indexOf(step) + return ( +
+ {visibleSteps.map((s, i) => { + const isDone = i < currentIdx + const isCurrent = i === currentIdx + const isLast = i === visibleSteps.length - 1 + return ( +
+
+ + + {BRAIN_STEP_LABELS[s]} + +
+ {!isLast && ( +
+ +
+ )} +
+ ) + })} +
+ ) +} + +function StepDot({ done, current }: { done: boolean; current: boolean }) { + if (current) { + return ( +
+ + +
+ ) + } + if (done) { + return ( +
+ +
+ ) + } + return ( +
+ +
+ ) +} diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx new file mode 100644 index 00000000..5d8ff4f2 --- /dev/null +++ b/apps/web/components/onboarding-brain/step-about.tsx @@ -0,0 +1,473 @@ +"use client" + +import { useEffect, useState } from "react" +import { motion } from "motion/react" +import { Button } from "@ui/components/button" +import { Input } from "@ui/components/input" +import { Textarea } from "@ui/components/textarea" +import { + ArrowRight, + Brain, + Building2, + LayoutGrid, + Loader2, + Plug, + Terminal, + User2, + UserPlus, + Users2, +} from "lucide-react" +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import type { BrainMode } from "./types" + +export interface AboutValues { + name: string + about: string + workspaceName: string + workspaceDomain: string +} + +interface Props { + mode: BrainMode + onModeChange: (m: BrainMode) => void + domain: string | null + suggestedWorkspaceName: string + defaultName: string + avatarUrl: string | null + values: AboutValues + onChange: (next: AboutValues) => void + onContinue: () => void + submitting?: boolean +} + +const cardSurfaceStyle = { + boxShadow: + "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", +} + +const inputBevelStyle = { + boxShadow: + "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", +} + +const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]" +const inputClass = + "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors" + +export function StepAbout({ + mode, + onModeChange, + domain, + suggestedWorkspaceName, + defaultName, + avatarUrl, + values, + onChange, + onContinue, + submitting, +}: Props) { + // biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available + useEffect(() => { + const patch: Partial = {} + if (!values.name && defaultName) patch.name = defaultName + if (!values.workspaceName && suggestedWorkspaceName) { + patch.workspaceName = suggestedWorkspaceName + } + if (!values.workspaceDomain && domain) { + patch.workspaceDomain = domain + } + if (Object.keys(patch).length > 0) onChange({ ...values, ...patch }) + }, [defaultName, suggestedWorkspaceName, domain]) + + const canContinue = + values.name.trim().length > 0 && values.workspaceName.trim().length > 0 + + return ( +
+
+
+ +

+ Tell us about you +

+

+ So your brain sounds like yours, not the docs. +

+ +
+
+

Your name

+ onChange({ ...values, name: e.target.value })} + placeholder="e.g. Mahesh" + className={inputClass} + style={inputBevelStyle} + /> +
+ +
+

+ What are you here for?{" "} + (optional) +

+