supermemory/apps/web/components/integrations-view.tsx
MaheshtheDev 1e1b0b1a37 feat(web): make /integrations a real route with connect deeplinks (#1155)
- Promote integrations from ?view=integrations to real /integrations and nested /integrations/[card] routes; the page body is shared via AppExperience and useViewMode is path-aware.
- Legacy ?view= URLs (and /settings/integrations) redirect to the new routes for back-compat; middleware/ensure-workspace allow the public routes.
- Add ?connect=<plugin|provider> deeplink that opens a card's connect modal instantly with a loading state (e.g. Hermes API key).
2026-06-23 20:42:29 +00:00

4074 lines
107 KiB
TypeScript

"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { hasActivePlan } from "@lib/queries"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import type {
ConnectionResponseSchema,
DocumentsWithMemoriesResponseSchema,
} from "@repo/validation/api"
import type { z } from "zod"
import { Button } from "@ui/components/button"
import {
ChromeIcon,
AppleShortcutsIcon,
RaycastIcon,
} from "@/components/integration-icons"
import {
GoogleDrive,
Notion,
OneDrive,
MCPIcon,
Granola,
} from "@ui/assets/icons"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
ArrowLeft,
ArrowRight,
BookOpen,
Check,
ChevronDown,
ExternalLink,
FileText,
Globe,
Info,
Loader,
Plus,
Search,
X,
Zap,
} from "lucide-react"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { CHROME_EXTENSION_URL, POKE_RECIPE_URL } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import Image from "next/image"
import { useViewMode } from "@/lib/view-mode-context"
import type { ViewParamValue } from "@/lib/search-params"
import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"
import { addDocumentParam, docParam } from "@/lib/search-params"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { AnimatePresence, motion } from "motion/react"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import {
PLUGIN_CATALOG,
FREE_TIER_PLUGIN_IDS,
isFreeTierPlugin,
normalizePluginClientId,
type InstallStep,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
import {
ShortcutsConnectButtons,
useShortcutsConnect,
} from "./integrations/shortcuts-detail"
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
import { GranolaConnectModal } from "./granola-connect-modal"
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
type Connection = z.infer<typeof ConnectionResponseSchema>
type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "granola"
interface ConnectedKey {
keyId: string
keyStart: string | null
pluginId: string
lastRequest?: string | null
createdAt?: string | null
}
function toIsoDate(value: string | Date | null | undefined): string | null {
if (!value) return null
const d = value instanceof Date ? value : new Date(value)
if (Number.isNaN(d.getTime())) return 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,
): { active: ConnectedKey[]; setup: ConnectedKey[] } {
const active: ConnectedKey[] = []
const setup: ConnectedKey[] = []
for (const key of apiKeys) {
if (key.enabled === false) continue
if (!key.metadata) continue
try {
const metadata =
typeof key.metadata === "string"
? (JSON.parse(key.metadata) as {
sm_type?: string
sm_client?: string
})
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue
const entry: ConnectedKey = {
keyId: key.id,
keyStart: keyPrefix(key),
pluginId: normalizePluginClientId(metadata.sm_client),
lastRequest: toIsoDate(key.lastRequest),
createdAt: toIsoDate(key.createdAt),
}
if (key.lastRequest) active.push(entry)
else setup.push(entry)
} catch {}
}
return { active, setup }
}
type ListedApiKey = {
id: string
name?: string | null
createdAt?: string | Date | null
enabled?: boolean
lastRequest?: string | Date | null
metadata: string | Record<string, unknown> | null
start?: string | null
}
type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import"
type MCPClientKey =
| "antigravity"
| "chatgpt"
| "codex"
| "cursor"
| "claude"
| "vscode"
| "cline"
| "gemini-cli"
| "claude-code"
| "mcp-url"
const MCP_CLIENTS: Array<{
key: MCPClientKey
name: string
tagline: string
simpleTitle?: string
dev?: boolean
}> = [
{
key: "antigravity",
name: "Antigravity",
tagline: "MCP config for Antigravity",
simpleTitle: "Bring your memory into Antigravity",
dev: true,
},
{
key: "cursor",
name: "Cursor",
tagline: "One-click MCP install in Cursor",
simpleTitle: "Code with your saved knowledge nearby",
},
{
key: "claude",
name: "Claude Desktop",
tagline: "Connect supermemory in Claude Desktop",
simpleTitle: "Reference your notes during any Claude chat",
},
{
key: "chatgpt",
name: "ChatGPT",
tagline: "Apps via ChatGPT developer mode",
simpleTitle: "Let ChatGPT recall what you've saved",
},
{
key: "vscode",
name: "VS Code",
tagline: "Native MCP support in VS Code",
simpleTitle: "Pull your knowledge into VS Code while coding",
dev: true,
},
{
key: "cline",
name: "Cline",
tagline: "MCP via the Cline VS Code extension",
simpleTitle: "Cline can read and add to your memory",
dev: true,
},
{
key: "gemini-cli",
name: "Gemini CLI",
tagline: "Google Gemini terminal client",
simpleTitle: "Bring your memory into Gemini sessions",
dev: true,
},
{
key: "codex",
name: "Codex (MCP)",
tagline: "OpenAI Codex CLI via MCP config",
simpleTitle: "Codex with access to your saved context",
dev: true,
},
{
key: "claude-code",
name: "Claude Code (MCP)",
tagline: "Connect via Claude Code MCP config",
simpleTitle: "Claude Code with your project context",
dev: true,
},
{
key: "mcp-url",
name: "MCP URL",
tagline: "Use the URL in any custom MCP client",
simpleTitle: "Connect any MCP-capable app to supermemory",
dev: true,
},
]
function mcpClientIconSrc(key: MCPClientKey): string {
if (key === "mcp-url") return "/mcp-icon.svg"
if (key === "antigravity") return "/mcp-supported-tools/antigravity.png"
const file = key === "claude-code" ? "claude" : key
return `/mcp-supported-tools/${file}.png`
}
const PLUGIN_SIMPLE_TITLES: Record<string, string> = {
claude_code: "Remembers your code conventions and decisions",
codex: "Codex sessions that remember your project",
cursor: "Cursor sessions with persistent project memory",
opencode: "OpenCode with persistent project memory",
openclaw: "Save chats from Telegram, Discord and Slack",
hermes: "Persistent memory for the Hermes agent",
}
type CategoryFilter =
| "all"
| "connected"
| "plugins"
| "knowledge-bases"
| "apps-extensions"
| "ai-clients"
const CATEGORY_VALUES: readonly CategoryFilter[] = [
"all",
"connected",
"ai-clients",
"plugins",
"knowledge-bases",
"apps-extensions",
] as const
const catParam = parseAsStringEnum<CategoryFilter>([
...CATEGORY_VALUES,
]).withDefault("all")
const CATEGORY_LABEL: Record<CategoryFilter, string> = {
all: "All",
connected: "Active",
plugins: "Plugins",
"knowledge-bases": "Knowledge bases",
"apps-extensions": "Apps & extensions",
"ai-clients": "MCP",
}
const SECTION_ORDER: Array<Exclude<CategoryFilter, "all" | "connected">> = [
"ai-clients",
"plugins",
"knowledge-bases",
"apps-extensions",
]
function itemCategory(
item: Item,
): Exclude<CategoryFilter, "all" | "connected"> {
switch (item.kind) {
case "plugin":
return "plugins"
case "connector":
return "knowledge-bases"
case "mcp-client":
return "ai-clients"
case "client":
case "import":
return "apps-extensions"
}
}
interface BaseItem {
id: string
name: string
tagline: string
icon: ReactNode
docsUrl?: string
pro?: boolean
max?: boolean
kind: ItemKind
simpleTitle?: string
dev?: boolean
isNew?: boolean
}
interface PluginItem extends BaseItem {
kind: "plugin"
pluginId: string
}
interface ConnectorItem extends BaseItem {
kind: "connector"
provider: ConnectorProvider
}
interface ClientItem extends BaseItem {
kind: "client"
action:
| { type: "external"; href: string }
| { type: "view"; viewMode: ViewParamValue }
}
interface MCPClientItem extends BaseItem {
kind: "mcp-client"
clientKey: MCPClientKey
}
interface ImportItem extends BaseItem {
kind: "import"
viewMode: ViewParamValue
}
type Item = PluginItem | ConnectorItem | ClientItem | MCPClientItem | ImportItem
const SECTIONS: Array<{
label: string
items: (plugin: typeof PLUGIN_CATALOG) => Item[]
}> = [
{
label: "MCP",
items: () =>
MCP_CLIENTS.map<MCPClientItem>((c) => ({
kind: "mcp-client",
clientKey: c.key,
id: `mcp-${c.key}`,
name: c.name,
tagline: c.tagline,
simpleTitle: c.simpleTitle,
dev: c.dev,
icon:
c.key === "mcp-url" ? (
<MCPIcon className="size-6" />
) : (
<Image
src={mcpClientIconSrc(c.key)}
alt={c.name}
width={24}
height={24}
unoptimized
className="size-6 rounded object-contain"
/>
),
docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction",
})),
},
{
label: "Plugins",
items: (catalog) =>
Object.keys(catalog).map<PluginItem>((id) => {
const plugin = catalog[id]
if (!plugin) throw new Error(`Missing plugin ${id}`)
return {
kind: "plugin",
id: `plugin-${id}`,
pluginId: id,
name: plugin.name,
tagline: plugin.tagline,
icon: (
<Image
src={plugin.icon}
alt={plugin.name}
width={24}
height={24}
className="size-6 rounded"
/>
),
docsUrl: plugin.docsUrl,
pro: !FREE_TIER_PLUGIN_IDS.includes(id),
simpleTitle: PLUGIN_SIMPLE_TITLES[id],
dev: true,
}
}),
},
{
label: "Knowledge bases",
items: () => [
{
kind: "connector",
id: "google-drive",
provider: "google-drive",
name: "Google Drive",
tagline: "Sync Docs, Sheets and Slides into your memory",
simpleTitle: "Your Docs, Sheets and Slides, searchable",
icon: <GoogleDrive className="size-6" />,
pro: true,
docsUrl: "https://supermemory.ai/docs/connectors/google-drive",
},
{
kind: "connector",
id: "notion",
provider: "notion",
name: "Notion",
tagline: "Import Notion pages and databases",
simpleTitle: "All your Notion pages, in supermemory",
icon: <Notion className="size-6" />,
pro: true,
docsUrl: "https://supermemory.ai/docs/connectors/notion",
},
{
kind: "connector",
id: "onedrive",
provider: "onedrive",
name: "OneDrive",
tagline: "Bring in Office documents from OneDrive",
simpleTitle: "Your OneDrive files, ready to recall",
icon: <OneDrive className="size-6" />,
pro: true,
docsUrl: "https://supermemory.ai/docs/connectors/onedrive",
},
{
kind: "connector",
id: "granola",
provider: "granola",
name: "Granola",
tagline: "Sync AI meeting notes into your memory",
simpleTitle: "Your meeting notes, ready to recall",
icon: <Granola className="size-6" />,
pro: true,
docsUrl: "https://supermemory.ai/docs/connectors/granola",
},
],
},
{
label: "Apps & extensions",
items: () => [
{
kind: "client",
id: "chrome",
name: "Chrome Extension",
tagline: "Save webpages, import bookmarks, sync ChatGPT memories",
simpleTitle: "Save any webpage with one click",
icon: <ChromeIcon className="size-6" />,
action: { type: "external", href: CHROME_EXTENSION_URL },
},
{
kind: "client",
id: "poke",
name: "Poke",
tagline: "Recall and save memories from Poke over text",
simpleTitle: "Text Poke to recall and save your memories",
isNew: true,
icon: (
<div className="relative size-6 shrink-0 overflow-hidden rounded-md sm:size-10 sm:rounded-lg">
<Image
src="/images/poke.png"
alt="Poke"
width={40}
height={40}
className="size-full object-cover"
/>
</div>
),
action: { type: "external", href: POKE_RECIPE_URL },
},
{
kind: "client",
id: "shortcuts",
name: "Apple Shortcuts",
tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Save anything from your phone or Mac",
icon: <AppleShortcutsIcon />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
},
{
kind: "client",
id: "raycast",
name: "Raycast",
tagline: "Add and search memories from Raycast on Mac",
simpleTitle: "Save and search from Raycast on Mac",
icon: <RaycastIcon className="size-6" />,
action: { type: "view", viewMode: "raycast" as ViewParamValue },
dev: true,
},
{
kind: "import",
id: "x-bookmarks",
name: "Import X bookmarks",
tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Turn your X bookmarks into memory",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
viewMode: "import" as ViewParamValue,
},
],
},
]
export function DetailWrapper({
onBack,
children,
}: {
onBack: () => void
children: ReactNode
}) {
return (
<div className="flex-1 p-4 md:p-6 pt-2">
<div className="max-w-3xl mx-auto">
<Button
variant="link"
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer mb-4"
onClick={onBack}
>
<ArrowLeft className="size-4 mr-1" />
Back to Integrations
</Button>
{children}
</div>
</div>
)
}
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
return (
<span
className={cn(
dmSans125ClassName(),
"ml-auto shrink-0 pl-2 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]",
)}
>
{children}
</span>
)
}
function NewChip() {
return (
<span
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]",
)}
>
New
</span>
)
}
function IconBox({
children,
size = "md",
}: {
children: ReactNode
size?: "sm" | "md"
}) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]",
size === "sm" ? "size-8" : "size-8 sm:size-10",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
)}
>
{children}
</div>
)
}
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<string, InfoUseCase[]> = {
"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 (
<button
type="button"
aria-label={`View ${name} use cases and docs`}
title="Use cases and docs"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(
"absolute top-4 right-4 flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] opacity-0 transition-all hover:text-[#FAFAFA] focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100 sm:size-8",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
)}
>
<Info className="size-3.5" />
</button>
)
}
function ItemInfoDialog({
actionSlot,
docsUrl,
hideDismiss,
icon,
id,
kind,
name,
onOpenChange,
open,
}: {
actionSlot: ReactNode
docsUrl?: string
hideDismiss?: boolean
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
onOpenChange(true)
return
}
closeWithReason("dismiss")
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
closeWithReason("dismiss")
}}
className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-[4px]"
/>
<DialogPrimitive.Content
data-slot="dialog-content"
onClick={(e) => e.stopPropagation()}
onInteractOutside={(e) => e.preventDefault()}
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(),
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] duration-100",
"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]",
)}
>
<DialogTitle className="sr-only">
{name} use cases and docs
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
<IconBox>{icon}</IconBox>
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
{name}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
Use cases that apply to this Supermemory connection.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
INSET,
)}
>
<BookOpen className="size-3.5" /> Docs
</a>
)}
<button
type="button"
aria-label="Close"
onClick={() => closeWithReason("close_button")}
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
<div className="flex flex-col gap-5">
{useCases.map((useCase, index) => (
<div key={useCase.title} className="flex gap-3">
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
INSET,
)}
>
{index + 1}
</span>
{index < useCases.length - 1 && (
<span className="w-px flex-1 bg-white/[0.14]" />
)}
</div>
<div className="min-w-0">
<p className="text-[15px] font-semibold leading-tight text-[#FAFAFA]">
{useCase.title}
</p>
<p className="mt-1 text-[13px] leading-relaxed text-[#A1A1AA]">
{useCase.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 pt-1">
{!hideDismiss && (
<button
type="button"
onClick={() => closeWithReason("im_good")}
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
I'm good
</button>
)}
{/* 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. */}
<div onClick={() => closeWithReason("action")}>{actionSlot}</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</Dialog>
)
}
function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
const [confirming, setConfirming] = useState(false)
useEffect(() => {
if (!confirming) return
const t = setTimeout(() => setConfirming(false), 3000)
return () => clearTimeout(t)
}, [confirming])
return (
<button
type="button"
onClick={() => (confirming ? onConfirm() : setConfirming(true))}
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
confirming
? "bg-red-500/15 text-red-400"
: "text-[#737373] hover:bg-white/5 hover:text-red-400",
)}
>
{confirming ? "Confirm" : "Disconnect"}
</button>
)
}
function ActiveButton({
count,
lastActive,
onClick,
}: {
count: number
lastActive?: string | null
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
dmSans125ClassName(),
"group flex shrink-0 cursor-pointer items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] transition-colors sm:text-[13px]",
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
<span className="group-hover:underline">
{count > 1 ? `${count} active` : "Active"}
</span>
{count <= 1 && lastActive && (
<span className="text-[11px] font-normal text-[#737373]">
· {formatRelativeTime(lastActive)}
</span>
)}
</button>
)
}
function FinishSetupButton({ onClick }: { onClick: () => void }) {
return (
<PillButton onClick={onClick}>
<span className="size-[7px] rounded-full bg-[#EAB308]" />
Finish setup
</PillButton>
)
}
function ConnectionsCountPill({ count }: { count: number }) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] sm:text-[13px]",
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
{count > 1 ? `${count} connected` : "Connected"}
</span>
)
}
function ImportedPill({
lastImportedAt,
}: {
lastImportedAt: string | number | Date | null
}) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] sm:text-[13px]",
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Imported
{lastImportedAt && (
<span className="text-[11px] font-normal text-[#737373]">
· {formatRelativeTime(lastImportedAt)}
</span>
)}
</span>
)
}
const CONNECTOR_META: Record<
ConnectorProvider,
{ name: string; icon: ReactNode; documentLabel: string }
> = {
"google-drive": {
name: "Google Drive",
icon: <GoogleDrive className="size-6" />,
documentLabel: "documents",
},
notion: {
name: "Notion",
icon: <Notion className="size-6" />,
documentLabel: "pages",
},
onedrive: {
name: "OneDrive",
icon: <OneDrive className="size-6" />,
documentLabel: "documents",
},
granola: {
name: "Granola",
icon: <Granola className="size-6" />,
documentLabel: "notes",
},
}
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<string, unknown> | 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 (
<div
className={cn(
dmSans125ClassName(),
"flex items-center justify-between gap-3 text-[11px]",
)}
>
<span className="shrink-0 text-[#737373]">{label}</span>
<span className="min-w-0 truncate text-right text-[#A1A1AA]">
{value}
</span>
</div>
)
}
function RailAction({
label,
onClick,
danger,
}: {
label: string
onClick: () => void
danger?: boolean
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(
dmSans125ClassName(),
"rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
danger
? "bg-[#EF4444]/15 text-[#EF4444] hover:bg-[#EF4444]/25"
: "bg-[#0D121A] text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
{label}
</button>
)
}
function RailRow({
icon,
name,
statusLine,
expanded,
onToggle,
children,
}: {
icon: ReactNode
name: string
statusLine: ReactNode
expanded: boolean
onToggle: () => void
children: ReactNode
}) {
return (
<div
className={cn(
"overflow-hidden rounded-[12px] bg-[#14161A]",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<button
type="button"
onClick={onToggle}
className="group flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-[#16181D]"
>
<IconBox size="sm">{icon}</IconBox>
<div className="min-w-0 flex-1">
<span
className={cn(
dmSans125ClassName(),
"block min-w-0 truncate text-[13px] font-medium text-[#FAFAFA]",
)}
>
{name}
</span>
<div className="mt-0.5 min-w-0">{statusLine}</div>
</div>
<ChevronDown
className={cn(
"size-4 shrink-0 text-[#525D6E] transition-transform group-hover:text-[#A1A1AA]",
expanded && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 border-t border-white/[0.06] px-3 py-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function ActiveStatusDot() {
return (
<>
<span className="size-[6px] shrink-0 rounded-full bg-[#00AC3F]" />
<span
className={cn(
dmSans125ClassName(),
"shrink-0 text-[11px] font-medium text-[#00AC3F]",
)}
>
Active
</span>
</>
)
}
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 (
<RailRow
icon={entry.icon}
name={entry.name}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
statusLine={
<div className="flex min-w-0 items-center gap-1.5">
<ActiveStatusDot />
{suffix && (
<span
className={cn(
dmSans125ClassName(),
"min-w-0 truncate text-[11px] text-[#737373]",
)}
>
· {suffix}
</span>
)}
</div>
}
>
{entry.createdAt && (
<RailDetail
label="Connected"
value={formatRelativeTime(entry.createdAt)}
/>
)}
{entry.lastActive && (
<RailDetail
label="Last active"
value={formatRelativeTime(entry.lastActive)}
/>
)}
<RailDetail label="Agents" value={`${entry.agentCount} connected`} />
<div className="mt-1 flex flex-wrap gap-1.5">
<RailAction label="Manage" onClick={entry.onManage} />
</div>
</RailRow>
)
}
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 (
<RailRow
icon={entry.icon}
name={entry.name}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
statusLine={
<div className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
"size-[6px] shrink-0 rounded-full",
status === "syncing" && "animate-pulse",
)}
style={{ backgroundColor: color }}
/>
<span
className={cn(
dmSans125ClassName(),
"shrink-0 text-[11px] font-medium",
)}
style={{ color }}
>
{label}
</span>
{statusParts.length > 0 && (
<span
className={cn(
dmSans125ClassName(),
"min-w-0 truncate text-[11px] text-[#737373]",
)}
>
· {statusParts.join(" · ")}
</span>
)}
</div>
}
>
{entry.email && <RailDetail label="Account" value={entry.email} />}
{entry.createdAt && (
<RailDetail label="Added" value={formatRelativeTime(entry.createdAt)} />
)}
{meta.lastSyncedAt && (
<RailDetail
label="Last synced"
value={formatRelativeTime(meta.lastSyncedAt)}
/>
)}
{meta.documentCount > 0 && (
<RailDetail
label="Synced"
value={`${meta.documentCount} ${entry.documentLabel}`}
/>
)}
{entry.spaceName && <RailDetail label="Space" value={entry.spaceName} />}
{needsReauth && (
<RailDetail
label="Status"
value={<span className="text-[#EF4444]">Reconnect needed</span>}
/>
)}
<div className="mt-1 flex flex-wrap gap-1.5">
{needsReauth && (
<RailAction label="Reconnect" danger onClick={entry.onReconnect} />
)}
<RailAction label="Manage" onClick={entry.onManage} />
</div>
</RailRow>
)
}
const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"]
function RailSkeleton({ rows }: { rows: number }) {
return (
<div className="flex flex-col gap-2">
{SKELETON_KEYS.slice(0, rows).map((k) => (
<div
key={k}
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] p-3"
>
<div className="size-8 shrink-0 animate-pulse rounded-md bg-white/[0.04]" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/[0.06]" />
<div className="h-2 w-2/5 animate-pulse rounded bg-white/[0.04]" />
</div>
</div>
))}
</div>
)
}
function RailEmpty({
icon,
title,
hint,
}: {
icon: ReactNode
title: string
hint: string
}) {
return (
<div className="flex flex-col items-center gap-1.5 px-3 py-7 text-center">
<div className="flex size-9 items-center justify-center rounded-full bg-[#0D121A] text-[#525D6E]">
{icon}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[12px] font-medium text-[#A1A1AA]",
)}
>
{title}
</p>
<p className={cn(dmSans125ClassName(), "text-[11px] text-[#525D6E]")}>
{hint}
</p>
</div>
)
}
function ActiveConnectionsRail({
entries,
loading,
className,
}: {
entries: RailEntry[]
loading?: boolean
className?: string
}) {
return (
<aside
className={cn(
"flex flex-col gap-3 rounded-[14px] bg-[#191D24] p-4 sm:p-5",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
className,
)}
>
<div className="flex items-center justify-between gap-2">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#FAFAFA]",
)}
>
Active connections
</h3>
<div className="flex shrink-0 items-center gap-2">
{loading && entries.length > 0 && (
<Loader className="size-3.5 animate-spin text-[#525D6E]" />
)}
{entries.length > 0 && (
<span
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 text-[12px] font-medium text-[#00AC3F]",
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
{entries.length}
</span>
)}
</div>
</div>
{entries.length > 0 ? (
<div className="flex flex-col gap-2">
{entries.map((entry) =>
entry.kind === "plugin" ? (
<PluginRailRow key={entry.id} entry={entry} />
) : (
<ConnectorRailRow key={entry.id} entry={entry} />
),
)}
</div>
) : loading ? (
<RailSkeleton rows={3} />
) : (
<RailEmpty
icon={<Zap className="size-4" />}
title="No active connections yet"
hint="Connect a tool below to see it here"
/>
)}
</aside>
)
}
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<ConnectorProvider, ReactNode> = {
"google-drive": <GoogleDrive className="size-3.5" />,
notion: <Notion className="size-3.5" />,
onedrive: <OneDrive className="size-3.5" />,
granola: <Granola className="size-3.5" />,
}
function pluginIconNode(iconSrc: string | null): ReactNode {
if (!iconSrc) return <FileText className="size-3.5 text-[#737373]" />
return (
<Image
src={iconSrc}
alt=""
width={14}
height={14}
className="size-3.5 rounded-sm"
/>
)
}
function resolveDocSource(
doc: RecentDoc,
connectionSource: Map<string, ConnectorProvider>,
): { 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<string, unknown> | null | undefined,
doc.source,
)
if (cc) {
return { label: cc.label, icon: pluginIconNode(cc.iconSrc) }
}
if (doc.source === "mcp") {
return { label: "MCP", icon: <MCPIcon className="size-3.5" /> }
}
const type = (doc.type ?? "").toLowerCase()
if (type.includes("notion")) {
return { label: "Notion", icon: <Notion className="size-3.5" /> }
}
if (
type.includes("google") ||
type.includes("gdrive") ||
type.includes("drive")
) {
return { label: "Google Drive", icon: <GoogleDrive className="size-3.5" /> }
}
if (type.includes("onedrive") || type.includes("microsoft")) {
return { label: "OneDrive", icon: <OneDrive className="size-3.5" /> }
}
if (type.includes("granola")) {
return { label: "Granola", icon: <Granola className="size-3.5" /> }
}
const host = hostnameOf(doc.url)
if (host) {
return { label: host, icon: <Globe className="size-3.5 text-[#737373]" /> }
}
return {
label: "Note",
icon: <FileText className="size-3.5 text-[#737373]" />,
}
}
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<string, ConnectorProvider>
onOpen: () => void
}) {
const { label, icon } = resolveDocSource(doc, connectionSource)
const title = docDisplayTitle(doc, label)
return (
<button
type="button"
onClick={onOpen}
className="group flex w-full items-center gap-2.5 rounded-[10px] px-2 py-2 text-left transition-colors hover:bg-[#14161A]"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#0D121A] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{icon}
</span>
<div className="min-w-0 flex-1">
<span
className={cn(
dmSans125ClassName(),
"block min-w-0 truncate text-[12px] text-[#E5E5E5] transition-colors group-hover:text-white",
)}
>
{title}
</span>
<span
className={cn(
dmSans125ClassName(),
"mt-0.5 flex min-w-0 items-center gap-1 text-[10px] text-[#737373]",
)}
>
<span className="truncate">{label}</span>
<span className="shrink-0">·</span>
<span className="shrink-0">{formatRelativeTime(doc.createdAt)}</span>
</span>
</div>
{doc.url ? (
<ExternalLink className="size-3 shrink-0 text-[#525D6E] transition-colors group-hover:text-[#A1A1AA]" />
) : (
<ArrowRight className="size-3 shrink-0 text-[#525D6E] transition-colors group-hover:text-[#A1A1AA]" />
)}
</button>
)
}
function RecentlyAddedCard({
docs,
connectionSource,
loading,
onOpenDoc,
onViewAll,
className,
}: {
docs: RecentDoc[]
connectionSource: Map<string, ConnectorProvider>
loading?: boolean
onOpenDoc: (doc: RecentDoc) => void
onViewAll: () => void
className?: string
}) {
return (
<aside
className={cn(
"flex flex-col gap-2 rounded-[14px] bg-[#191D24] p-4 sm:p-5",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
className,
)}
>
<div className="flex items-center justify-between gap-2">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#FAFAFA]",
)}
>
Recently added
</h3>
{docs.length > 0 && (
<button
type="button"
onClick={onViewAll}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1 text-[11px] text-[#737373] transition-colors hover:text-[#FAFAFA]",
)}
>
View all
<ArrowRight className="size-3" />
</button>
)}
</div>
{docs.length > 0 ? (
<div className="scrollbar-none flex max-h-[320px] flex-col gap-0.5 overflow-y-auto">
{docs.map((doc) => (
<RecentDocRow
key={doc.id ?? doc.customId}
doc={doc}
connectionSource={connectionSource}
onOpen={() => onOpenDoc(doc)}
/>
))}
</div>
) : loading ? (
<RailSkeleton rows={4} />
) : (
<RailEmpty
icon={<FileText className="size-4" />}
title="No memories yet"
hint="Anything you save will show up here"
/>
)}
</aside>
)
}
type MobileActivityTab = "active" | "recent"
function MobileActivityPanel({
entries,
docs,
connectionSource,
activeLoading,
recentsLoading,
onOpenDoc,
onViewAll,
}: {
entries: RailEntry[]
docs: RecentDoc[]
connectionSource: Map<string, ConnectorProvider>
activeLoading?: boolean
recentsLoading?: boolean
onOpenDoc: (doc: RecentDoc) => void
onViewAll: () => void
}) {
const [tab, setTab] = useState<MobileActivityTab>("active")
const hasActiveTab = activeLoading || entries.length > 0
const hasRecentTab = recentsLoading || docs.length > 0
const showTabs = hasActiveTab && hasRecentTab
const activeTab: MobileActivityTab =
hasActiveTab && (tab === "active" || !hasRecentTab) ? "active" : "recent"
useEffect(() => {
if (!hasActiveTab && hasRecentTab && tab === "active") {
setTab("recent")
}
if (!hasRecentTab && hasActiveTab && tab === "recent") {
setTab("active")
}
}, [hasActiveTab, hasRecentTab, tab])
const tabClass = (value: MobileActivityTab) =>
cn(
dmSans125ClassName(),
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
activeTab === value
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)
return (
<aside
className={cn(
"rounded-[14px] bg-[#191D24] p-3",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<div className="flex items-center justify-between gap-3">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#FAFAFA]",
)}
>
Activity
</h3>
{activeTab === "recent" && docs.length > 0 && (
<button
type="button"
onClick={onViewAll}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1 text-[11px] text-[#737373] transition-colors hover:text-[#FAFAFA]",
)}
>
View all
<ArrowRight className="size-3" />
</button>
)}
</div>
{showTabs && (
<div
className={cn(
"mt-3 flex items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
>
<button
type="button"
onClick={() => setTab("active")}
className={tabClass("active")}
>
Active
{entries.length > 0 && (
<span className="text-[10px] font-semibold tabular-nums text-[#00AC3F]">
{entries.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setTab("recent")}
className={tabClass("recent")}
>
Recent
{docs.length > 0 && (
<span className="text-[10px] font-semibold tabular-nums text-[#525D6E]">
{docs.length}
</span>
)}
</button>
</div>
)}
<div className="mt-2.5">
{activeTab === "active" ? (
entries.length > 0 ? (
<div className="scrollbar-none flex max-h-[158px] flex-col gap-2 overflow-y-auto pr-0.5">
{entries.map((entry) =>
entry.kind === "plugin" ? (
<PluginRailRow key={entry.id} entry={entry} />
) : (
<ConnectorRailRow key={entry.id} entry={entry} />
),
)}
</div>
) : activeLoading ? (
<RailSkeleton rows={2} />
) : (
<RailEmpty
icon={<Zap className="size-4" />}
title="No active connections yet"
hint="Connect a tool below to see it here"
/>
)
) : docs.length > 0 ? (
<div className="scrollbar-none flex max-h-[142px] flex-col gap-0.5 overflow-y-auto pr-0.5">
{docs.map((doc) => (
<RecentDocRow
key={doc.id ?? doc.customId}
doc={doc}
connectionSource={connectionSource}
onOpen={() => onOpenDoc(doc)}
/>
))}
</div>
) : recentsLoading ? (
<RailSkeleton rows={3} />
) : (
<RailEmpty
icon={<FileText className="size-4" />}
title="No memories yet"
hint="Anything you save will show up here"
/>
)}
</div>
</aside>
)
}
function ItemCard({
actionSlot,
infoActionSlot,
icon,
id,
kind,
name,
tagline,
pro,
max,
isNew,
docsUrl,
leftIndicator,
statusSlot,
}: {
actionSlot: ReactNode
infoActionSlot?: ReactNode
icon: ReactNode
id: string
kind: ItemKind
name: string
tagline: string
pro?: boolean
max?: boolean
isNew?: boolean
docsUrl?: string
leftIndicator?: 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.
<div
role="button"
tabIndex={0}
onClick={() => setInfoOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setInfoOpen(true)
}
}}
className={cn(
"group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
<ItemInfoDialog
actionSlot={infoActionSlot ?? actionSlot}
docsUrl={docsUrl}
hideDismiss={id === "shortcuts"}
icon={icon}
id={id}
kind={kind}
name={name}
open={infoOpen}
onOpenChange={setInfoOpen}
/>
<div className="flex shrink-0 items-start justify-between gap-2">
<IconBox>{icon}</IconBox>
</div>
<div className="flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1">
{leftIndicator}
<span
className={cn(
dmSans125ClassName(),
"min-w-0 truncate text-[13px] font-medium text-[#FAFAFA] sm:text-[14px]",
)}
>
{name}
</span>
{isNew && <NewChip />}
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-1 hidden text-[12px] leading-snug text-[#A1A1AA] sm:line-clamp-2 sm:block",
)}
>
{tagline}
</p>
</div>
<div className="flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between">
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
<div
className="hidden min-w-0 flex-1 sm:flex"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{statusSlot}
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
<div
className="flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{actionSlot}
</div>
</div>
</div>
</div>
)
}
interface FeaturedPick {
id: string
name: string
emoji?: string
headline: string
support: string
tagline: string
icon: ReactNode
backdrop?: ReactNode
docsUrl?: string
ctaLabel: string
onCta: () => void
}
const FEATURED_ROTATE_MS = 7000
function FeaturedHero({ picks }: { picks: FeaturedPick[] }) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
const [manualOverride, setManualOverride] = useState(false)
useEffect(() => {
if (picks.length <= 1) return
if (paused || manualOverride) return
const t = setInterval(() => {
setIndex((i) => (i + 1) % picks.length)
}, FEATURED_ROTATE_MS)
return () => clearInterval(t)
}, [picks.length, paused, manualOverride])
if (picks.length === 0) return null
const pick = picks[index] ?? picks[0]
if (!pick) return null
return (
// biome-ignore lint/a11y/useSemanticElements: card wraps nested dot buttons, so it can't be a <button>
<div
role="button"
tabIndex={0}
onClick={pick.onCta}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
pick.onCta()
}
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className={cn(
"group relative flex w-full flex-col items-start justify-center overflow-hidden rounded-[14px] px-5 py-4 text-left cursor-pointer sm:px-6 sm:py-6",
"bg-[#191D24]",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
"min-h-[150px] sm:min-h-[160px]",
)}
>
{pick.backdrop && (
<AnimatePresence mode="wait">
<motion.div
key={`bg-${pick.id}`}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 8 }}
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-[72%] overflow-hidden sm:w-2/3"
>
<div className="absolute -right-16 -top-12 opacity-35 sm:right-0 sm:-top-4 sm:opacity-55">
{pick.backdrop}
</div>
<div className="absolute inset-0 bg-gradient-to-l from-transparent via-[#191D24]/70 to-[#191D24]" />
<div className="absolute inset-0 bg-gradient-to-t from-[#191D24]/60 via-transparent to-transparent" />
</motion.div>
</AnimatePresence>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.06] mix-blend-overlay"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
}}
/>
<span
className={cn(
dmSans125ClassName(),
"relative z-1 mb-3 inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] text-[#06121A]",
"shadow-[0_0_14px_rgba(54,253,253,0.30)]",
)}
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
}}
>
Featured
</span>
{picks.length > 1 && (
<div className="absolute right-5 top-5 sm:right-6 sm:top-6 z-1 flex shrink-0 items-center gap-1.5">
{picks.map((p, i) => {
const active = i === index
return (
<button
key={p.id}
type="button"
aria-label={`Show featured ${p.name}`}
onClick={(e) => {
e.stopPropagation()
setIndex(i)
setManualOverride(true)
}}
className={cn(
"rounded-full transition-all cursor-pointer",
active
? "w-3 h-1 bg-[#4BA0FA]"
: "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
)}
/>
)
})}
</div>
)}
<AnimatePresence mode="wait">
<motion.div
key={pick.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -3 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="relative flex max-w-[72%] flex-col gap-1.5 sm:max-w-[55%]"
>
<h3
className={cn(
dmSans125ClassName(),
"text-[17px] font-semibold leading-tight tracking-[-0.15px] text-[#FAFAFA] sm:text-[18px]",
)}
>
{pick.headline}
</h3>
<p
className={cn(
dmSans125ClassName(),
"line-clamp-2 text-[12px] leading-snug text-[#A1A1AA] sm:text-[13px]",
)}
>
<span className="font-medium text-[#CBD5E1]">
{pick.emoji ? `${pick.emoji} ` : ""}
{pick.name}
</span>{" "}
· {pick.support}
</p>
</motion.div>
</AnimatePresence>
</div>
)
}
function SearchToggle({
value,
onChange,
expanded,
setExpanded,
}: {
value: string
onChange: (next: string) => void
expanded: boolean
setExpanded: (next: boolean) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const open = () => {
setExpanded(true)
requestAnimationFrame(() => inputRef.current?.focus())
}
const close = () => {
onChange("")
setExpanded(false)
}
if (!expanded && !value) {
return (
<button
type="button"
aria-label="Search integrations"
onClick={open}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
>
<Search className="size-3.5" />
</button>
)
}
return (
<div
className={cn(
"flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-full bg-[#0D121A] px-3",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
>
<Search className="size-3.5 shrink-0 text-[#A1A1AA]" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") close()
}}
onBlur={() => {
if (!value) setExpanded(false)
}}
placeholder="Search integrations"
className={cn(
dmSans125ClassName(),
"min-w-0 flex-1 bg-transparent text-[12px] text-[#FAFAFA] placeholder:text-[#525D6E] focus:outline-none",
)}
/>
{value && (
<button
type="button"
aria-label="Clear search"
onClick={close}
className="shrink-0 text-[#737373] transition-colors hover:text-[#FAFAFA]"
>
<X className="size-3.5" />
</button>
)}
</div>
)
}
function CategoryFilterToggle({
value,
onChange,
counts,
compact,
}: {
value: CategoryFilter
onChange: (next: CategoryFilter) => void
counts: Record<CategoryFilter, number>
compact?: boolean
}) {
const visible = compact
? [value]
: CATEGORY_VALUES.filter((v) => v === "all" || counts[v] > 0)
return (
<div
className={cn(
"scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
>
{visible.map((v) => {
const active = value === v
return (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={cn(
dmSans125ClassName(),
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
active
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
{CATEGORY_LABEL[v]}
{v !== "all" && (
<span
className={cn(
"text-[10px] font-semibold tabular-nums",
active ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{counts[v]}
</span>
)}
</button>
)
})}
</div>
)
}
function SectionRail({
label,
children,
}: {
label: string
children: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const update = useCallback(() => {
const el = scrollRef.current
if (!el) return
setCanScrollLeft(el.scrollLeft > 4)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
useEffect(() => {
update()
const el = scrollRef.current
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("scrollend", update)
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
el.removeEventListener("scrollend", update)
ro.disconnect()
}
}, [update])
const scrollBy = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
setTimeout(update, 450)
}
const arrowClass = cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
)
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
)}
>
{label}
</h3>
<div className="hidden items-center gap-1.5 sm:flex">
<button
type="button"
aria-label="Show previous"
disabled={!canScrollLeft}
onClick={() => scrollBy(-1)}
className={arrowClass}
>
<ArrowLeft className="size-3.5" />
</button>
<button
type="button"
aria-label="Show more"
disabled={!canScrollRight}
onClick={() => scrollBy(1)}
className={arrowClass}
>
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
<div
ref={scrollRef}
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
>
{children}
</div>
</section>
)
}
export function IntegrationsView({
publicMode = false,
onOpenDocument,
}: {
publicMode?: boolean
onOpenDocument?: (doc: RecentDoc) => void
}) {
const { setViewMode } = useViewMode()
const queryClient = useQueryClient()
const { org } = useAuth()
const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const hasProProduct =
!publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
const isAutumnLoading = !publicMode && autumn.isLoading
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
const [newKey, setNewKey] = useState<{
open: boolean
key: string
pluginId: string | null
loading: boolean
}>({ open: false, key: "", pluginId: null, loading: false })
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
null,
)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
)
const { data: pluginsData } = useQuery({
queryFn: async () => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/plugins`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to fetch plugins")
return (await res.json()) as { plugins: string[] }
},
enabled: !publicMode,
queryKey: ["plugins"],
})
const { data: connections = [], isLoading: connectionsLoading } = useQuery({
queryKey: ["connections"],
queryFn: async () => {
const response = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (response.error)
throw new Error(response.error?.message || "Failed to load connections")
return response.data as Connection[]
},
staleTime: 30 * 1000,
enabled: !publicMode && hasProProduct,
})
const {
data: apiKeys = [],
refetch: refetchKeys,
isLoading: apiKeysLoading,
} = useQuery<ListedApiKey[]>({
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 { data: xBookmarksImport } = useQuery({
queryKey: ["x-bookmarks-import-status"],
queryFn: async () => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
categories: ["tweet"],
},
disableValidation: true,
})
if (response.error)
throw new Error(
response.error?.message || "Failed to load X bookmarks status",
)
return {
count: response.data?.pagination?.totalItems ?? 0,
lastImportedAt: response.data?.documents?.[0]?.createdAt ?? null,
}
},
staleTime: 5 * 60 * 1000,
enabled: !publicMode,
})
const tweetCount = xBookmarksImport?.count ?? 0
const lastTweetImportAt = xBookmarksImport?.lastImportedAt ?? null
const keyPrefix = useCallback((key: ListedApiKey): string | null => {
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
}, [])
const { active: activePlugins, setup: setupPlugins } = useMemo(
() => parsePluginAuthKeys(apiKeys, keyPrefix),
[apiKeys, keyPrefix],
)
const activePluginById = useMemo(() => {
const map = new Map<string, ConnectedKey>()
for (const key of activePlugins) {
const existing = map.get(key.pluginId)
if (!existing) {
map.set(key.pluginId, key)
continue
}
const a = key.lastRequest ? new Date(key.lastRequest).getTime() : 0
const b = existing.lastRequest
? new Date(existing.lastRequest).getTime()
: 0
if (a >= b) map.set(key.pluginId, key)
}
return map
}, [activePlugins])
const activeCountByPlugin = useMemo(() => {
const map = new Map<string, number>()
for (const key of activePlugins) {
map.set(key.pluginId, (map.get(key.pluginId) ?? 0) + 1)
}
return map
}, [activePlugins])
const setupPluginIds = useMemo(
() => new Set(setupPlugins.map((k) => k.pluginId)),
[setupPlugins],
)
const connectionsByProvider = useMemo(() => {
const out: Record<ConnectorProvider, Connection[]> = {
"google-drive": [],
notion: [],
onedrive: [],
granola: [],
}
for (const c of connections) {
const p = c.provider as ConnectorProvider
if (p in out) out[p].push(c)
}
return out
}, [connections])
const connectionSource = useMemo(() => {
const m = new Map<string, ConnectorProvider>()
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 =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const params = new URLSearchParams({ client: pluginId })
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"This plugin requires a Pro plan. Hermes and Codex are available on the Free plan.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(errorData.message || "Failed to create plugin key")
}
return (await res.json()) as { key: string }
},
onMutate: (pluginId) => setConnectingPlugin(pluginId),
onError: (err) => {
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
setNewKey((s) =>
s.loading
? { open: false, key: "", pluginId: null, loading: false }
: s,
)
toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error",
})
},
onSettled: () => {
setConnectingPlugin(null)
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
},
onSuccess: (data, pluginId) => {
setNewKey({ open: true, key: data.key, pluginId, loading: false })
},
})
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: [],
},
})
if ("data" in response && response.data && !("error" in response.data)) {
return response.data
}
throw new Error(response.error?.message || "Failed to connect")
},
onMutate: (provider) => {
setConnectingProvider(provider)
analytics.connectionAuthStarted({ provider })
},
onError: (err) => {
setConnectingProvider(null)
toast.error("Failed to connect", {
description: err instanceof Error ? err.message : "Unknown error",
})
},
onSuccess: (data) => {
if (data?.authLink) {
window.location.href = data.authLink
return
}
setConnectingProvider(null)
toast.error("Connect link missing — try again.")
},
})
const handleRevokePluginKey = async (keyId: string) => {
try {
await authClient.apiKey.delete({ keyId })
toast.success("Plugin disconnected")
refetchKeys()
} catch {
toast.error("Failed to disconnect plugin")
}
}
const handleUpgrade = useCallback(
async (planId?: unknown) => {
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
try {
const result = await autumn.attach({
planId: checkoutPlanId,
successUrl: `${window.location.origin}/integrations`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
}
},
[autumn],
)
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("")
const [searchExpanded, setSearchExpanded] = useState(false)
const openMcpClient = (key: MCPClientKey) => {
void setMcpClient(key)
setMcpModalOpen(true)
}
// Deeplink: /integrations?connect=<plugin-id|provider> auto-opens that card's connect flow.
const [connectTarget, setConnectTarget] = useQueryState(
"connect",
parseAsString,
)
// Tracks the last target we acted on; reset when the param clears so a fresh deeplink re-fires.
const connectHandledRef = useRef<string | null>(null)
useEffect(() => {
if (!connectTarget) {
connectHandledRef.current = null
return
}
if (connectHandledRef.current === connectTarget) return
const target = connectTarget
const isPlugin = !!PLUGIN_CATALOG[target]
const freeTier = isPlugin && isFreeTierPlugin(target)
// Paid plugins and granola need the plan query before deciding upgrade-vs-connect.
const needsPlan = (isPlugin && !freeTier) || target === "granola"
if (needsPlan && isAutumnLoading) return
// Defer to a macrotask and cancel on cleanup so React Strict Mode's mount→unmount→remount
// fires this exactly once (on the surviving mount) instead of opening/minting twice.
let cancelled = false
const timer = setTimeout(() => {
if (cancelled) return
connectHandledRef.current = target
if (publicMode) {
redirectToLogin()
return
}
if (isPlugin) {
if (!freeTier && !hasProProduct) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
// Open instantly; the key fills in on mint. The ?connect param stays the source
// of truth until the modal closes.
setNewKey({ open: true, key: "", pluginId: target, loading: true })
createPluginKeyMutation.mutate(target)
}
return
}
if (target === "granola") {
if (!hasProProduct) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
setGranolaModalOpen(true)
}
return
}
if (["notion", "google-drive", "onedrive"].includes(target)) {
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
void setConnectTarget(null)
void setAddDoc("connect")
}
}, 0)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [
connectTarget,
isAutumnLoading,
hasProProduct,
publicMode,
redirectToLogin,
setConnectTarget,
setAddDoc,
createPluginKeyMutation,
handleUpgrade,
])
const closeMcpModal = () => {
setMcpModalOpen(false)
void setMcpClient(null)
}
const activeMcpClient = mcpClient
? MCP_CLIENTS.find((c) => c.key === mcpClient)
: undefined
const allItems = useMemo<Item[]>(
() =>
SECTIONS.flatMap((s) => s.items(PLUGIN_CATALOG)).filter(
(item) => item.kind !== "plugin" || enabledPluginIds.has(item.pluginId),
),
[enabledPluginIds],
)
const isItemConnected = useCallback(
(item: Item): boolean => {
if (publicMode) return false
if (item.kind === "plugin") {
return activePluginById.has(item.pluginId)
}
if (item.kind === "connector") {
return connectionsByProvider[item.provider].length > 0
}
if (item.kind === "import") {
return tweetCount > 0
}
return false
},
[activePluginById, connectionsByProvider, publicMode, tweetCount],
)
const counts = useMemo<Record<CategoryFilter, number>>(
() => ({
all: allItems.length,
connected: allItems.filter(isItemConnected).length,
plugins: allItems.filter((i) => itemCategory(i) === "plugins").length,
"knowledge-bases": allItems.filter(
(i) => itemCategory(i) === "knowledge-bases",
).length,
"apps-extensions": allItems.filter(
(i) => itemCategory(i) === "apps-extensions",
).length,
"ai-clients": allItems.filter((i) => itemCategory(i) === "ai-clients")
.length,
}),
[allItems, isItemConnected],
)
useEffect(() => {
if (category !== "all" && counts[category] === 0) {
void setCategory("all")
}
}, [category, counts, setCategory])
const railEntries = useMemo<RailEntry[]>(() => {
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: (
<Image
src={plugin.icon}
alt={plugin.name}
width={24}
height={24}
className="size-6 rounded"
/>
),
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<string | null>((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 openRecentDoc = useCallback(
(doc: RecentDoc) => {
if (onOpenDocument) {
onOpenDocument(doc)
return
}
void setDocId(doc.id ?? doc.customId ?? null)
},
[onOpenDocument, setDocId],
)
const claudeCodeConnected = activePluginById.has("claude_code")
const claudeCodeNeedsPro =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
const featuredPicks: FeaturedPick[] = [
{
id: "feat-poke",
name: "Poke",
emoji: "🌴",
headline: "Your memory, one text away.",
support: "recall and save anything by texting Poke",
tagline: "Connect Poke to recall and save memories over text.",
icon: (
<Image
src="/images/poke.png"
alt="Poke"
width={32}
height={32}
className="rounded"
/>
),
backdrop: (
<Image
src="/images/poke.png"
alt=""
width={360}
height={360}
className="object-contain"
/>
),
ctaLabel: "Connect",
onCta: () => {
if (publicMode) {
redirectToLogin()
return
}
window.open(POKE_RECIPE_URL, "_blank", "noopener,noreferrer")
},
},
{
id: "feat-mcp",
name: "Supermemory MCP",
headline: "Your AI tools forget everything between chats.",
support: "one setup gives Cursor, Claude & ChatGPT your memory",
tagline: "Plug your memory into any MCP client.",
icon: <MCPIcon className="size-8" />,
backdrop: (
<Image
src="/onboarding/mcp.png"
alt=""
width={380}
height={380}
className="object-contain"
/>
),
docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction",
ctaLabel: "Connect",
onCta: () => {
if (publicMode) {
redirectToLogin()
return
}
void setMcpClient(null)
setViewMode("mcp")
},
},
{
id: "feat-claude-code",
name: "Claude Code plugin",
headline: "Stop re-explaining your codebase every session.",
support: "remembers your conventions, decisions & project context",
tagline: "Long-term memory for your Claude Code sessions.",
icon: (
<Image
src="/images/plugins/claude-code.svg"
alt="Claude Code"
width={32}
height={32}
className="rounded"
/>
),
backdrop: (
<Image
src="/images/plugins/claude-code.svg"
alt=""
width={360}
height={360}
className="object-contain"
/>
),
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("api_pro")
return
}
createPluginKeyMutation.mutate("claude_code")
},
},
{
id: "feat-chrome",
name: "Chrome Extension",
headline: "That article you'll “read later”? Gone by next week.",
support: "save anything on the web in one click",
tagline: "Save anything on the web, straight from your browser.",
icon: <ChromeIcon className="size-8" />,
backdrop: <ChromeIcon className="size-96" />,
ctaLabel: "Connect",
onCta: () => {
if (publicMode) {
redirectToLogin()
return
}
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
analytics.onboardingChromeExtensionClicked({ source: "integrations" })
},
},
]
const q = search.trim().toLowerCase()
const visibleItems = allItems.filter((item) => {
if (category === "connected" && !isItemConnected(item)) return false
if (
category !== "all" &&
category !== "connected" &&
itemCategory(item) !== category
)
return false
if (q) {
const hay = `${item.name} ${item.tagline}`.toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
const trackCard = (item: Item) =>
analytics.integrationCardClicked({
kind: item.kind,
id: item.id,
name: item.name,
})
const renderRight = (item: Item): ReactNode => {
if (publicMode) {
return (
<PillButton
onClick={() => {
trackCard(item)
redirectToLogin()
}}
>
Connect
</PillButton>
)
}
switch (item.kind) {
case "plugin": {
const activeKey = activePluginById.get(item.pluginId)
const needsProUpgrade =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
if (activeKey) {
const busy = connectingPlugin === item.pluginId
return (
<button
type="button"
aria-label={`Connect ${item.name} to another agent`}
title="Connect another agent"
onClick={() => {
if (needsProUpgrade) {
handleUpgrade("api_pro")
return
}
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
}}
disabled={!!connectingPlugin}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] disabled:opacity-50 sm:size-9",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
)}
>
{busy ? (
<Loader className="size-3.5 animate-spin" />
) : (
<Plus className="size-4" />
)}
</button>
)
}
if (setupPluginIds.has(item.pluginId)) {
return (
<FinishSetupButton
onClick={() => {
trackCard(item)
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
if (connectingPlugin) return
createPluginKeyMutation.mutate(item.pluginId)
return
}
setFinishSetupPluginId(item.pluginId)
}}
/>
)
}
if (needsProUpgrade) {
return (
<PillButton onClick={() => handleUpgrade("api_pro")}>
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
</PillButton>
)
}
const busy = connectingPlugin === item.pluginId
return (
<PillButton
onClick={() => {
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
}}
disabled={!!connectingPlugin}
>
{busy ? (
<>
<Loader className="size-3.5 animate-spin" /> Connecting
</>
) : (
"Connect"
)}
</PillButton>
)
}
case "connector": {
const count = connectionsByProvider[item.provider].length
const isGranola = item.provider === "granola"
const needsPlanUpgrade = !isAutumnLoading && !hasProProduct
if (count > 0) {
return (
<div className="flex w-full items-center justify-between gap-2">
<button
type="button"
aria-label="Add another knowledge source"
title="Add another knowledge source"
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
handleUpgrade("api_pro")
return
}
setGranolaModalOpen(true)
return
}
void setAddDoc("connect")
}}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
)}
>
<Plus className="size-4" />
</button>
</div>
)
}
if (needsPlanUpgrade) {
return (
<PillButton onClick={() => handleUpgrade("api_pro")}>
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
</PillButton>
)
}
const busy = connectingProvider === item.provider
return (
<PillButton
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
handleUpgrade("api_pro")
return
}
setGranolaModalOpen(true)
return
}
addConnectionMutation.mutate(item.provider)
}}
disabled={!!connectingProvider}
>
{busy ? (
<>
<Loader className="size-3.5 animate-spin" /> Connecting
</>
) : (
"Connect"
)}
</PillButton>
)
}
case "client": {
if (item.action.type === "external") {
return (
<PillButton
onClick={() => {
trackCard(item)
window.open(
(item.action as { type: "external"; href: string }).href,
"_blank",
"noopener,noreferrer",
)
if (item.id === "chrome") {
analytics.onboardingChromeExtensionClicked({
source: "integrations",
})
}
}}
>
Connect
</PillButton>
)
}
if (item.id === "shortcuts") {
return <ShortcutsConnectButtons controller={shortcutsConnect} />
}
return (
<PillButton
onClick={() => {
trackCard(item)
setViewMode(
(item.action as { type: "view"; viewMode: ViewParamValue })
.viewMode,
)
}}
>
Connect
</PillButton>
)
}
case "mcp-client":
return (
<PillButton
onClick={() => {
trackCard(item)
openMcpClient(item.clientKey)
}}
>
Connect
</PillButton>
)
case "import":
return (
<PillButton
onClick={() => {
trackCard(item)
setViewMode(item.viewMode)
}}
>
Connect
</PillButton>
)
}
}
const renderInfoRight = (item: Item): ReactNode => {
if (publicMode) return renderRight(item)
switch (item.kind) {
case "plugin": {
const activeKey = activePluginById.get(item.pluginId)
const needsProUpgrade =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
if (activeKey) {
const busy = connectingPlugin === item.pluginId
return (
<PillButton
onClick={() => {
if (needsProUpgrade) {
handleUpgrade("api_pro")
return
}
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
}}
disabled={!!connectingPlugin}
>
{busy ? (
<>
<Loader className="size-3.5 animate-spin" /> Connecting
</>
) : (
"Connect"
)}
</PillButton>
)
}
return renderRight(item)
}
case "connector": {
const count = connectionsByProvider[item.provider].length
if (count > 0) {
return (
<PillButton
onClick={() => {
trackCard(item)
void setAddDoc("connect")
}}
>
Connect
</PillButton>
)
}
return renderRight(item)
}
case "client": {
if (item.id === "shortcuts") {
return <ShortcutsConnectButtons controller={shortcutsConnect} />
}
return renderRight(item)
}
default:
return renderRight(item)
}
}
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 (
<ActiveButton
count={activeCountByPlugin.get(item.pluginId) ?? 0}
lastActive={activeKey.lastRequest}
onClick={() => {
trackCard(item)
setConnectedPluginId(item.pluginId)
}}
/>
)
}
case "connector": {
const count = connectionsByProvider[item.provider].length
if (count <= 0) return null
return <ConnectionsCountPill count={count} />
}
case "import": {
if (tweetCount <= 0) return null
return <ImportedPill lastImportedAt={lastTweetImportAt} />
}
default:
return null
}
}
const renderItemCard = (item: Item) => (
<ItemCard
key={item.id}
actionSlot={renderRight(item)}
infoActionSlot={renderInfoRight(item)}
icon={item.icon}
id={item.id}
kind={item.kind}
name={item.name}
tagline={item.tagline}
pro={item.pro}
max={item.max}
isNew={item.isNew}
docsUrl={item.docsUrl}
leftIndicator={renderLeftIndicator(item)}
statusSlot={renderStatus(item)}
/>
)
const renderLeftIndicator = (_item: Item): ReactNode => {
return null
}
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
: undefined
const connectedDialogPlugin = connectedPluginId
? PLUGIN_CATALOG[connectedPluginId]
: undefined
const connectedDialogKeys = connectedPluginId
? activePlugins.filter((key) => key.pluginId === connectedPluginId)
: []
const connectedDialogNeedsPro =
!!connectedPluginId &&
!isAutumnLoading &&
!hasProProduct &&
!isFreeTierPlugin(connectedPluginId)
const finishSetupPlugin = finishSetupPluginId
? PLUGIN_CATALOG[finishSetupPluginId]
: undefined
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
const pluginSteps = dialogPlugin?.installSteps ?? []
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
const setupSteps: InstallStep[] = skipGeneratedKeyStep
? pluginSteps
: [
{
title: "Copy your API key",
description:
"You won't be able to see it again — store it somewhere safe.",
code: newKey.key,
copyLabel: "API key",
secret: true,
},
...pluginSteps,
]
return (
<div className="flex-1">
{shortcutsConnect.dialog}
<div
className={cn(
"mx-auto w-full",
showRightColumn ? "max-w-[88rem]" : "max-w-5xl",
)}
>
<div className="flex flex-col gap-5">
{!q && <FeaturedHero picks={featuredPicks} />}
{showRightColumn && (
<div className="lg:hidden">
<MobileActivityPanel
entries={railEntries}
docs={recentDocs}
connectionSource={connectionSource}
activeLoading={railLoading}
recentsLoading={recentsLoading}
onOpenDoc={openRecentDoc}
onViewAll={() => setViewMode("list")}
/>
</div>
)}
<div
className={cn(
"flex flex-col gap-5",
showRightColumn && "lg:flex-row lg:items-start lg:gap-4",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div
className={cn(
"relative overflow-hidden rounded-[14px] bg-[#191D24] p-4 sm:p-6",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<div className="mb-4 flex items-center justify-between gap-2">
<div className="min-w-0 shrink">
<CategoryFilterToggle
value={category}
onChange={(v) => void setCategory(v)}
counts={counts}
compact={searchExpanded || !!search}
/>
</div>
<SearchToggle
value={search}
onChange={setSearch}
expanded={searchExpanded}
setExpanded={setSearchExpanded}
/>
</div>
{visibleItems.length === 0 ? (
<p
className={cn(
dmSans125ClassName(),
"py-6 text-center text-[13px] text-[#A1A1AA]",
)}
>
{q
? `No integrations match “${search}”.`
: "Nothing in this category yet."}
</p>
) : q || category !== "all" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleItems.map((item) => renderItemCard(item))}
</div>
) : (
<div className="flex flex-col gap-6">
{SECTION_ORDER.map((cat) => {
const items = visibleItems.filter(
(i) => itemCategory(i) === cat,
)
if (items.length === 0) return null
return (
<SectionRail key={cat} label={CATEGORY_LABEL[cat]}>
{items.map((item) => (
<div
key={item.id}
className="w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"
>
{renderItemCard(item)}
</div>
))}
</SectionRail>
)
})}
</div>
)}
</div>
</div>
{showRightColumn && (
<div className="hidden w-full lg:block lg:w-[320px] lg:shrink-0">
<div className="flex flex-col gap-4 lg:sticky lg:top-2">
<ActiveConnectionsRail
entries={railEntries}
loading={railLoading}
/>
<RecentlyAddedCard
docs={recentDocs}
connectionSource={connectionSource}
loading={recentsLoading}
onOpenDoc={openRecentDoc}
onViewAll={() => setViewMode("list")}
/>
</div>
</div>
)}
</div>
</div>
</div>
<Dialog
open={newKey.open}
onOpenChange={(open) => {
setNewKey((s) => ({
open,
key: open ? s.key : "",
pluginId: open ? s.pluginId : null,
loading: open ? s.loading : false,
}))
if (!open) void setConnectTarget(null)
}}
>
<DialogContent
showCloseButton={false}
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 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Set up {dialogPlugin?.name ?? "your plugin"}
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
{dialogPlugin && (
<IconBox>
<Image
src={dialogPlugin.icon}
alt={dialogPlugin.name}
width={24}
height={24}
/>
</IconBox>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
Set up {dialogPlugin?.name ?? "your plugin"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
{newKey.loading
? "Generating your key…"
: "Copy your key and run these steps to finish."}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{dialogPlugin?.docsUrl && (
<a
href={dialogPlugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
INSET,
)}
>
<BookOpen className="size-3.5" /> Docs
</a>
)}
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
{newKey.loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
<Loader className="size-4 animate-spin" />
Generating your key
</div>
) : (
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<button
type="button"
onClick={() => {
setNewKey({
open: false,
key: "",
pluginId: null,
loading: false,
})
void setConnectTarget(null)
}}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={!!connectedPluginId}
onOpenChange={(open) => {
if (!open) setConnectedPluginId(null)
}}
>
<DialogContent
showCloseButton={false}
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 rounded-2xl md:px-4 sm:max-w-[520px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
{connectedDialogPlugin?.name ?? "Plugin"} connection
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
{connectedDialogPlugin && (
<IconBox>
<Image
src={connectedDialogPlugin.icon}
alt={connectedDialogPlugin.name}
width={24}
height={24}
/>
</IconBox>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
{connectedDialogPlugin?.name ?? "Plugin"}
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-[#00AC3F]">
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Active
{activePluginById.get(connectedPluginId ?? "")?.lastRequest && (
<span className="text-[#737373]">
·{" "}
{formatRelativeTime(
activePluginById.get(connectedPluginId ?? "")
?.lastRequest,
)}
</span>
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{connectedDialogPlugin?.docsUrl && (
<a
href={connectedDialogPlugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
INSET,
)}
>
<BookOpen className="size-3.5" /> Docs
</a>
)}
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
</div>
<div
className={cn(
"flex min-w-0 flex-col gap-2 rounded-[14px] bg-[#14161A] p-4",
INSET,
)}
>
<p className="text-[11px] font-medium uppercase tracking-wide text-[#737373]">
{connectedDialogKeys.length > 1
? `${connectedDialogKeys.length} connections`
: "Connection"}
</p>
{connectedDialogKeys.length > 0 ? (
<div className="flex flex-col gap-1.5">
{connectedDialogKeys.map((key) => (
<div
key={key.keyId}
className="flex min-w-0 items-center justify-between gap-3 rounded-lg bg-[#0D121A]/70 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-[#A1A1AA]">
{key.keyStart ? `${key.keyStart}...` : "API key"}
</span>
<DisconnectButton
onConfirm={() => void handleRevokePluginKey(key.keyId)}
/>
</div>
))}
</div>
) : (
<p className="text-[12px] text-[#A1A1AA]">
No active connection was found.
</p>
)}
<p className="mt-1 text-[12px] text-[#737373]">
Connect this plugin to another agent to run them in parallel.
</p>
</div>
<div className="flex shrink-0 items-center justify-between gap-2">
{connectedDialogNeedsPro ? (
<PillButton onClick={() => handleUpgrade("api_pro")}>
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade to connect
more
</PillButton>
) : (
<PillButton
onClick={() => {
if (!connectedPluginId) return
const pluginId = connectedPluginId
setConnectedPluginId(null)
createPluginKeyMutation.mutate(pluginId)
}}
disabled={!!connectingPlugin}
>
{connectingPlugin === connectedPluginId ? (
<>
<Loader className="size-3.5 animate-spin" /> Connecting
</>
) : (
<>
<Plus className="size-3.5 text-[#4BA0FA]" /> Connect another
</>
)}
</PillButton>
)}
<DialogPrimitive.Close asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</DialogPrimitive.Close>
</div>
</DialogContent>
</Dialog>
<Dialog
open={!!finishSetupPluginId}
onOpenChange={(open) => {
if (!open) setFinishSetupPluginId(null)
}}
>
<DialogContent
showCloseButton={false}
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 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
{finishSetupPlugin && (
<IconBox>
<Image
src={finishSetupPlugin.icon}
alt={finishSetupPlugin.name}
width={24}
height={24}
/>
</IconBox>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
Complete install in the tool this card turns active after the
first API call.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
{finishSetupSteps.length > 0 ? (
<InstallSteps steps={finishSetupSteps} />
) : (
<p className="text-[13px] text-[#A1A1AA]">
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
authentication, then send a test memory.
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<DialogPrimitive.Close asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</DialogPrimitive.Close>
</div>
</DialogContent>
</Dialog>
<Dialog
open={mcpModalOpen}
onOpenChange={(open) => {
if (!open) closeMcpModal()
else setMcpModalOpen(true)
}}
>
<DialogContent
showCloseButton={false}
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 rounded-2xl md:px-4 sm:max-w-[640px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Set up {activeMcpClient?.name ?? "MCP client"}
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
<IconBox>
{activeMcpClient && activeMcpClient.key !== "mcp-url" ? (
<Image
src={mcpClientIconSrc(activeMcpClient.key)}
alt={activeMcpClient.name}
width={24}
height={24}
unoptimized
className="size-6 rounded object-contain"
/>
) : (
<MCPIcon className="size-6" />
)}
</IconBox>
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
Set up {activeMcpClient?.name ?? "MCP client"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
Connect supermemory MCP to{" "}
{activeMcpClient?.name ?? "your client"}.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href="https://supermemory.ai/docs/supermemory-mcp/introduction"
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
INSET,
)}
>
<BookOpen className="size-3.5" /> Docs
</a>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
<MCPSteps variant="embedded" />
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<button
type="button"
onClick={closeMcpModal}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</div>
</DialogContent>
</Dialog>
<GranolaConnectModal
open={hasProProduct && granolaModalOpen}
onOpenChange={(open) => {
setGranolaModalOpen(open && hasProProduct)
if (!open) void setConnectTarget(null)
}}
/>
</div>
)
}