From 7c2bd19cd86c4b9befd9081b2a7465e695c12080 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:29:38 +0000 Subject: [PATCH 1/2] Update integrations info modal (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace percard Docs links with hover only info buttons and card click info modals - Add doc backed use cases inside setupstyle info modals, with Docs access and the same card action in the footer - Track all info modal close behavior through one analytics event with a close reason - Keep Active/Connected status in the card footer, align the info icon to the card action margin, and fix the Settings modal Integrations linkout

Normal no hovering

Screenshot 2026-06-09 at 1 21 38 PM

On hovering

Screenshot 2026-06-09 at 1 21 23 PM Screenshot 2026-06-09 at 1 34 43 PM

Info modal

Screenshot 2026-06-09 at 1 22 29 PM Screenshot 2026-06-09 at 1 22 46 PM --- apps/web/components/integrations-view.tsx | 571 +++++++++++++++--- .../components/settings/settings-content.tsx | 5 +- .../components/settings/settings-modal.tsx | 1 - apps/web/lib/analytics.ts | 6 + apps/web/lib/plugin-catalog.ts | 10 +- 5 files changed, 498 insertions(+), 95 deletions(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 97466f356..566f0a36a 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" -import { dmSans125ClassName } from "@/lib/fonts" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import { hasActivePlan } from "@lib/queries" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" @@ -23,6 +23,7 @@ import { ArrowRight, BookOpen, Check, + Info, Loader, Plus, Search, @@ -338,7 +339,7 @@ const SECTIONS: Array<{ className="size-6 rounded object-contain" /> ), - docsUrl: "https://docs.supermemory.ai/supermemory-mcp/introduction", + docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction", })), }, { @@ -381,6 +382,7 @@ const SECTIONS: Array<{ simpleTitle: "Your Docs, Sheets and Slides, searchable", icon: , pro: true, + docsUrl: "https://supermemory.ai/docs/connectors/google-drive", }, { kind: "connector", @@ -391,6 +393,7 @@ const SECTIONS: Array<{ simpleTitle: "All your Notion pages, in supermemory", icon: , pro: true, + docsUrl: "https://supermemory.ai/docs/connectors/notion", }, { kind: "connector", @@ -401,6 +404,7 @@ const SECTIONS: Array<{ simpleTitle: "Your OneDrive files, ready to recall", icon: , pro: true, + docsUrl: "https://supermemory.ai/docs/connectors/onedrive", }, ], }, @@ -531,22 +535,369 @@ function IconBox({ children }: { children: ReactNode }) { ) } -function DocsLink({ href }: { href: string }) { +type InfoUseCase = { + title: string + description: string +} + +type InfoModalCloseReason = Parameters< + typeof analytics.integrationInfoModalClosed +>[0]["close_reason"] + +const MCP_INFO_USE_CASES: InfoUseCase[] = [ + { + title: "Persistent assistant memory", + description: + "Store useful context during conversations and recall it later from this MCP client.", + }, + { + title: "Shared context across tools", + description: + "Use the same Supermemory account across MCP-compatible clients so memory follows the user between sessions.", + }, + { + title: "Profiles and project context", + description: + "Bring user profiles and project-scoped memories into supported AI clients when they need context.", + }, +] + +const ITEM_INFO_USE_CASES: Record = { + "plugin-claude_code": [ + { + title: "Session context injection", + description: + "Fetch relevant project memories, user preferences, and past interactions when Claude Code starts a session.", + }, + { + title: "Automatic coding capture", + description: + "Save useful tool activity like edits, new files, shell commands, and spawned tasks for future sessions.", + }, + ], + "plugin-codex": [ + { + title: "Recall before each prompt", + description: + "Inject relevant memories and profile context into Codex before each prompt.", + }, + { + title: "Capture after sessions", + description: + "Store conversation transcripts after a session, scoped to the current project and user.", + }, + { + title: "Explicit memory skills", + description: + "Use supermemory-search, supermemory-save, and supermemory-forget when memory needs direct control.", + }, + ], + "plugin-opencode": [ + { + title: "Project memory in OpenCode", + description: + "Inject preferences, project knowledge, and past interactions at the start of OpenCode sessions.", + }, + { + title: "Smart session capture", + description: + "Save memories from explicit phrases like remember or save this, and summarize long sessions during compaction.", + }, + ], + "plugin-openclaw": [ + { + title: "Memory across messaging channels", + description: + "Give OpenClaw memory across WhatsApp, Telegram, Discord, Slack, iMessage, and other channels.", + }, + { + title: "Auto-recall and auto-capture", + description: + "Inject relevant memories before AI turns and store conversation exchanges after turns.", + }, + { + title: "Direct memory tools", + description: + "Let the AI store, search, forget, and inspect profile memories during conversations.", + }, + ], + "plugin-hermes": [ + { + title: "Semantic memory for Hermes", + description: + "Add long-term memory, profile recall, search, and session-aware ingest to Hermes.", + }, + { + title: "Turn and session memory", + description: + "Prefetch relevant context before turns, capture completed turns, and ingest full sessions for richer graph updates.", + }, + { + title: "Organized containers", + description: + "Use profile-scoped memory and optional multi-container tags for work, personal, or project-specific context.", + }, + ], + "google-drive": [ + { + title: "Scoped Drive sync", + description: + "Sync selected Google Docs, Sheets, Slides, and PDFs after OAuth and the hosted file picker.", + }, + { + title: "Fresh knowledge base", + description: + "Keep selected Drive files updated in Supermemory, with scheduled and manual import support.", + }, + ], + notion: [ + { + title: "Workspace knowledge sync", + description: + "Sync Notion pages, databases, and blocks into Supermemory from connected workspaces.", + }, + { + title: "Rich Notion context", + description: + "Preserve rich formatting and database properties so Notion content remains useful for retrieval.", + }, + ], + onedrive: [ + { + title: "Microsoft 365 documents", + description: + "Sync Word documents, Excel spreadsheets, and PowerPoint presentations from OneDrive.", + }, + { + title: "Personal and business accounts", + description: + "Connect personal or business OneDrive accounts and keep Office files updated through sync.", + }, + ], + chrome: [ + { + title: "Save from the browser", + description: + "Capture webpages into Supermemory while browsing instead of manually copying content.", + }, + { + title: "Bring bookmarks into memory", + description: + "Import saved browser context so it can be searched and reused later.", + }, + ], + shortcuts: [ + { + title: "Quick mobile capture", + description: + "Add memories from iPhone, iPad, or Mac through Apple Shortcuts.", + }, + { + title: "Save without opening the app", + description: + "Send useful snippets and links into Supermemory from native Apple workflows.", + }, + ], + raycast: [ + { + title: "Fast desktop capture", + description: + "Add memories from Raycast on Mac without leaving the launcher.", + }, + { + title: "Search from Raycast", + description: + "Look up Supermemory content directly from your desktop command bar.", + }, + ], + "x-bookmarks": [ + { + title: "Import saved X posts", + description: + "Turn X/Twitter bookmarks into searchable Supermemory memories.", + }, + { + title: "Reuse social research", + description: + "Bring bookmarked threads, references, and ideas into the same memory layer as your other tools.", + }, + ], +} + +function getInfoUseCases(id: string): InfoUseCase[] { + return ITEM_INFO_USE_CASES[id] ?? MCP_INFO_USE_CASES +} + +function ItemInfoButton({ + name, + onClick, +}: { + name: string + onClick: () => void +}) { return ( - e.stopPropagation()} + + ) +} + +function ItemInfoDialog({ + actionSlot, + docsUrl, + icon, + id, + kind, + name, + onOpenChange, + open, +}: { + actionSlot: ReactNode + docsUrl?: string + icon: ReactNode + id: string + kind: ItemKind + name: string + onOpenChange: (open: boolean) => void + open: boolean +}) { + const useCases = getInfoUseCases(id) + const closeWithReason = (closeReason: InfoModalCloseReason) => { + analytics.integrationInfoModalClosed({ + kind, + id, + name, + close_reason: closeReason, + }) + onOpenChange(false) + } + return ( + { + if (nextOpen) { + onOpenChange(true) + return + } + closeWithReason("dismiss") + }} + > + e.stopPropagation()} + style={{ + 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", + }} + className={cn( + dmSans125ClassName(), + "flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]", + )} + > + {name} use cases and docs +
+ {icon} +
+

+ {name} +

+

+ Use cases that apply to this Supermemory connection. +

+
+
+ {docsUrl && ( + + Docs + + )} + +
+
+
+
+
+ {useCases.map((useCase, index) => ( +
+
+ + {index + 1} + + {index < useCases.length - 1 && ( + + )} +
+
+

+ {useCase.title} +

+

+ {useCase.description} +

+
+
+ ))} +
+
+
+
+ + {/* biome-ignore lint/a11y/noStaticElementInteractions: closes the info dialog after the nested action button runs. */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handling stays on the nested real button. */} +
closeWithReason("action")}>{actionSlot}
+
+
+
) } @@ -629,43 +980,61 @@ function ConnectionsCountPill({ count }: { count: number }) { } function ItemCard({ + actionSlot, icon, + id, + kind, name, tagline, pro, isNew, docsUrl, leftIndicator, - rightSlot, + statusSlot, }: { + actionSlot: ReactNode icon: ReactNode + id: string + kind: ItemKind name: string tagline: string pro?: boolean isNew?: boolean docsUrl?: string leftIndicator?: ReactNode - rightSlot: ReactNode + statusSlot?: ReactNode }) { + const [infoOpen, setInfoOpen] = useState(false) return ( + // biome-ignore lint/a11y/useSemanticElements: the card contains nested action buttons, so it cannot be a native button.
setInfoOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setInfoOpen(true) + } + }} className={cn( - "flex h-full flex-col gap-4 rounded-[12px] bg-[#14161A] p-4 transition-colors hover:bg-[#16181D]", + "group relative flex h-full cursor-pointer flex-col gap-4 rounded-[12px] bg-[#14161A] p-4 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]", )} > + setInfoOpen(true)} /> +
{icon} - {docsUrl && ( - // biome-ignore lint/a11y/noStaticElementInteractions: wrapper to stop event propagation -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - -
- )}
@@ -691,7 +1060,24 @@ function ItemCard({ {tagline}

-
{rightSlot}
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */} +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {statusSlot} +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */} +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {actionSlot} +
+
) @@ -1413,7 +1799,7 @@ export function IntegrationsView() { className="object-contain" /> ), - docsUrl: "https://docs.supermemory.ai/supermemory-mcp/introduction", + docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction", ctaLabel: "Connect", onCta: () => { void setMcpClient(null) @@ -1444,7 +1830,7 @@ export function IntegrationsView() { className="object-contain" /> ), - docsUrl: "https://docs.supermemory.ai/integrations/claude-code", + docsUrl: "https://supermemory.ai/docs/integrations/claude-code", ctaLabel: claudeCodeConnected ? "Active" : claudeCodeNeedsPro @@ -1502,46 +1888,35 @@ export function IntegrationsView() { switch (item.kind) { case "plugin": { const activeKey = activePluginById.get(item.pluginId) - const activeCount = activeCountByPlugin.get(item.pluginId) ?? 0 const needsProUpgrade = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId) if (activeKey) { const busy = connectingPlugin === item.pluginId return ( -
- { - trackCard(item) - setConnectedPluginId(item.pluginId) - }} - /> - -
+ ) } if (setupPluginIds.has(item.pluginId)) { @@ -1585,24 +1960,21 @@ export function IntegrationsView() { const needsProUpgrade = !isAutumnLoading && !hasProProduct if (count > 0) { return ( -
- - -
+ ) } if (needsProUpgrade) { @@ -1692,17 +2064,46 @@ export function IntegrationsView() { } } + const renderStatus = (item: Item): ReactNode => { + switch (item.kind) { + case "plugin": { + const activeKey = activePluginById.get(item.pluginId) + if (!activeKey) return null + return ( + { + trackCard(item) + setConnectedPluginId(item.pluginId) + }} + /> + ) + } + case "connector": { + const count = connectionsByProvider[item.provider].length + if (count <= 0) return null + return + } + default: + return null + } + } + const renderItemCard = (item: Item) => ( ) @@ -2194,7 +2595,7 @@ export function IntegrationsView() {
void className?: string showIdentity?: boolean - onClose?: () => void }) { const { user, org } = useAuth() const router = useRouter() @@ -159,8 +157,7 @@ export function SettingsContent({ } const handleIntegrations = () => { - router.push("/?view=integrations") - onClose?.() + void router.push("/?view=integrations") } const handleDeleteAccount = async () => { diff --git a/apps/web/components/settings/settings-modal.tsx b/apps/web/components/settings/settings-modal.tsx index ee4a57843..89c52a3f7 100644 --- a/apps/web/components/settings/settings-modal.tsx +++ b/apps/web/components/settings/settings-modal.tsx @@ -123,7 +123,6 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) { activeTab={tab} onTabChange={handleTabChange} showIdentity={false} - onClose={() => setParam(null)} className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6" /> diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index f9f1dfeeb..ea2742aa8 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -50,6 +50,12 @@ export const analytics = { // integrations surface (main Nova page) integrationCardClicked: (props: { kind: string; id: string; name: string }) => safeCapture("integration_card_clicked", props), + integrationInfoModalClosed: (props: { + kind: string + id: string + name: string + close_reason: "dismiss" | "close_button" | "im_good" | "action" + }) => safeCapture("integration_info_modal_closed", props), nextAppResearchCtaDismissed: () => safeCapture("next_app_research_cta_dismissed"), diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts index 0c69d57bb..e1a4ffdee 100644 --- a/apps/web/lib/plugin-catalog.ts +++ b/apps/web/lib/plugin-catalog.ts @@ -34,7 +34,7 @@ export const PLUGIN_CATALOG: Record = { name: "Claude Code", tagline: "Remembers your conventions, decisions, and project context", icon: "/images/plugins/claude-code.svg", - docsUrl: "https://docs.supermemory.ai/integrations/claude-code", + docsUrl: "https://supermemory.ai/docs/integrations/claude-code", installSteps: [ { title: "Save your API key", @@ -56,7 +56,7 @@ export const PLUGIN_CATALOG: Record = { name: "Codex", tagline: "Persistent memory for the Codex CLI — free on every plan", icon: "/images/plugins/codex.png", - docsUrl: "https://docs.supermemory.ai/integrations/codex", + docsUrl: "https://supermemory.ai/docs/integrations/codex", githubUrl: "https://github.com/supermemoryai/codex-supermemory", installSteps: [ { @@ -79,7 +79,7 @@ export const PLUGIN_CATALOG: Record = { name: "OpenCode", tagline: "Long-term memory for your OpenCode sessions", icon: "/images/plugins/opencode.svg", - docsUrl: "https://docs.supermemory.ai/integrations/opencode", + docsUrl: "https://supermemory.ai/docs/integrations/opencode", githubUrl: "https://github.com/supermemoryai/opencode-supermemory", usesOAuth: true, installSteps: [ @@ -108,7 +108,7 @@ export const PLUGIN_CATALOG: Record = { name: "OpenClaw", tagline: "Cross-platform memory across Telegram, Discord, Slack", icon: "/images/plugins/openclaw.svg", - docsUrl: "https://docs.supermemory.ai/integrations/openclaw", + docsUrl: "https://supermemory.ai/docs/integrations/openclaw", installSteps: [ { title: "Install the plugin", @@ -128,7 +128,7 @@ export const PLUGIN_CATALOG: Record = { name: "Hermes", tagline: "Persistent memory for the Hermes agent — free on every plan", icon: "/images/plugins/hermes.svg", - docsUrl: "https://docs.supermemory.ai/integrations/hermes", + docsUrl: "https://supermemory.ai/docs/integrations/hermes", installSteps: [ { title: "Run Hermes memory setup", 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 2/2] 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 | 1565 +++-------------- 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, 4581 insertions(+), 1320 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 000000000..187ed2307 --- /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 3d67b3bae..42bfce24b 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 { + StepAbout, + type AboutValues, +} from "@/components/onboarding-brain/step-about" import { - CHROME_EXTENSION_URL, - RAYCAST_EXTENSION_URL, - ADD_MEMORY_SHORTCUT_URL, -} from "@repo/lib/constants" + StepSources, + type SourcesValues, +} from "@/components/onboarding-brain/step-sources" +import { StepIngest } from "@/components/onboarding-brain/step-ingest" import { - ChromeIcon, - AppleShortcutsIcon, - RaycastIcon, -} from "@/components/integration-icons" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" + StepTeam, + type TeamValues, +} from "@/components/onboarding-brain/step-team" import { - Sparkles, - ChevronLeft, - ChevronRight, - AlertCircle, - CheckCircle2, - Loader2, -} from "lucide-react" -import { analytics, type OnboardingStep } from "@/lib/analytics" -import { consumePendingConnectUrl } from "@/lib/constants" + BRAIN_STEPS, + type BrainMetadata, + type BrainMode, + type BrainStep, + containerTagFromWorkspace, + detectModeFromEmail, + generateOrgSlug, + generateUsername, + workspaceDomainFromEmail, + workspaceNameFromEmail, +} from "@/components/onboarding-brain/types" + +const STORAGE_KEY = "supermemory-brain-onboarding-v1" + +export default function BrainOnboardingPage() { + const router = useRouter() + const params = useSearchParams() + const { user, org, organizations, setActiveOrg, refetchOrganizations } = + useAuth() -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 stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about" + const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl) + ? stepFromUrl + : "about" -function XIcon({ className }: { className?: string }) { - return ( - - ) -} + const [step, setStep] = useState(initialStep) -function LinkedInIcon({ className }: { className?: string }) { - return ( - + const detectedMode = useMemo( + () => detectModeFromEmail(user?.email), + [user?.email], ) -} - -function SubmitArrow() { - return ( - - Submit - - + const suggestedWorkspaceName = useMemo( + () => workspaceNameFromEmail(user?.email), + [user?.email], ) -} - -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 domain = useMemo( + () => workspaceDomainFromEmail(user?.email), + [user?.email], ) -} - -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) + 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(() => { - 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.`, - }) + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return + const cached = JSON.parse(raw) as { + mode?: BrainMode + about?: AboutValues + sources?: SourcesValues + team?: TeamValues } - }, 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]) -} + 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 {} + }, []) -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() { - 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 skippingRef = useRef(false) - const completedTrackedRef = useRef(false) - const [isSkipping, setIsSkipping] = useState(false) - const [spotlightCategory, setSpotlightCategory] = - useState("productivity") - - /** 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 spotlightCatalog = useMemo( - () => buildSpotlightCatalog(router), + 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]) - - const handleSkip = useCallback(async () => { - if (skippingRef.current) return - skippingRef.current = true - setIsSkipping(true) - analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] }) + }, [ + organizations, + about, + suggestedWorkspaceName, + mode, + domain, + containerTag, + setActiveOrg, + refetchOrganizations, + ]) + + 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]) - - 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) => { - analytics.onboardingProfileSubmitted({ source }) - setStatus("processing") - setSpotlightCategory("productivity") - setPauseSpotlight(false) - setDocStatus("queued") - setMemoriesCount(0) - setDocTitle("") - - try { - await ensureOrg() + }, [ensureOrg, goNext]) - let docId: string | undefined + const [sendingInvites, setSendingInvites] = useState(false) + const sendingInvitesRef = useRef(false) - 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") + 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"}.`, + ) } - }, - [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) + } 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 5c88b5065..82948b2a5 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 000000000..69ff83f34 --- /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 000000000..2aac5ac69 --- /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 000000000..9b2d9b309 --- /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 000000000..5d8ff4f27 --- /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) +

+