+
- {extension}
+ {fileName || extension}
{document.content && (
diff --git a/apps/web/components/document-icon.tsx b/apps/web/components/document-icon.tsx
index 202ead22..00e36341 100644
--- a/apps/web/components/document-icon.tsx
+++ b/apps/web/components/document-icon.tsx
@@ -15,7 +15,7 @@ import {
NotionDoc,
PDF,
} from "@ui/assets/icons"
-import { Globe, FileText, Image } from "lucide-react"
+import { Globe, FileText, FileCode, Image } from "lucide-react"
import { cn } from "@lib/utils"
function MCPIcon({ className }: { className?: string }) {
@@ -144,13 +144,60 @@ export interface DocumentIconProps {
type: string | null | undefined
source?: string | null
url?: string | null
+ fileName?: string | null
+ mimeType?: string | null
className?: string
}
+function fileExtensionIcon(
+ ext: string | undefined,
+ mimeType: string | null | undefined,
+ iconClassName: string,
+): React.ReactNode | null {
+ if (ext === ".html" || ext === ".htm" || mimeType === "text/html") {
+ return
+ }
+ switch (ext) {
+ case ".pdf":
+ return
+ case ".doc":
+ case ".docx":
+ return (
+
+
+
+ )
+ case ".xls":
+ case ".xlsx":
+ case ".csv":
+ return (
+
+
+
+ )
+ case ".ppt":
+ case ".pptx":
+ return (
+
+
+
+ )
+ case ".md":
+ case ".mdx":
+ case ".txt":
+ case ".json":
+ return
+ default:
+ return null
+ }
+}
+
export function DocumentIcon({
type,
source,
url,
+ fileName,
+ mimeType,
className,
}: DocumentIconProps) {
const iconClassName = cn("size-4", className)
@@ -163,6 +210,22 @@ export function DocumentIcon({
return
}
+ // Uploaded files get a type icon, never the URL favicon of their storage host
+ if (fileName || mimeType) {
+ const lower = fileName?.toLowerCase()
+ const ext = lower?.includes(".")
+ ? lower.slice(lower.lastIndexOf("."))
+ : undefined
+ const fileIcon = fileExtensionIcon(ext, mimeType, iconClassName)
+ if (fileIcon) return fileIcon
+ if (mimeType?.startsWith("image/")) {
+ return
+ }
+ if (!type || type === "unknown" || type === "text") {
+ return
+ }
+ }
+
if (
type === "webpage" ||
type === "url" ||
diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx
index ba7d02a3..73218af2 100644
--- a/apps/web/components/ensure-workspace.tsx
+++ b/apps/web/components/ensure-workspace.tsx
@@ -22,11 +22,14 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
const { session, organizations, isRestoring } = useAuth()
- const isMcpPublicPage = searchParams.get("view") === "mcp"
+ const isPublicAppPage =
+ pathname === "/" &&
+ ["integrations", "mcp"].includes(searchParams.get("view") ?? "")
+ const isGuestPublicAppPage = isPublicAppPage && !session
const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {
- if (isMcpPublicPage) return
+ if (isGuestPublicAppPage) return
if (isRestoring) return
if (!session) {
router.replace(
@@ -44,11 +47,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
isRestoring,
isOnboarding,
router,
- isMcpPublicPage,
+ isGuestPublicAppPage,
])
const showLoading =
- !isMcpPublicPage &&
+ !isGuestPublicAppPage &&
(isRestoring ||
(!session && !isRestoring) ||
(session && organizations === null) ||
diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx
index cc3afb71..e47f56c2 100644
--- a/apps/web/components/header.tsx
+++ b/apps/web/components/header.tsx
@@ -504,7 +504,41 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
)
}
-export function PublicHeader() {
+export function PublicHeader({
+ variant = "default",
+}: {
+ variant?: "default" | "integrations"
+}) {
+ if (variant === "integrations") {
+ return (
+
+
+
+
+ supermemory
+
+
+
+
+
+
+
+ )
+ }
+
return (
@@ -84,6 +96,16 @@ function toIsoDate(value: string | Date | null | undefined): string | null {
return d.toISOString()
}
+function toMs(value: string | null | undefined): number {
+ if (!value) return 0
+ const t = new Date(value).getTime()
+ return Number.isNaN(t) ? 0 : t
+}
+
+function compactRelativeTime(value: number | string): string {
+ return formatRelativeTime(value).replace(/\s*ago$/i, "")
+}
+
function parsePluginAuthKeys(
apiKeys: ListedApiKey[],
keyPrefix: (key: ListedApiKey) => string | null,
@@ -347,7 +369,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",
})),
},
{
@@ -390,6 +412,7 @@ const SECTIONS: Array<{
simpleTitle: "Your Docs, Sheets and Slides, searchable",
icon:
,
pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/google-drive",
},
{
kind: "connector",
@@ -400,6 +423,7 @@ const SECTIONS: Array<{
simpleTitle: "All your Notion pages, in supermemory",
icon:
,
pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/notion",
},
{
kind: "connector",
@@ -410,6 +434,7 @@ const SECTIONS: Array<{
simpleTitle: "Your OneDrive files, ready to recall",
icon:
,
pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/onedrive",
},
{
kind: "connector",
@@ -537,11 +562,18 @@ function NewChip() {
)
}
-function IconBox({ children }: { children: ReactNode }) {
+function IconBox({
+ children,
+ size = "md",
+}: {
+ children: ReactNode
+ size?: "sm" | "md"
+}) {
return (
@@ -550,22 +582,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 (
+
)
}
@@ -647,18 +1026,698 @@ function ConnectionsCountPill({ count }: { count: number }) {
)
}
-function ItemCard({
+const CONNECTOR_META: Record<
+ ConnectorProvider,
+ { name: string; icon: ReactNode; documentLabel: string }
+> = {
+ "google-drive": {
+ name: "Google Drive",
+ icon: ,
+ documentLabel: "documents",
+ },
+ notion: {
+ name: "Notion",
+ icon: ,
+ documentLabel: "pages",
+ },
+ onedrive: {
+ name: "OneDrive",
+ icon: ,
+ documentLabel: "documents",
+ },
+}
+
+interface PluginEntry {
+ kind: "plugin"
+ id: string
+ name: string
+ icon: ReactNode
+ pro: boolean
+ agentCount: number
+ createdAt: string | null
+ lastActive: string | null
+ onManage: () => void
+}
+
+interface ConnectorEntry {
+ kind: "connector"
+ id: string
+ name: string
+ documentLabel: string
+ icon: ReactNode
+ pro: boolean
+ provider: ConnectorProvider
+ connection: Connection
+ connectionCount: number
+ email: string | null
+ spaceName: string | null
+ createdAt: string | null
+ onManage: () => void
+ onReconnect: () => void
+}
+
+type RailEntry = PluginEntry | ConnectorEntry
+
+function railConnectionMeta(connection: Connection) {
+ const m = connection.metadata as Record | undefined
+ return {
+ syncInProgress: m?.syncInProgress === true,
+ lastSyncedAt:
+ typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
+ documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
+ }
+}
+
+function RailDetail({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
+
+function RailAction({
+ label,
+ onClick,
+ danger,
+}: {
+ label: string
+ onClick: () => void
+ danger?: boolean
+}) {
+ return (
+
+ )
+}
+
+function RailRow({
icon,
name,
+ statusLine,
+ expanded,
+ onToggle,
+ children,
+}: {
+ icon: ReactNode
+ name: string
+ statusLine: ReactNode
+ expanded: boolean
+ onToggle: () => void
+ children: ReactNode
+}) {
+ return (
+
+
+
+ {expanded && (
+
+
+ {children}
+
+
+ )}
+
+
+ )
+}
+
+function ActiveStatusDot() {
+ return (
+ <>
+
+
+ Active
+
+ >
+ )
+}
+
+function PluginRailRow({ entry }: { entry: PluginEntry }) {
+ const [expanded, setExpanded] = useState(false)
+ const lastTime = entry.lastActive ?? entry.createdAt
+ const suffix = [
+ entry.agentCount > 1 ? `${entry.agentCount} agents` : null,
+ lastTime ? formatRelativeTime(lastTime) : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")
+ return (
+ setExpanded((v) => !v)}
+ statusLine={
+
+
+ {suffix && (
+
+ · {suffix}
+
+ )}
+
+ }
+ >
+ {entry.createdAt && (
+
+ )}
+ {entry.lastActive && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+const CONNECTOR_STATUS = {
+ expired: { color: "#EF4444", label: "Expired" },
+ syncing: { color: "#4BA0FA", label: "Syncing" },
+ synced: { color: "#00AC3F", label: "Synced" },
+ idle: { color: "#737373", label: "Connected" },
+} as const
+
+function ConnectorRailRow({ entry }: { entry: ConnectorEntry }) {
+ const [expanded, setExpanded] = useState(false)
+ const { needsReauth } = useConnectionHealth(entry.connection.id)
+ const meta = railConnectionMeta(entry.connection)
+ const status: keyof typeof CONNECTOR_STATUS = needsReauth
+ ? "expired"
+ : meta.syncInProgress
+ ? "syncing"
+ : meta.lastSyncedAt
+ ? "synced"
+ : "idle"
+ const { color, label } = CONNECTOR_STATUS[status]
+ const statusParts = [
+ status !== "syncing" && meta.documentCount > 0
+ ? String(meta.documentCount)
+ : null,
+ status === "synced" && meta.lastSyncedAt
+ ? compactRelativeTime(meta.lastSyncedAt)
+ : null,
+ ].filter(Boolean)
+ return (
+ setExpanded((v) => !v)}
+ statusLine={
+
+
+
+ {label}
+
+ {statusParts.length > 0 && (
+
+ · {statusParts.join(" · ")}
+
+ )}
+
+ }
+ >
+ {entry.email && }
+ {entry.createdAt && (
+
+ )}
+ {meta.lastSyncedAt && (
+
+ )}
+ {meta.documentCount > 0 && (
+
+ )}
+ {entry.spaceName && }
+ {needsReauth && (
+ Reconnect needed}
+ />
+ )}
+
+ {needsReauth && (
+
+ )}
+
+
+
+ )
+}
+
+const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"]
+
+function RailSkeleton({ rows }: { rows: number }) {
+ return (
+
+ {SKELETON_KEYS.slice(0, rows).map((k) => (
+
+ ))}
+
+ )
+}
+
+function RailEmpty({
+ icon,
+ title,
+ hint,
+}: {
+ icon: ReactNode
+ title: string
+ hint: string
+}) {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+
+ {hint}
+
+
+ )
+}
+
+function ActiveConnectionsRail({
+ entries,
+ loading,
+ className,
+}: {
+ entries: RailEntry[]
+ loading?: boolean
+ className?: string
+}) {
+ return (
+
+ )
+}
+
+type RecentDoc = z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+>["documents"][number]
+
+function hostnameOf(url: string | null | undefined): string | null {
+ if (!url) return null
+ try {
+ return new URL(url).hostname.replace(/^www\./, "")
+ } catch {
+ return null
+ }
+}
+
+const CONNECTOR_SMALL_ICON: Record = {
+ "google-drive": ,
+ notion: ,
+ onedrive: ,
+}
+
+function pluginIconNode(iconSrc: string | null): ReactNode {
+ if (!iconSrc) return
+ return (
+
+ )
+}
+
+function resolveDocSource(
+ doc: RecentDoc,
+ connectionSource: Map,
+): { label: string; icon: ReactNode } {
+ const tags = (doc as { containerTags?: unknown }).containerTags
+ if (Array.isArray(tags)) {
+ for (const tag of tags) {
+ if (typeof tag !== "string") continue
+ const space = detectPluginSpace(tag)
+ if (space) {
+ return { label: space.label, icon: pluginIconNode(space.iconSrc) }
+ }
+ }
+ }
+ if (doc.connectionId) {
+ const provider = connectionSource.get(doc.connectionId)
+ if (provider) {
+ return {
+ label: CONNECTOR_META[provider].name,
+ icon: CONNECTOR_SMALL_ICON[provider],
+ }
+ }
+ }
+ const cc = detectPluginSource(
+ doc.metadata as Record | null | undefined,
+ doc.source,
+ )
+ if (cc) {
+ return { label: cc.label, icon: pluginIconNode(cc.iconSrc) }
+ }
+ if (doc.source === "mcp") {
+ return { label: "MCP", icon: }
+ }
+ const type = (doc.type ?? "").toLowerCase()
+ if (type.includes("notion")) {
+ return { label: "Notion", icon: }
+ }
+ if (
+ type.includes("google") ||
+ type.includes("gdrive") ||
+ type.includes("drive")
+ ) {
+ return { label: "Google Drive", icon: }
+ }
+ if (type.includes("onedrive") || type.includes("microsoft")) {
+ return { label: "OneDrive", icon: }
+ }
+ const host = hostnameOf(doc.url)
+ if (host) {
+ return { label: host, icon: }
+ }
+ return {
+ label: "Note",
+ icon: ,
+ }
+}
+
+function docDisplayTitle(doc: RecentDoc, sourceLabel: string): string {
+ const t = doc.title?.trim()
+ if (t && !/^untitled/i.test(t)) return t
+ const summary = typeof doc.summary === "string" ? doc.summary.trim() : ""
+ if (summary) {
+ const line = summary
+ .split("\n")
+ .find((l) => l.trim())
+ ?.trim()
+ if (line) return line.length > 80 ? `${line.slice(0, 79)}…` : line
+ }
+ return `${sourceLabel} session`
+}
+
+function RecentDocRow({
+ doc,
+ connectionSource,
+ onOpen,
+}: {
+ doc: RecentDoc
+ connectionSource: Map
+ onOpen: () => void
+}) {
+ const { label, icon } = resolveDocSource(doc, connectionSource)
+ const title = docDisplayTitle(doc, label)
+ return (
+
+ )
+}
+
+function RecentlyAddedCard({
+ docs,
+ connectionSource,
+ loading,
+ onOpenDoc,
+ onViewAll,
+ className,
+}: {
+ docs: RecentDoc[]
+ connectionSource: Map
+ loading?: boolean
+ onOpenDoc: (doc: RecentDoc) => void
+ onViewAll: () => void
+ className?: string
+}) {
+ return (
+
+ )
+}
+
+function ItemCard({
+ actionSlot,
+ icon,
+ id,
+ kind,
+ name,
tagline,
pro,
max,
isNew,
docsUrl,
leftIndicator,
- rightSlot,
+ statusSlot,
}: {
+ actionSlot: ReactNode
icon: ReactNode
+ id: string
+ kind: ItemKind
name: string
tagline: string
pro?: boolean
@@ -666,27 +1725,39 @@ function ItemCard({
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()}
- >
-
-
- )}
@@ -712,7 +1783,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}
+
+
)
@@ -1095,14 +2183,23 @@ function SectionRail({
)
}
-export function IntegrationsView() {
+export function IntegrationsView({
+ publicMode = false,
+ onOpenDocument,
+}: {
+ publicMode?: boolean
+ onOpenDocument?: (doc: RecentDoc) => void
+}) {
const { setViewMode } = useViewMode()
const queryClient = useQueryClient()
const { org } = useAuth()
- const autumn = useCustomer()
- const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
- const hasMaxProduct = hasActivePlan(autumn.data?.subscriptions, "api_max")
- const isAutumnLoading = autumn.isLoading
+ const { allProjects } = useContainerTags()
+ const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
+ const hasProProduct =
+ !publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
+ const hasMaxProduct =
+ !publicMode && hasActivePlan(autumn.data?.subscriptions, "api_max")
+ const isAutumnLoading = !publicMode && autumn.isLoading
const [connectingPlugin, setConnectingPlugin] = useState(null)
const [connectingProvider, setConnectingProvider] =
@@ -1130,10 +2227,11 @@ export function IntegrationsView() {
if (!res.ok) throw new Error("Failed to fetch plugins")
return (await res.json()) as { plugins: string[] }
},
+ enabled: !publicMode,
queryKey: ["plugins"],
})
- const { data: connections = [] } = useQuery({
+ const { data: connections = [], isLoading: connectionsLoading } = useQuery({
queryKey: ["connections"],
queryFn: async () => {
const response = await $fetch("@post/connections/list", {
@@ -1144,27 +2242,29 @@ export function IntegrationsView() {
return response.data as Connection[]
},
staleTime: 30 * 1000,
- enabled: hasProProduct,
+ enabled: !publicMode && hasProProduct,
})
- const { data: apiKeys = [], refetch: refetchKeys } = useQuery(
- {
- queryKey: ["api-keys", org?.id],
- queryFn: async () => {
- if (!org?.id) return []
- const API_URL =
- process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
- const res = await fetch(`${API_URL}/v3/auth/keys`, {
- credentials: "include",
- })
- if (!res.ok) return []
- const data = (await res.json()) as { keys?: ListedApiKey[] }
- return data.keys ?? []
- },
- enabled: !!org?.id,
- staleTime: 30 * 1000,
+ const {
+ data: apiKeys = [],
+ refetch: refetchKeys,
+ isLoading: apiKeysLoading,
+ } = useQuery({
+ queryKey: ["api-keys", org?.id],
+ queryFn: async () => {
+ if (!org?.id) return []
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const res = await fetch(`${API_URL}/v3/auth/keys`, {
+ credentials: "include",
+ })
+ if (!res.ok) return []
+ const data = (await res.json()) as { keys?: ListedApiKey[] }
+ return data.keys ?? []
},
- )
+ enabled: !publicMode && !!org?.id,
+ staleTime: 30 * 1000,
+ })
const keyPrefix = useCallback((key: ListedApiKey): string | null => {
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
@@ -1219,6 +2319,15 @@ export function IntegrationsView() {
return out
}, [connections])
+ const connectionSource = useMemo(() => {
+ const m = new Map()
+ for (const c of connections) {
+ const p = c.provider as ConnectorProvider
+ if (p in CONNECTOR_META) m.set(c.id, p)
+ }
+ return m
+ }, [connections])
+
const createPluginKeyMutation = useMutation({
mutationFn: async (pluginId: string) => {
const API_URL =
@@ -1316,13 +2425,22 @@ export function IntegrationsView() {
}
}
- const availablePluginIds = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
+ const redirectToLogin = useCallback(() => {
+ const loginUrl = new URL("/login", window.location.origin)
+ loginUrl.searchParams.set("redirect", window.location.href)
+ window.location.assign(loginUrl.toString())
+ }, [])
+
+ const availablePluginIds = publicMode
+ ? Object.keys(PLUGIN_CATALOG)
+ : (pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG))
const enabledPluginIds = new Set(
availablePluginIds.filter((id) => PLUGIN_CATALOG[id]),
)
const [category, setCategory] = useQueryState("cat", catParam)
const [, setAddDoc] = useQueryState("add", addDocumentParam)
+ const [, setDocId] = useQueryState("doc", docParam)
const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [search, setSearch] = useState("")
@@ -1352,6 +2470,7 @@ export function IntegrationsView() {
const isItemConnected = useCallback(
(item: Item): boolean => {
+ if (publicMode) return false
if (item.kind === "plugin") {
return activePluginById.has(item.pluginId)
}
@@ -1360,7 +2479,7 @@ export function IntegrationsView() {
}
return false
},
- [activePluginById, connectionsByProvider],
+ [activePluginById, connectionsByProvider, publicMode],
)
const counts = useMemo>(
@@ -1386,6 +2505,120 @@ export function IntegrationsView() {
}
}, [category, counts, setCategory])
+ const railEntries = useMemo(() => {
+ const getSpaceName = (tag?: string): string | null => {
+ if (!tag) return null
+ if (tag === DEFAULT_PROJECT_ID) return "Default"
+ return allProjects.find((p) => p.containerTag === tag)?.name ?? null
+ }
+ const rows: Array<{ ts: number; entry: RailEntry }> = []
+ for (const [pluginId, key] of activePluginById) {
+ const plugin = PLUGIN_CATALOG[pluginId]
+ if (!plugin) continue
+ const count = activeCountByPlugin.get(pluginId) ?? 1
+ rows.push({
+ ts: toMs(key.lastRequest ?? key.createdAt),
+ entry: {
+ kind: "plugin",
+ id: `plugin-${pluginId}`,
+ name: plugin.name,
+ icon: (
+
+ ),
+ pro: !FREE_TIER_PLUGIN_IDS.includes(pluginId),
+ agentCount: count,
+ createdAt: key.createdAt ?? null,
+ lastActive: key.lastRequest ?? null,
+ onManage: () => setConnectedPluginId(pluginId),
+ },
+ })
+ }
+ for (const provider of [
+ "google-drive",
+ "notion",
+ "onedrive",
+ ] as ConnectorProvider[]) {
+ const conns = connectionsByProvider[provider]
+ const primary = conns[0]
+ if (!primary) continue
+ const meta = CONNECTOR_META[provider]
+ const earliest = conns.reduce((min, c) => {
+ if (!min) return c.createdAt
+ return c.createdAt < min ? c.createdAt : min
+ }, null)
+ const email = conns.find((c) => c.email)?.email ?? null
+ rows.push({
+ ts: toMs(earliest),
+ entry: {
+ kind: "connector",
+ id: `connector-${provider}`,
+ name: meta.name,
+ documentLabel: meta.documentLabel,
+ icon: meta.icon,
+ pro: true,
+ provider,
+ connection: primary,
+ connectionCount: conns.length,
+ email,
+ spaceName: getSpaceName(primary.containerTags?.[0]),
+ createdAt: earliest,
+ onManage: () => void setAddDoc("connect"),
+ onReconnect: () => addConnectionMutation.mutate(provider),
+ },
+ })
+ }
+ rows.sort((a, b) => b.ts - a.ts)
+ return rows.map((r) => r.entry)
+ }, [
+ activePluginById,
+ activeCountByPlugin,
+ connectionsByProvider,
+ allProjects,
+ setAddDoc,
+ addConnectionMutation,
+ ])
+
+ const hasActiveRail = railEntries.length > 0
+
+ const { data: recentDocs = [], isLoading: recentsLoading } = useQuery({
+ queryKey: ["integrations-recent-docs", org?.id],
+ queryFn: async () => {
+ const response = await $fetch("@post/documents/documents", {
+ body: {
+ page: 1,
+ limit: 6,
+ sort: "createdAt",
+ order: "desc",
+ containerTags: [],
+ },
+ disableValidation: true,
+ })
+ if (response.error) {
+ throw new Error(
+ response.error?.message || "Failed to load recent documents",
+ )
+ }
+ const data = response.data as z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+ >
+ return data.documents ?? []
+ },
+ enabled: !publicMode && !!org?.id,
+ staleTime: 60 * 1000,
+ })
+
+ const railLoading =
+ !publicMode && (apiKeysLoading || connectionsLoading || isAutumnLoading)
+ const showRightColumn =
+ !publicMode &&
+ (hasActiveRail || recentDocs.length > 0 || railLoading || recentsLoading)
+
const claudeCodeConnected = activePluginById.has("claude_code")
const claudeCodeNeedsPro =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
@@ -1418,6 +2651,10 @@ export function IntegrationsView() {
),
ctaLabel: "Connect",
onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
window.open(POKE_RECIPE_URL, "_blank", "noopener,noreferrer")
},
},
@@ -1437,9 +2674,13 @@ 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: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
void setMcpClient(null)
setViewMode("mcp")
},
@@ -1468,13 +2709,19 @@ export function IntegrationsView() {
className="object-contain"
/>
),
- docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
- ctaLabel: claudeCodeConnected
- ? "Active"
- : claudeCodeNeedsPro
- ? "Upgrade"
- : "Connect",
+ docsUrl: "https://supermemory.ai/docs/integrations/claude-code",
+ ctaLabel: publicMode
+ ? "Connect"
+ : claudeCodeConnected
+ ? "Active"
+ : claudeCodeNeedsPro
+ ? "Upgrade"
+ : "Connect",
onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
if (claudeCodeConnected) return
if (claudeCodeNeedsPro) {
handleUpgrade()
@@ -1493,6 +2740,10 @@ export function IntegrationsView() {
backdrop: ,
ctaLabel: "Connect",
onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
analytics.onboardingChromeExtensionClicked({ source: "integrations" })
},
@@ -1523,49 +2774,51 @@ export function IntegrationsView() {
})
const renderRight = (item: Item): ReactNode => {
+ if (publicMode) {
+ return (
+ {
+ trackCard(item)
+ redirectToLogin()
+ }}
+ >
+ Connect
+
+ )
+ }
+
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)) {
@@ -1736,10 +2989,41 @@ export function IntegrationsView() {
}
}
+ const renderStatus = (item: Item): ReactNode => {
+ if (publicMode) return null
+
+ 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) => (
)
@@ -1792,65 +3076,107 @@ export function IntegrationsView() {
return (
-
- {!q &&
}
+
+
+ {!q &&
}
-
-
-
-
void setCategory(v)}
- counts={counts}
- compact={searchExpanded || !!search}
- />
+
+
+
+
+
+ void setCategory(v)}
+ counts={counts}
+ compact={searchExpanded || !!search}
+ />
+
+
+
+ {visibleItems.length === 0 ? (
+
+ {q
+ ? `No integrations match “${search}”.`
+ : "Nothing in this category yet."}
+
+ ) : q || category !== "all" ? (
+
+ {visibleItems.map((item) => renderItemCard(item))}
+
+ ) : (
+
+ {SECTION_ORDER.map((cat) => {
+ const items = visibleItems.filter(
+ (i) => itemCategory(i) === cat,
+ )
+ if (items.length === 0) return null
+ return (
+
+ {items.map((item) => (
+
+ {renderItemCard(item)}
+
+ ))}
+
+ )
+ })}
+
+ )}
+
-
+ {showRightColumn && (
+
+
+
+
{
+ if (onOpenDocument) {
+ onOpenDocument(doc)
+ return
+ }
+ void setDocId(doc.id ?? doc.customId ?? null)
+ }}
+ onViewAll={() => setViewMode("list")}
+ />
+
+
+ )}
- {visibleItems.length === 0 ? (
-
- {q
- ? `No integrations match “${search}”.`
- : "Nothing in this category yet."}
-
- ) : q || category !== "all" ? (
-
- {visibleItems.map((item) => renderItemCard(item))}
-
- ) : (
-
- {SECTION_ORDER.map((cat) => {
- const items = visibleItems.filter(
- (i) => itemCategory(i) === cat,
- )
- if (items.length === 0) return null
- return (
-
- {items.map((item) => (
-
- {renderItemCard(item)}
-
- ))}
-
- )
- })}
-
- )}
@@ -2239,7 +3565,7 @@ export function IntegrationsView() {
(
{isLoading ? (
) : documentCount > 0 || memoryCount > 0 ? (
loadMore() : undefined}
+ isLoading={false}
+ isLoadingMore={false}
+ onLoadMore={hasMore && !isLoadingMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
@@ -70,6 +72,16 @@ export function MemoryGraph({
>
{children}
+ {isInitialLoading && (
+
+
+
+ )}
)
}
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)
+
+
+
+
+
+
+
+
+
+ {mode === "team" ? (
+
+ onChange({ ...values, workspaceDomain: d })
+ }
+ value={values.workspaceName}
+ onChange={(w) => onChange({ ...values, workspaceName: w })}
+ suggested={suggestedWorkspaceName}
+ />
+ ) : (
+ onChange({ ...values, workspaceName: w })}
+ />
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+function TeamWorkspaceCard({
+ domain,
+ onDomainChange,
+ value,
+ onChange,
+ suggested,
+}: {
+ domain: string
+ onDomainChange: (d: string) => void
+ value: string
+ onChange: (v: string) => void
+ suggested: string
+}) {
+ return (
+ <>
+
+
+ {domain ? (
+
+ ) : (
+
+ )}
+
+
+
onDomainChange(e.target.value.trim())}
+ placeholder="your-team.com"
+ className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
+ />
+
+ Team workspace
+
+
+
+
+
+
+ Workspace name{" "}
+
+ (rename if you'd like)
+
+
+
onChange(e.target.value)}
+ placeholder={suggested || "Acme"}
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+ ,
+ title: "Invite teammates",
+ blurb: "Everyone contributes to the same brain.",
+ },
+ {
+ icon: ,
+ title: "Shared coding agent context",
+ blurb: "Claude, Cursor, MCP — same brain across the team.",
+ },
+ {
+ icon: ,
+ title: "Org-wide spaces",
+ blurb: "Carve out sales, eng, design with their own access.",
+ },
+ ]}
+ />
+
+
+ Not your team? Switch above.
+
+ >
+ )
+}
+
+function DomainLogo({ domain }: { domain: string }) {
+ const sources = [
+ `https://logo.clearbit.com/${domain}`,
+ `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
+ `https://icons.duckduckgo.com/ip3/${domain}.ico`,
+ ]
+ const [idx, setIdx] = useState(0)
+ if (idx >= sources.length) {
+ return
+ }
+ return (
+
setIdx((i) => i + 1)}
+ />
+ )
+}
+
+function PersonalWorkspaceCard({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (v: string) => void
+}) {
+ return (
+ <>
+
+
+
+
+
+
+ Just you, for now
+
+
+ Personal workspace
+
+
+
+
+
+
Workspace nickname
+
onChange(e.target.value)}
+ placeholder="My brain"
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+ ,
+ title: "Your own brain",
+ blurb: "Notes, docs, bookmarks — all searchable in one place.",
+ },
+ {
+ icon: ,
+ title: "Plug into your AI tools",
+ blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
+ },
+ {
+ icon: ,
+ title: "Switch to a team anytime",
+ blurb: "Invite teammates whenever you're ready.",
+ },
+ ]}
+ />
+
+
+ Working with a team? Switch above.
+
+ >
+ )
+}
+
+function ModeToggle({
+ mode,
+ onChange,
+}: {
+ mode: BrainMode
+ onChange: (m: BrainMode) => void
+}) {
+ const items: { id: BrainMode; label: string }[] = [
+ { id: "personal", label: "Personal" },
+ { id: "team", label: "Team" },
+ ]
+ return (
+
+ {items.map((item) => {
+ const isActive = mode === item.id
+ return (
+
+ )
+ })}
+
+ )
+}
+
+function UserAvatar({
+ url,
+ name,
+ className,
+}: {
+ url: string | null
+ name: string
+ className?: string
+}) {
+ const [errored, setErrored] = useState(false)
+ const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
+ const hasImage = url && !errored
+
+ return (
+
+ {hasImage ? (
+

setErrored(true)}
+ />
+ ) : (
+
+ {initial}
+
+ )}
+
+ )
+}
+
+type Perk = { icon: React.ReactNode; title: string; blurb: string }
+
+function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
+ return (
+
+
+ {heading}
+
+
+ {perks.map((p) => (
+ -
+ {p.icon}
+
+ {p.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-ingest.tsx b/apps/web/components/onboarding-brain/step-ingest.tsx
new file mode 100644
index 00000000..90a43377
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-ingest.tsx
@@ -0,0 +1,480 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Image from "next/image"
+import { useQueryState, parseAsString } from "nuqs"
+import { Button } from "@ui/components/button"
+import { MCPIcon } from "@ui/assets/icons"
+import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import { toast } from "sonner"
+import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
+import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
+
+interface Props {
+ mcpUrl: string
+ onContinue: () => void
+}
+
+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 AgentCategory = "coding" | "productivity"
+
+type Agent = {
+ key: string
+ name: string
+ tagline: string
+ category: AgentCategory
+ pluginId?: string
+}
+
+const AGENTS: Agent[] = [
+ {
+ key: "cursor",
+ name: "Cursor",
+ tagline: "Persistent context across coding sessions.",
+ category: "coding",
+ },
+ {
+ key: "claude-code",
+ name: "Claude Code",
+ tagline: "Memory and decisions across CLI sessions.",
+ category: "coding",
+ pluginId: "claude_code",
+ },
+ {
+ key: "vscode",
+ name: "VS Code",
+ tagline: "Inline context while you write.",
+ category: "coding",
+ },
+ {
+ key: "cline",
+ name: "Cline",
+ tagline: "Agentic dev tasks with your memory.",
+ category: "coding",
+ },
+ {
+ key: "codex",
+ name: "Codex",
+ tagline: "OpenAI Codex with persistent memory.",
+ category: "coding",
+ pluginId: "codex",
+ },
+ {
+ key: "gemini-cli",
+ name: "Gemini CLI",
+ tagline: "Gemini in your terminal, brain-aware.",
+ category: "coding",
+ },
+ {
+ key: "claude",
+ name: "Claude Desktop",
+ tagline: "Memory across every Claude conversation.",
+ category: "productivity",
+ },
+ {
+ key: "chatgpt",
+ name: "ChatGPT",
+ tagline: "Custom GPT backed by your brain.",
+ category: "productivity",
+ },
+]
+
+const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
+ { id: "coding", label: "Coding" },
+ { id: "productivity", label: "Productivity" },
+]
+
+function agentIcon(agent: Agent) {
+ if (agent.pluginId) {
+ const plugin = PLUGIN_CATALOG[agent.pluginId]
+ if (plugin) return plugin.icon
+ }
+ const file = agent.key === "claude-code" ? "claude" : agent.key
+ return `/mcp-supported-tools/${file}.png`
+}
+
+export function StepIngest({ mcpUrl, onContinue }: Props) {
+ const [activeCategory, setActiveCategory] = useState("coding")
+ const [selectedKey, setSelectedKey] = useState("cursor")
+ const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
+
+ const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
+
+ useEffect(() => {
+ if (selectedAgent && !selectedAgent.pluginId) {
+ setMcpClient(selectedAgent.key)
+ } else {
+ setMcpClient(null)
+ }
+ }, [selectedAgent, setMcpClient])
+
+ const selectAgent = (agent: Agent) => {
+ setSelectedKey(agent.key)
+ }
+
+ const filtered = AGENTS.filter((a) => a.category === activeCategory)
+
+ return (
+
+
+
+ Use your brain anywhere
+
+
+ Now plug it into the tools you already use to write code, chat, think.
+
+
+
+
+
+
+
+
+
+ {selectedAgent?.pluginId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+function McpHero({ url }: { url: string }) {
+ const [copied, setCopied] = useState(false)
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(url)
+ setCopied(true)
+ toast.success("MCP URL copied")
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ Universal MCP URL
+
+
+ {url}
+
+
+
+
+ )
+}
+
+function PluginSteps({ pluginId }: { pluginId: string }) {
+ const plugin = PLUGIN_CATALOG[pluginId]
+ if (!plugin) return null
+ const steps = plugin.installSteps ?? []
+ return (
+
+
+
+
+
+
+
+ Set up {plugin.name}
+
+
+ {plugin.tagline}
+
+
+ {plugin.docsUrl && (
+
+ Docs ↗
+
+ )}
+
+
+
+ {steps.map((step, i) => (
+
+ ))}
+
+
+
+
+ Your API key is minted in
+ Settings → Integrations → Plugins. Mint it once and paste into the
+ step above.
+
+
+
+ )
+}
+
+function PluginStep({
+ idx,
+ step,
+}: {
+ idx: number
+ step: import("@/lib/plugin-catalog").InstallStep
+}) {
+ const [revealed, setRevealed] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const copy = async () => {
+ if (!step.code) return
+ try {
+ await navigator.clipboard.writeText(step.code)
+ setCopied(true)
+ toast.success("Copied")
+ setTimeout(() => setCopied(false), 1500)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+ return (
+
+
+
+
+ {step.title}
+ {step.optional && (
+
+ Optional
+
+ )}
+
+ {step.description && (
+
+ {step.description}
+
+ )}
+ {step.code && (
+
+
+ {step.code}
+
+
+ {step.secret && (
+
+ )}
+
+
+
+ )}
+
+
+ )
+}
+
+function CategoryTabs({
+ value,
+ onChange,
+}: {
+ value: AgentCategory
+ onChange: (c: AgentCategory) => void
+}) {
+ const counts: Record = {
+ coding: 0,
+ productivity: 0,
+ }
+ for (const a of AGENTS) counts[a.category] += 1
+ return (
+
+ {CATEGORY_ORDER.map((cat) => {
+ const isActive = value === cat.id
+ return (
+
+ )
+ })}
+
+ )
+}
+
+function AgentRow({
+ agent,
+ active,
+ onClick,
+}: {
+ agent: Agent
+ active: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx
new file mode 100644
index 00000000..6e77cab6
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-sources.tsx
@@ -0,0 +1,587 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@ui/components/button"
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+} from "@ui/components/drawer"
+import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
+import { Logo } from "@ui/assets/Logo"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import {
+ AlertTriangle,
+ ArrowRight,
+ Check,
+ Database,
+ FolderOpen,
+ Github,
+ Globe,
+ Mic,
+ Plus,
+} from "lucide-react"
+import {
+ AppleShortcutsIcon,
+ ChromeIcon,
+ RaycastIcon,
+} from "@/components/integration-icons"
+
+function XBookmarksIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function GmailIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { $fetch } from "@lib/api"
+import { toast } from "sonner"
+
+type SourceId = "drive" | "notion" | "gmail" | "github" | "onedrive"
+type SourceState = "idle" | "connecting" | "connected" | "waitlist"
+type DriveScope = "selective" | "full"
+
+export interface SourcesValues {
+ connected: Partial>
+ driveScope: DriveScope
+}
+
+interface Props {
+ containerTag: string
+ workspaceName: string
+ values: SourcesValues
+ onChange: (next: SourcesValues) => void
+ onContinue: () => void
+}
+
+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)",
+}
+
+export function StepSources({
+ containerTag,
+ workspaceName,
+ values,
+ onChange,
+ onContinue,
+}: Props) {
+ const [moreOpen, setMoreOpen] = useState(false)
+
+ const setState = (id: SourceId, state: SourceState) => {
+ onChange({ ...values, connected: { ...values.connected, [id]: state } })
+ }
+
+ const connectRealProvider = async (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => {
+ setState(id, "connecting")
+ try {
+ const metadata: Record = {}
+ if (provider === "google-drive") {
+ metadata.scope = values.driveScope
+ }
+ const res = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [containerTag],
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
+ },
+ })
+ const data = "data" in res ? res.data : null
+ if (data && "authLink" in data && data.authLink) {
+ window.location.href = data.authLink
+ return
+ }
+ throw new Error("No auth link returned")
+ } catch (err) {
+ setState(id, "idle")
+ toast.error(
+ err instanceof Error ? err.message : "Could not start connection",
+ )
+ }
+ }
+
+ const connectedCount = Object.values(values.connected).filter(
+ (s) => s === "connected" || s === "waitlist",
+ ).length
+
+ return (
+
+
+
+
+ Connect your team's signals
+
+
+ Start with the sources that carry the most context. Add more
+ anytime.
+
+
+
+
+
+
+
}
+ state={values.connected.drive ?? "idle"}
+ ctaLabel="Connect"
+ perks={[
+ "Docs, sheets, slides — all parsed",
+ "Stays in sync as files change",
+ "You pick what to share at sign-in",
+ ]}
+ onConnect={() => connectRealProvider("google-drive", "drive")}
+ headerNote={
+ values.driveScope === "full" ? (
+
+
+ Full Drive can exhaust your monthly usage.
+
+ ) : null
+ }
+ footerLeft={
+
onChange({ ...values, driveScope: s })}
+ />
+ }
+ footerRight={}
+ />
+ }
+ state={values.connected.notion ?? "idle"}
+ ctaLabel="Connect"
+ perks={[
+ "Pages and database rows",
+ "Stays in sync when you edit",
+ "Pick which workspaces ingest",
+ ]}
+ onConnect={() => connectRealProvider("notion", "notion")}
+ footerRight={}
+ />
+
+
+
+
+
+
+
+
+
+
+
setMoreOpen(false)}
+ containerTag={containerTag}
+ connectRealProvider={connectRealProvider}
+ />
+
+ )
+}
+
+function RoutingChip({ workspaceName }: { workspaceName: string }) {
+ return (
+
+
+ Routing to
+
+ {workspaceName || "your brain"}
+
+
+ )
+}
+
+function SourceCard({
+ title,
+ blurb,
+ icon,
+ state,
+ ctaLabel,
+ perks,
+ soft,
+ headerNote,
+ footerLeft,
+ footerRight,
+ onConnect,
+}: {
+ title: string
+ blurb: string
+ icon: React.ReactNode
+ state: SourceState
+ ctaLabel: string
+ perks: string[]
+ soft?: boolean
+ headerNote?: React.ReactNode
+ footerLeft?: React.ReactNode
+ footerRight?: React.ReactNode
+ onConnect: () => void
+}) {
+ const isDone = state === "connected" || state === "waitlist"
+
+ return (
+
+
+
+
+ {icon}
+
+
+
{title}
+
+ {blurb}
+
+ {headerNote}
+
+
+ {isDone ? (
+
+
+ {state === "waitlist" ? "Requested" : "Connected"}
+
+ ) : (
+
+ )}
+
+
+
+ {perks.map((p) => (
+ -
+
+ {p}
+
+ ))}
+
+
+ {soft && !isDone && (
+
+ OAuth lands shortly — request access and we'll auto-enable it.
+
+ )}
+
+ {(footerLeft || footerRight) && (
+
+
{footerLeft}
+
{footerRight}
+
+ )}
+
+ )
+}
+
+function SpaceChip({ name }: { name: string }) {
+ return (
+
+
+ Saves to
+
+
+ {name}
+
+ )
+}
+
+function DriveScopePicker({
+ value,
+ onChange,
+}: {
+ value: DriveScope
+ onChange: (s: DriveScope) => void
+}) {
+ return (
+
+ )
+}
+
+function MoreDrawer({
+ open,
+ onClose,
+ containerTag,
+ connectRealProvider,
+}: {
+ open: boolean
+ onClose: () => void
+ containerTag: string
+ connectRealProvider: (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => void
+}) {
+ return (
+ !o && onClose()}>
+
+
+
+ More integrations
+
+
+ Add any of these alongside your spotlight sources. Everything routes
+ to {containerTag}.
+
+
+
+ }
+ action="Request access"
+ soft
+ />
+ }
+ action="Request access"
+ soft
+ />
+ }
+ action="Connect"
+ onAction={() => connectRealProvider("onedrive", "onedrive")}
+ />
+ }
+ action="Coming soon"
+ soft
+ />
+ }
+ action="Connect"
+ soft
+ />
+ }
+ action="Connect"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Import"
+ soft
+ />
+
+
+
+ )
+}
+
+function MoreItem({
+ title,
+ blurb,
+ icon,
+ action,
+ soft,
+ onAction,
+}: {
+ title: string
+ blurb: string
+ icon: React.ReactNode
+ action: string
+ soft?: boolean
+ onAction?: () => void
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {blurb}
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-team.tsx b/apps/web/components/onboarding-brain/step-team.tsx
new file mode 100644
index 00000000..2c04f857
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-team.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+
+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)",
+}
+
+const inputClass =
+ "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
+
+export interface TeamValues {
+ invites: { email: string; role: "admin" | "member" }[]
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+}
+
+interface Props {
+ mode: "personal" | "team"
+ isScale: boolean
+ inviteDomain: string | null
+ values: TeamValues
+ onChange: (next: TeamValues) => void
+ onContinue: () => void
+ onSkip?: () => void
+ onUpgrade: () => void
+ submitting?: boolean
+}
+
+const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
+
+export function StepTeam({
+ mode,
+ inviteDomain,
+ values,
+ onChange,
+ onContinue,
+ onSkip,
+ submitting,
+}: Props) {
+ const [draft, setDraft] = useState("")
+ const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
+
+ const addInvites = (text: string) => {
+ const found = text.match(EMAIL_RE) ?? []
+ if (found.length === 0) return
+ const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
+ const next: { email: string; role: "admin" | "member" }[] = []
+ for (const raw of found) {
+ const email = raw.trim().toLowerCase()
+ if (!email || existing.has(email)) continue
+ existing.add(email)
+ next.push({ email, role: "member" })
+ }
+ if (next.length === 0) {
+ setDraft("")
+ return
+ }
+ onChange({ ...values, invites: [...values.invites, ...next] })
+ setDraft("")
+ }
+
+ const removeInvite = (email: string) => {
+ onChange({
+ ...values,
+ invites: values.invites.filter((i) => i.email !== email),
+ })
+ }
+
+ const setRole = (email: string, role: "admin" | "member") => {
+ onChange({
+ ...values,
+ invites: values.invites.map((i) =>
+ i.email === email ? { ...i, role } : i,
+ ),
+ })
+ }
+
+ const domainBreakdown = useMemo(() => {
+ const counts = new Map()
+ for (const inv of values.invites) {
+ const at = inv.email.lastIndexOf("@")
+ if (at < 0) continue
+ const domain = inv.email.slice(at + 1).toLowerCase()
+ counts.set(domain, (counts.get(domain) ?? 0) + 1)
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])
+ }, [values.invites])
+
+ if (mode === "personal") {
+ return (
+
+
+
+
+
+ Going solo — for now
+
+
+ You're in Personal mode, so there's no team step. Switch to Team in
+ the top bar anytime to invite others.
+
+
+
+ )
+ }
+
+ const count = values.invites.length
+
+ return (
+
+
+
+
+
+
+
+
+ Invite your team
+
+
+ A brain gets sharper as more people contribute. You can also do
+ this later.
+
+
+
+
+
+
+
+ Paste multiple emails at once — we'll split them for you.
+
+
+ {count === 0 ? (
+
+
+ No invites yet.
+
+
+ Try{" "}
+ alex@{domainOrFallback},{" "}
+ sam@{domainOrFallback},
+ etc.
+
+
+ ) : (
+ <>
+
+
+ {count} invite{count === 1 ? "" : "s"}
+
+ {domainBreakdown.length > 0 && (
+
+ {domainBreakdown.slice(0, 3).map(([d, n]) => (
+
+ {d} · {n}
+
+ ))}
+
+ )}
+
+
+ {values.invites.map((inv) => (
+
+
+
+ {(inv.email[0] ?? "?").toUpperCase()}
+
+
+
+ {inv.email}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts
new file mode 100644
index 00000000..2e016f3f
--- /dev/null
+++ b/apps/web/components/onboarding-brain/types.ts
@@ -0,0 +1,131 @@
+export type BrainMode = "personal" | "team"
+
+export type BrainStep = "about" | "sources" | "ingest" | "team"
+
+export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
+
+export const BRAIN_STEP_LABELS: Record = {
+ about: "About",
+ sources: "Tools",
+ ingest: "Flows",
+ team: "Team",
+}
+
+const FREE_EMAIL_DOMAINS = new Set([
+ "gmail.com",
+ "googlemail.com",
+ "yahoo.com",
+ "yahoo.co.uk",
+ "yahoo.co.in",
+ "outlook.com",
+ "hotmail.com",
+ "live.com",
+ "icloud.com",
+ "me.com",
+ "mac.com",
+ "aol.com",
+ "protonmail.com",
+ "proton.me",
+ "pm.me",
+ "fastmail.com",
+ "zoho.com",
+ "yandex.com",
+ "yandex.ru",
+ "mail.com",
+ "qq.com",
+ "163.com",
+ "126.com",
+ "naver.com",
+ "duck.com",
+])
+
+export function detectModeFromEmail(
+ email: string | undefined | null,
+): BrainMode {
+ if (!email) return "personal"
+ const at = email.lastIndexOf("@")
+ if (at < 0) return "personal"
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ if (!domain) return "personal"
+ if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
+ return "team"
+}
+
+export function workspaceNameFromEmail(
+ email: string | undefined | null,
+): string {
+ if (!email) return ""
+ const at = email.lastIndexOf("@")
+ if (at < 0) return ""
+ const domain = email.slice(at + 1).toLowerCase()
+ const root = domain.split(".")[0] ?? ""
+ if (!root) return ""
+ return root.charAt(0).toUpperCase() + root.slice(1)
+}
+
+export function workspaceDomainFromEmail(
+ email: string | undefined | null,
+): string | null {
+ if (!email) return null
+ const at = email.lastIndexOf("@")
+ if (at < 0) return null
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ return domain || null
+}
+
+export type BrainMetadata = {
+ brainOnboardingVersion?: "v1"
+ brainOnboardingComplete?: boolean
+ brainMode?: BrainMode
+ brainWorkspaceName?: string
+ brainWorkspaceDomain?: string | null
+ brainAbout?: string
+ brainContainerTag?: string
+ brainSources?: {
+ drive?: { status: "connected" | "pending" | "skipped" }
+ gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
+ notion?: { status: "connected" | "pending" | "skipped" }
+ granola?: { status: "waitlist" | "skipped" }
+ }
+ brainInvites?: { email: string; role: "admin" | "member" }[]
+ brainPermissions?: {
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+ }
+}
+
+export function generateOrgSlug(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "") || "org"
+ return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function generateUsername(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/(^_|_$)/g, "") || "user"
+ return `${base}${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function containerTagFromWorkspace(
+ name: string,
+ mode: BrainMode,
+): string {
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+ if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
+ return mode === "team" ? `${slug}-brain` : `${slug}-personal`
+}
diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx
index 2021f83b..3627b220 100644
--- a/apps/web/components/settings/billing.tsx
+++ b/apps/web/components/settings/billing.tsx
@@ -11,8 +11,15 @@ import {
DialogContent,
DialogTrigger,
} from "@ui/components/dialog"
+import { Logo } from "@ui/assets/Logo"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
+import { usePostHog } from "@lib/posthog"
+import {
+ CANCEL_REASONS,
+ cancelReasonNeedsDetail,
+ type CancelReasonValue,
+} from "./cancel-reasons"
import {
Check,
ChevronLeft,
@@ -31,6 +38,38 @@ import { toast } from "sonner"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
+
+function GoogleMeetIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
const CREDIT_FEATURE_ID = "usd_credits"
const TOP_UP_PLAN_ID = "credits_topup"
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
@@ -437,11 +476,15 @@ export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer()
+ const posthog = usePostHog()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
- const [cancelConfirmText, setCancelConfirmText] = useState("")
+ const [cancelReason, setCancelReason] = useState(
+ null,
+ )
+ const [cancelDetail, setCancelDetail] = useState("")
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
const [planPage, setPlanPage] = useState<0 | 1>(0)
@@ -578,16 +621,11 @@ export default function Billing() {
? (`api_${currentPlan}` as const)
: null
- const currentPlanCard = [...PLAN_CARDS, ...ADVANCED_PLAN_CARDS].find(
- (p) => p.id === currentPlan,
- )
- const cancelLossItems = currentPlanCard
- ? [
- `${currentPlanCard.credits}/mo included credits`,
- ...currentPlanCard.features.filter((f) => !/credit/i.test(f)),
- ]
- : []
- const canConfirmCancel = cancelConfirmText.trim().toUpperCase() === "CANCEL"
+ const cancelNeedsDetail =
+ cancelReason != null && cancelReasonNeedsDetail(cancelReason)
+ const canConfirmCancel =
+ cancelReason != null &&
+ (!cancelNeedsDetail || cancelDetail.trim().length > 0)
const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
const isPlanCanceling = canceledSub != null
@@ -627,6 +665,11 @@ export default function Billing() {
}
}
+ const resetCancelForm = () => {
+ setCancelReason(null)
+ setCancelDetail("")
+ }
+
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
@@ -635,9 +678,18 @@ export default function Billing() {
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
+ if (posthog?.__loaded) {
+ posthog.capture("subscription_cancelled", {
+ reason: cancelReason,
+ reason_detail: cancelDetail.trim() || null,
+ plan: currentPlan,
+ plan_id: cancellablePlanId,
+ surface: "nova",
+ })
+ }
autumn.refetch?.()
setIsCancelDialogOpen(false)
- setCancelConfirmText("")
+ resetCancelForm()
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
@@ -934,7 +986,7 @@ export default function Billing() {
open={isCancelDialogOpen}
onOpenChange={(open) => {
setIsCancelDialogOpen(open)
- if (!open) setCancelConfirmText("")
+ if (!open) resetCancelForm()
}}
>
@@ -950,7 +1002,7 @@ export default function Billing() {
@@ -985,74 +1037,177 @@ export default function Billing() {
- {cancelLossItems.length > 0 ? (
-
-
- You'll lose
-
-
- {cancelLossItems.map((item) => (
- -
+
+
+
+
+
+ or
+
+
+
+
+
+ Why are you leaving?
+
+
+ {CANCEL_REASONS.map((option) => {
+ const selected = cancelReason === option.value
+ return (
+
+ )
+ })}
+
+ {cancelReason !== null ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
- ) : null}
-
-
- setCancelConfirmText(e.target.value)}
- placeholder="CANCEL"
- type="text"
- value={cancelConfirmText}
- />
-
-
-
-
-
-
diff --git a/apps/web/components/settings/cancel-reasons.ts b/apps/web/components/settings/cancel-reasons.ts
new file mode 100644
index 00000000..9378193b
--- /dev/null
+++ b/apps/web/components/settings/cancel-reasons.ts
@@ -0,0 +1,19 @@
+export const CANCEL_REASONS = [
+ { value: "too_expensive", label: "Too expensive" },
+ { value: "missing_features", label: "Missing features I need" },
+ { value: "switching", label: "Found a better alternative" },
+ { value: "not_using", label: "Not using it enough" },
+ { value: "other", label: "Other" },
+] as const
+
+export type CancelReasonValue = (typeof CANCEL_REASONS)[number]["value"]
+
+const NEEDS_DETAIL: CancelReasonValue[] = [
+ "missing_features",
+ "switching",
+ "other",
+]
+
+export function cancelReasonNeedsDetail(value: CancelReasonValue): boolean {
+ return NEEDS_DETAIL.includes(value)
+}
diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx
index cb3d5e82..98cb318e 100644
--- a/apps/web/components/settings/settings-content.tsx
+++ b/apps/web/components/settings/settings-content.tsx
@@ -123,13 +123,11 @@ export function SettingsContent({
onTabChange,
className,
showIdentity = true,
- onClose,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => 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 ee4a5784..89c52a3f 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/hooks/use-space-profile.ts b/apps/web/hooks/use-space-profile.ts
index 9ee2114d..8883625f 100644
--- a/apps/web/hooks/use-space-profile.ts
+++ b/apps/web/hooks/use-space-profile.ts
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"
-import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
export type SpaceProfile = {
@@ -7,6 +6,16 @@ export type SpaceProfile = {
dynamic: string[]
}
+const API_BASE =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+type SpaceProfileResponse = {
+ profile?: {
+ static?: string[] | null
+ dynamic?: string[] | null
+ } | null
+}
+
export function useSpaceProfile(containerTag: string) {
const { org } = useAuth()
const orgId = org?.id ?? ""
@@ -14,21 +23,32 @@ export function useSpaceProfile(containerTag: string) {
return useQuery({
queryKey: ["space-profile", orgId, containerTag],
queryFn: async (): Promise => {
- const response = await $fetch(
- "@get/container-tags/:containerTag/profile",
- {
- params: { containerTag },
+ const response = await fetch(`${API_BASE}/v4/profile`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "X-App-Source": "nova",
},
- )
- if (response.error) {
+ body: JSON.stringify({ containerTag }),
+ })
+
+ if (!response.ok) {
+ const body = (await response.json().catch(() => ({}))) as {
+ error?: string
+ message?: string
+ }
throw new Error(
- response.error.message || "Failed to load space profile",
+ body.message ?? body.error ?? "Failed to load space profile",
)
}
- const profile = response.data.profile
+
+ const data = (await response.json()) as SpaceProfileResponse
+ const profile = data.profile
+
return {
- static: profile.static ?? [],
- dynamic: profile.dynamic ?? [],
+ static: profile?.static ?? [],
+ dynamic: profile?.dynamic ?? [],
}
},
enabled: !!orgId && !!containerTag,
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index f9f1dfee..ea2742aa 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 6848203c..4653f7f9 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: [
{
@@ -115,7 +115,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: [
@@ -144,7 +144,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",
@@ -164,7 +164,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",
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
index 4715c571..2094e5aa 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -31,6 +31,11 @@ export default async function proxy(request: Request) {
return NextResponse.next()
}
+ // Integrations index is public in guest mode; actions still require login.
+ if (url.pathname === "/" && url.searchParams.get("view") === "integrations") {
+ return NextResponse.next()
+ }
+
if (url.pathname.startsWith("/api/")) {
if (!sessionCookie) {
console.debug("[MIDDLEWARE] API route without session, returning 401")
diff --git a/apps/web/package.json b/apps/web/package.json
index 3535d9a4..cd4e10b3 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -2,7 +2,10 @@
"name": "@repo/web",
"version": "0.1.0",
"private": true,
- "portless": { "name": "app.dev.supermemory", "script": "dev:app" },
+ "portless": {
+ "name": "app.dev.supermemory",
+ "script": "dev:app"
+ },
"scripts": {
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
@@ -73,6 +76,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -117,6 +121,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
+ "@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
diff --git a/bun.lock b/bun.lock
index 7f815b13..40d15a9e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -186,6 +186,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -230,6 +231,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
+ "@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
@@ -1981,6 +1983,8 @@
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
+ "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
+
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/chrome": ["@types/chrome@0.1.37", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-IJE4ceuDO7lrEuua7Pow47zwNcI8E6qqkowRP7aFPaZ0lrjxh6y836OPqqkIZeTX64FTogbw+4RNH0+QrweCTQ=="],
@@ -2479,6 +2483,8 @@
"canvas-color-tracker": ["canvas-color-tracker@1.3.2", "", { "dependencies": { "tinycolor2": "^1.6.0" } }, "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg=="],
+ "canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index ab721b39..01439d1e 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -7,7 +7,7 @@ const ADD_MEMORY_SHORTCUT_URL =
const RAYCAST_EXTENSION_URL = "https://www.raycast.com/supermemory/supermemory"
const CHROME_EXTENSION_URL =
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc"
-const POKE_RECIPE_URL = "https://poke.com/r/5tHPbS8gZvA"
+const POKE_RECIPE_URL = "https://supermemory.link/poke"
export {
BIG_DIMENSIONS_NEW,