diff --git a/apps/web/app/(app)/settings/integrations/page.tsx b/apps/web/app/(app)/settings/integrations/page.tsx index 3984fb523..aa8a456b5 100644 --- a/apps/web/app/(app)/settings/integrations/page.tsx +++ b/apps/web/app/(app)/settings/integrations/page.tsx @@ -8,8 +8,9 @@ export default function SettingsIntegrationsPage() { const searchParams = useSearchParams() useEffect(() => { - const qs = searchParams.toString() - router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`) + const params = new URLSearchParams(searchParams.toString()) + params.set("view", "integrations") + router.replace(`/?${params.toString()}`) }, [router, searchParams]) return null diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index bc9711c00..e602f070c 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -1,9 +1,9 @@ "use client" -import { useQuery } from "@tanstack/react-query" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" +import { dmSans125ClassName } from "@/lib/fonts" import { hasActivePlan } from "@lib/queries" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" @@ -16,127 +16,381 @@ import { AppleShortcutsIcon, RaycastIcon, } from "@/components/integration-icons" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" -import { ArrowLeft, Sun } from "lucide-react" -import { CHROME_EXTENSION_URL } from "@repo/lib/constants" +import { GoogleDrive, Notion, OneDrive, MCPIcon } from "@ui/assets/icons" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { + ArrowLeft, + ArrowRight, + BookOpen, + Check, + ChevronDown, + Loader, + Search, + X, + Zap, +} from "lucide-react" +import { CHROME_EXTENSION_URL } from "@lib/constants" import { analytics } from "@/lib/analytics" import Image from "next/image" -import { IntegrationGridCard } from "@/components/integrations/integration-grid-card" import { useViewMode } from "@/lib/view-mode-context" -import { addDocumentParam, type ViewParamValue } from "@/lib/search-params" -import { useQueryState } from "nuqs" +import type { ViewParamValue } from "@/lib/search-params" +import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs" +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 { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { + PLUGIN_CATALOG, + FREE_TIER_PLUGIN_IDS, + isFreeTierPlugin, + type InstallStep, +} from "@/lib/plugin-catalog" +import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" +import { MCPSteps } from "./mcp-modal/mcp-detail-view" type Connection = z.infer -type CardId = - | "mcp" - | "chrome" - | "connections" - | "shortcuts" - | "raycast" - | "import" - | "plugins" +type ConnectorProvider = "google-drive" | "notion" | "onedrive" -interface IntegrationCardDef { - id: CardId - title: string - description: string - icon: React.ReactNode - pro?: boolean - externalHref?: string +interface ConnectedKey { + keyId: string + keyStart: string | null + pluginId: string } -const cards: IntegrationCardDef[] = [ +type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import" + +type MCPClientKey = + | "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 +}> = [ { - id: "plugins", - title: "Plugins", - description: - "Hermes on every plan; Claude Code, Codex, OpenCode, OpenClaw, and more with Pro", - icon: ( -
- Claude Code - Codex - OpenCode - OpenClaw - Hermes -
- ), + key: "cursor", + name: "Cursor", + tagline: "One-click MCP install in Cursor", + simpleTitle: "Code with your saved knowledge nearby", }, { - id: "connections", - title: "Connections", - description: "Link Notion, Google Drive, or OneDrive to import your docs", - pro: true, - icon: ( -
- - - -
- ), + 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, }, { - id: "mcp", - title: "Connect to AI", - description: "Set up MCP to use your memory in Cursor, Claude, and more", - icon: ( - MCP - ), + key: "gemini-cli", + name: "Gemini CLI", + tagline: "Google Gemini terminal client", + simpleTitle: "Bring your memory into Gemini sessions", + dev: true, }, { - id: "chrome", - title: "Chrome Extension", - description: "Save any webpage, import bookmarks, sync ChatGPT memories", - icon: , - externalHref: CHROME_EXTENSION_URL, + 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" + const file = key === "claude-code" ? "claude" : key + return `/mcp-supported-tools/${file}.png` +} + +const PLUGIN_SIMPLE_TITLES: Record = { + claude_code: "Remembers your code conventions and decisions", + codex: "Codex sessions that remember your project", + 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([ + ...CATEGORY_VALUES, +]).withDefault("all") + +const CATEGORY_LABEL: Record = { + all: "All", + connected: "Connected", + plugins: "Plugins", + "knowledge-bases": "Knowledge bases", + "apps-extensions": "Apps & extensions", + "ai-clients": "MCP", +} + +const SECTION_ORDER: Array> = [ + "ai-clients", + "plugins", + "knowledge-bases", + "apps-extensions", +] + +function itemCategory( + item: Item, +): Exclude { + 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 + kind: ItemKind + simpleTitle?: string + dev?: 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((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" ? ( + + ) : ( + {c.name} + ), + docsUrl: "https://docs.supermemory.ai/supermemory-mcp/introduction", + })), }, { - id: "shortcuts", - title: "Apple Shortcuts", - description: "Add memories directly from iPhone, iPad or Mac", - icon: , + label: "Plugins", + items: (catalog) => + Object.keys(catalog).map((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: ( + {plugin.name} + ), + docsUrl: plugin.docsUrl, + pro: !FREE_TIER_PLUGIN_IDS.includes(id), + simpleTitle: PLUGIN_SIMPLE_TITLES[id], + dev: true, + } + }), }, { - id: "raycast", - title: "Raycast", - description: "Add and search memories from Raycast on Mac", - icon: , + 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: , + pro: true, + }, + { + kind: "connector", + id: "notion", + provider: "notion", + name: "Notion", + tagline: "Import Notion pages and databases", + simpleTitle: "All your Notion pages, in supermemory", + icon: , + pro: true, + }, + { + kind: "connector", + id: "onedrive", + provider: "onedrive", + name: "OneDrive", + tagline: "Bring in Office documents from OneDrive", + simpleTitle: "Your OneDrive files, ready to recall", + icon: , + pro: true, + }, + ], }, { - id: "import", - title: "Import Bookmarks", - description: "Bring in X/Twitter bookmarks and turn them into memories", - icon: X, + 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: , + action: { type: "external", href: CHROME_EXTENSION_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: , + 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: , + 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: X, + viewMode: "import" as ViewParamValue, + }, + ], }, ] @@ -145,7 +399,7 @@ export function DetailWrapper({ children, }: { onBack: () => void - children: React.ReactNode + children: ReactNode }) { return (
@@ -164,20 +418,603 @@ export function DetailWrapper({ ) } -const CARD_GROUPS: Array<{ label: string; ids: CardId[] }> = [ - { label: "AI tools", ids: ["plugins", "mcp"] }, - { - label: "Apps & extensions", - ids: ["connections", "chrome", "shortcuts", "raycast", "import"], - }, -] +function ProChip() { + return ( + + Pro + + ) +} + +function IconBox({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function DocsLink({ href }: { href: string }) { + return ( + e.stopPropagation()} + className={cn( + dmSans125ClassName(), + "flex size-8 shrink-0 items-center justify-center rounded-full text-[12px] text-[#A1A1AA] transition-colors hover:text-white sm:h-auto sm:w-auto sm:gap-1.5 sm:rounded-none", + )} + > + + Docs + + ) +} + +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 ( + + ) +} + +function ConnectedPill({ + keys, + onRevoke, +}: { + keys: ConnectedKey[] + onRevoke: (keyId: string) => void +}) { + return ( + + + + + e.stopPropagation()} + className={cn( + dmSans125ClassName(), + "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]", + )} + > +

+ {keys.length > 1 ? `${keys.length} connections` : "Connection"} +

+
+ {keys.map((k) => ( +
+ + {k.keyStart ? `${k.keyStart}…` : "API key"} + + onRevoke(k.keyId)} /> +
+ ))} +
+
+
+ ) +} + +function ConnectionsCountPill({ count }: { count: number }) { + return ( + + + {count > 1 ? `${count} connected` : "Connected"} + + ) +} + +function ItemCard({ + icon, + name, + tagline, + pro, + docsUrl, + leftIndicator, + rightSlot, +}: { + icon: ReactNode + name: string + tagline: string + pro?: boolean + docsUrl?: string + leftIndicator?: ReactNode + rightSlot: ReactNode +}) { + return ( +
+
+ {icon} + {docsUrl && ( + // biome-ignore lint/a11y/noStaticElementInteractions: wrapper to stop event propagation +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + +
+ )} +
+
+
+
+ {leftIndicator} + + {name} + + {pro && } +
+

+ {tagline} +

+
+
{rightSlot}
+
+
+ ) +} + +interface FeaturedPick { + id: string + name: 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 ( +
+ )} + + + +

+ {pick.headline} +

+

+ {pick.name} ·{" "} + {pick.support} +

+
+
+ + ) +} + +function SearchToggle({ + value, + onChange, + expanded, + setExpanded, +}: { + value: string + onChange: (next: string) => void + expanded: boolean + setExpanded: (next: boolean) => void +}) { + const inputRef = useRef(null) + + const open = () => { + setExpanded(true) + requestAnimationFrame(() => inputRef.current?.focus()) + } + + const close = () => { + onChange("") + setExpanded(false) + } + + if (!expanded && !value) { + return ( + + ) + } + + return ( +
+ + 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 && ( + + )} +
+ ) +} + +function CategoryFilterToggle({ + value, + onChange, + counts, + compact, +}: { + value: CategoryFilter + onChange: (next: CategoryFilter) => void + counts: Record + compact?: boolean +}) { + const visible = compact + ? [value] + : CATEGORY_VALUES.filter((v) => v === "all" || counts[v] > 0) + return ( +
+ {visible.map((v) => { + const active = value === v + return ( + + ) + })} +
+ ) +} + +function SectionRail({ + label, + children, +}: { + label: string + children: ReactNode +}) { + const scrollRef = useRef(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 ( +
+
+

+ {label} +

+
+ + +
+
+
+ {children} +
+
+ ) +} export function IntegrationsView() { const { setViewMode } = useViewMode() - const [, setAddDoc] = useQueryState("add", addDocumentParam) + const queryClient = useQueryClient() const { org } = useAuth() const autumn = useCustomer() const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro") + const isAutumnLoading = autumn.isLoading + + const [connectingPlugin, setConnectingPlugin] = useState(null) + const [connectingProvider, setConnectingProvider] = + useState(null) + const [newKey, setNewKey] = useState<{ + open: boolean + key: string + pluginId: string | null + }>({ open: false, key: "", pluginId: 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[] } + }, + queryKey: ["plugins"], + }) const { data: connections = [] } = useQuery({ queryKey: ["connections"], @@ -193,25 +1030,12 @@ export function IntegrationsView() { enabled: hasProProduct, }) - const { data: facetsData } = useQuery({ - queryKey: ["document-facets", []], - queryFn: async () => { - const response = await $fetch("@post/documents/documents/facets", { - body: { containerTags: [] }, - disableValidation: true, - }) - if (response.error) - throw new Error(response.error?.message || "Failed to fetch facets") - return response.data as { - facets: Array<{ category: string; count: number }> - total: number - } - }, - staleTime: 5 * 60 * 1000, - }) - - type ApiKey = { metadata: Record | null } - const { data: apiKeys = [] } = useQuery({ + type ApiKey = { + id: string + metadata: Record | null + start: string | null + } + const { data: apiKeys = [], refetch: refetchKeys } = useQuery({ queryKey: ["api-keys", org?.id], queryFn: async () => { if (!org?.id) return [] @@ -224,96 +1048,724 @@ export function IntegrationsView() { staleTime: 30 * 1000, }) - const connectedPluginCount = apiKeys.filter( - (key) => key.metadata?.sm_type === "plugin_auth", - ).length + const connectedPlugins = useMemo(() => { + const out: ConnectedKey[] = [] + for (const key of apiKeys) { + 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) { + out.push({ + keyId: key.id, + keyStart: key.start ?? null, + pluginId: metadata.sm_client, + }) + } + } catch {} + } + return out + }, [apiKeys]) - const tweetCount = - facetsData?.facets.find((f) => f.category === "tweet")?.count ?? 0 + const connectionsByProvider = useMemo(() => { + const out: Record = { + "google-drive": [], + notion: [], + onedrive: [], + } + for (const c of connections) { + const p = c.provider as ConnectorProvider + if (p in out) out[p].push(c) + } + return out + }, [connections]) - const getStatusLabel = ( - id: CardId, - ): { label: string; variant: "connected" | "neutral" } | undefined => { - if (id === "connections" && hasProProduct) { - return connections.length > 0 - ? { label: `${connections.length} connected`, variant: "connected" } - : { label: "Not connected", variant: "neutral" } + 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) => { + 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 }) + }, + }) + + 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), + 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") } - if (id === "import") { - return tweetCount > 0 - ? { label: `${tweetCount} tweets imported`, variant: "connected" } - : undefined + } + + const handleUpgrade = async () => { + try { + const result = await autumn.attach({ + planId: "api_pro", + successUrl: `${window.location.origin}/?view=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.") } - if (id === "plugins") { - return connectedPluginCount > 0 - ? { label: `${connectedPluginCount} connected`, variant: "connected" } - : undefined + } + + const availablePluginIds = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG) + const enabledPluginIds = new Set( + availablePluginIds.filter((id) => PLUGIN_CATALOG[id]), + ) + + const [category, setCategory] = useQueryState("cat", catParam) + 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) + } + + const closeMcpModal = () => { + setMcpModalOpen(false) + void setMcpClient(null) + } + + const activeMcpClient = mcpClient + ? MCP_CLIENTS.find((c) => c.key === mcpClient) + : undefined + + const allItems = useMemo( + () => + SECTIONS.flatMap((s) => s.items(PLUGIN_CATALOG)).filter( + (item) => item.kind !== "plugin" || enabledPluginIds.has(item.pluginId), + ), + [enabledPluginIds], + ) + + const isItemConnected = useCallback( + (item: Item): boolean => { + if (item.kind === "plugin") { + return connectedPlugins.some((k) => k.pluginId === item.pluginId) + } + if (item.kind === "connector") { + return connectionsByProvider[item.provider].length > 0 + } + return false + }, + [connectedPlugins, connectionsByProvider], + ) + + const counts = useMemo>( + () => ({ + 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 claudeCodeConnected = connectedPlugins.some( + (k) => k.pluginId === "claude_code", + ) + const claudeCodeNeedsPro = + !isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code") + + const featuredPicks: FeaturedPick[] = [ + { + 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: , + backdrop: ( + + ), + docsUrl: "https://docs.supermemory.ai/supermemory-mcp/introduction", + ctaLabel: "Connect", + onCta: () => { + 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: ( + Claude Code + ), + backdrop: ( + + ), + docsUrl: "https://docs.supermemory.ai/integrations/claude-code", + ctaLabel: claudeCodeConnected + ? "Connected" + : claudeCodeNeedsPro + ? "Upgrade" + : "Connect", + onCta: () => { + if (claudeCodeConnected) return + if (claudeCodeNeedsPro) { + handleUpgrade() + 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: , + backdrop: , + ctaLabel: "Connect", + onCta: () => { + 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 renderRight = (item: Item): ReactNode => { + switch (item.kind) { + case "plugin": { + const keys = connectedPlugins.filter( + (k) => k.pluginId === item.pluginId, + ) + const needsProUpgrade = + !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId) + if (keys.length > 0) { + return + } + if (needsProUpgrade) { + return ( + + Upgrade + + ) + } + const busy = connectingPlugin === item.pluginId + return ( + createPluginKeyMutation.mutate(item.pluginId)} + disabled={!!connectingPlugin} + > + {busy ? ( + <> + Connecting… + + ) : ( + "Connect" + )} + + ) + } + case "connector": { + const count = connectionsByProvider[item.provider].length + const needsProUpgrade = !isAutumnLoading && !hasProProduct + if (count > 0) return + if (needsProUpgrade) { + return ( + + Upgrade + + ) + } + const busy = connectingProvider === item.provider + return ( + addConnectionMutation.mutate(item.provider)} + disabled={!!connectingProvider} + > + {busy ? ( + <> + Connecting… + + ) : ( + "Connect" + )} + + ) + } + case "client": { + if (item.action.type === "external") { + return ( + { + window.open( + (item.action as { type: "external"; href: string }).href, + "_blank", + "noopener,noreferrer", + ) + analytics.onboardingChromeExtensionClicked({ + source: "integrations", + }) + }} + > + Connect + + ) + } + return ( + + setViewMode( + (item.action as { type: "view"; viewMode: ViewParamValue }) + .viewMode, + ) + } + > + Connect + + ) + } + case "mcp-client": + return ( + openMcpClient(item.clientKey)}> + Connect + + ) + case "import": + return ( + setViewMode(item.viewMode)}> + Connect + + ) } - return undefined } + const renderItemCard = (item: Item) => ( + + ) + + const renderLeftIndicator = (item: Item): ReactNode => { + if (item.kind === "plugin") { + const isConnected = connectedPlugins.some( + (k) => k.pluginId === item.pluginId, + ) + return isConnected ? ( + + ) : null + } + if (item.kind === "connector") { + const count = connectionsByProvider[item.provider].length + return count > 0 ? ( + + ) : null + } + return null + } + + const dialogPlugin = newKey.pluginId + ? PLUGIN_CATALOG[newKey.pluginId] + : undefined + const pluginSteps = dialogPlugin?.installSteps ?? [] + const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) + const setupSteps: InstallStep[] = stepsEmbedKey + ? 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 (
-
-
-
- -

Integrations

-
-

- Connect supermemory to your tools and workflows -

-
+
+ {!q && category !== "connected" && ( + + )} -
- {CARD_GROUPS.map((group) => { - const groupCards = cards.filter((c) => group.ids.includes(c.id)) - return ( -
-
- - {group.label} - -
-
-
- {groupCards.map((card) => { - const status = getStatusLabel(card.id) - return ( - { - if (card.externalHref) { - window.open( - card.externalHref, - "_blank", - "noopener,noreferrer", - ) - analytics.onboardingChromeExtensionClicked({ - source: "integrations", - }) - } else if (card.id === "connections") { - void setAddDoc("connect") - } else { - void setViewMode(card.id as ViewParamValue) - } - }} - /> - ) - })} -
-
- ) - })} +
+
+
+ void setCategory(v)} + counts={counts} + compact={searchExpanded || !!search} + /> +
+ +
+ {visibleItems.length === 0 ? ( +

+ {q + ? `No integrations match “${search}”.` + : "Nothing in this category yet."} +

+ ) : q ? ( +
+ {visibleItems.map((item) => renderItemCard(item))} +
+ ) : ( +
+ {SECTION_ORDER.map((cat) => { + const items = visibleItems.filter( + (i) => itemCategory(i) === cat, + ) + if (items.length === 0) return null + return ( + + {items.map((item) => ( +
+ {renderItemCard(item)} +
+ ))} +
+ ) + })} +
+ )}
+ + + setNewKey((s) => ({ + open, + key: open ? s.key : "", + pluginId: open ? s.pluginId : null, + })) + } + > + + + Set up {dialogPlugin?.name ?? "your plugin"} + +
+ {dialogPlugin && ( + + {dialogPlugin.name} + + )} +
+

+ Set up {dialogPlugin?.name ?? "your plugin"} +

+

+ Copy your key and run these steps to finish. +

+
+
+ {dialogPlugin?.docsUrl && ( + + Docs + + )} + + + +
+
+
+
+ +
+
+
+ +
+
+
+ + { + if (!open) closeMcpModal() + else setMcpModalOpen(true) + }} + > + + + Set up {activeMcpClient?.name ?? "MCP client"} + +
+ + {activeMcpClient && activeMcpClient.key !== "mcp-url" ? ( + {activeMcpClient.name} + ) : ( + + )} + +
+

+ Set up {activeMcpClient?.name ?? "MCP client"} +

+

+ Connect supermemory MCP to{" "} + {activeMcpClient?.name ?? "your client"}. +

+
+
+ + Docs + + + + +
+
+
+
+ +
+
+
+ +
+
+
) } diff --git a/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx b/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx index 67ac3aa1d..4d32fa3a3 100644 --- a/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx +++ b/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx @@ -69,52 +69,76 @@ export function ClaudeDesktopManualTimeline({ }: ClaudeDesktopManualTimelineProps) { const isDetail = variant === "detail" - const textMuted = isDetail ? "text-[#B0B0B0]" : "text-muted-foreground" - const border = isDetail ? "border-[#3D434D]" : "border-border" - const spine = isDetail ? "bg-[#7C8C9E]" : "bg-muted-foreground/40" + const textMuted = isDetail ? "text-[#A1A1AA]" : "text-muted-foreground" + const border = isDetail ? "border-white/[0.07]" : "border-border" + const spine = isDetail ? "bg-white/[0.14]" : "bg-muted-foreground/40" const circleBorder = isDetail - ? "border-[#9CA8B8]" + ? "border-white/[0.12]" : "border-muted-foreground/50" - const circleBg = isDetail ? "bg-[#080B0F]" : "bg-background" - const codeBg = isDetail ? "bg-[#050608]" : "bg-muted" - const imgBorder = isDetail ? "border-[#3D434D]" : "border-border" - const screenshotBed = isDetail ? "bg-[#2A2D35]" : "bg-muted" + const circleBg = isDetail ? "bg-[#0D121A]" : "bg-background" + const codeBg = isDetail ? "bg-[#0B0E13]" : "bg-muted" + const imgBorder = isDetail ? "border-white/[0.07]" : "border-border" + const screenshotBed = isDetail ? "bg-[#0B0E13]" : "bg-muted" return (
-
    +
      {STEPS.map((item) => (
    1. -
      -
      + {isDetail ? ( +
      + {item.step} +
      + ) : ( +
      + {item.step} +
      + )} +
      +
      +

      - {item.step} -

      -
      -
      -

      {item.body}

      @@ -123,14 +147,14 @@ export function ClaudeDesktopManualTimeline({
       												
      @@ -139,13 +163,13 @@ export function ClaudeDesktopManualTimeline({
       											
      diff --git a/apps/web/components/mcp-modal/mcp-detail-view.tsx b/apps/web/components/mcp-modal/mcp-detail-view.tsx index 43960e18b..61b176e20 100644 --- a/apps/web/components/mcp-modal/mcp-detail-view.tsx +++ b/apps/web/components/mcp-modal/mcp-detail-view.tsx @@ -30,6 +30,67 @@ import { getManualInstallEntry, } from "@/lib/mcp-manual-instructions" import { ClaudeDesktopManualTimeline } from "@/components/mcp-modal/claude-desktop-manual-timeline" +import { INSET } from "@/components/integrations/install-steps" + +function McpCodeBlock({ + code, + multiline, + onCopy, +}: { + code: string + multiline?: boolean + onCopy?: () => void +}) { + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard.writeText(code) + setCopied(true) + toast.success("Copied to clipboard!") + setTimeout(() => setCopied(false), 2000) + onCopy?.() + } + return ( +
      +
      +
      +					{code}
      +				
      + {!multiline && ( +
      + )} +
      + +
      + ) +} const clients = { chatgpt: "ChatGPT", @@ -101,7 +162,8 @@ function ClientToolIcon({ return (
      {failed ? ( @@ -137,7 +199,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { "mcpTab", parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("manual"), ) - const [isCommandCopied, setIsCommandCopied] = useState(false) const [isManualCopied, setIsManualCopied] = useState(false) const [activeStep, setActiveStep] = useQueryState( "mcpStep", @@ -170,15 +231,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { return command } - const copyToClipboard = () => { - const command = generateInstallCommand() - navigator.clipboard.writeText(command) - analytics.mcpInstallCmdCopied() - setIsCommandCopied(true) - setActiveStep(3) - setTimeout(() => setIsCommandCopied(false), 2000) - } - const copyManualSnippet = (text: string) => { navigator.clipboard.writeText(text) analytics.mcpInstallCmdCopied() @@ -203,17 +255,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { ? "manual" : setupTab - const gradientStep3 = - activeStep === 3 - ? { - background: - "linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)", - backgroundClip: "text", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - } - : undefined - return (
      0 && "border-t border-[#242A33]", + rowIndex > 0 && "border-t border-white/[0.06]", )} >
      -

      +

      {category.label}

      @@ -249,9 +290,9 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { setActiveStep(2) }} className={cn( - "group flex w-full min-w-0 items-center gap-3 rounded-xl border border-[#242A33] bg-[#080B0F] p-3.5 text-left transition-colors", - "hover:border-[#3273FC4D] hover:bg-[#08142D]", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3273FC]/40", + "group flex w-full min-w-0 items-center gap-3 rounded-[12px] bg-[#0D121A] p-3.5 text-left transition-colors", + "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] hover:bg-[#10151D]", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/40", )} > @@ -259,10 +300,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {

      {item.title}

      -

      {item.subtitle}

      +

      + {item.subtitle} +

      @@ -270,51 +313,63 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
      ))} -

      +

      You can connect several clients; setup is different for each one.

      ) : (
      - + {!isEmbedded && ( + <> + -
      - -
      -

      - {clients[selectedKey]} -

      -

      - {detailSetup ? setupInstructionsSubtitle(detailSetup) : ""} -

      -
      -
      +
      + +
      +

      + {clients[selectedKey]} +

      +

      + {detailSetup ? setupInstructionsSubtitle(detailSetup) : ""} +

      +
      +
      + + )} -
      +
      {detailSetup && mcpClientSetupShowsTabs(detailSetup) ? (
      -
      + { + analytics.mcpInstallCmdCopied() + toast.success("Copied to clipboard!") + setActiveStep(3) + }} + />
      )} {selectedClient === "cursor" && (
      -

      +

      Open the link below to add supermemory in Cursor, or use Manual instructions to edit{" "} - + ~/.cursor/mcp.json .

      -
      +
      { @@ -411,50 +450,18 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { {selectedClient !== "mcp-url" && selectedClient !== "cursor" && (
      -

      +

      Run this command in your terminal. It installs the MCP for {clients[selectedKey]} and starts OAuth when needed.

      -
      - - -
      + { + analytics.mcpInstallCmdCopied() + setActiveStep(3) + }} + />
      )} @@ -464,7 +471,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { if (manual.kind === "chatgpt") { return (
      -
        +
        1. Open ChatGPT in your browser.
        2. Go to Settings → Apps → Advanced settings → enable @@ -478,27 +485,15 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { Paste the URL below and complete OAuth in ChatGPT.
        -
        - - -
        + { + analytics.mcpInstallCmdCopied() + setActiveStep(3) + }} + />
        -

        +

        Add this to your client's MCP config. Replace the placeholder with an API key from supermemory settings (Integrations).

        -
        -
        -														
        -															{snippet}
        -														
        -													
        - -
        + { + analytics.mcpInstallCmdCopied() + setActiveStep(3) + }} + /> {detailSetup?.oneClick ? ( -

        +

        Use Bearer auth in headers, or switch to One click setup and paste the HTTPS URL if your client supports OAuth only. @@ -563,35 +543,22 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { } return (

        -

        {manual.paths}

        -

        +

        + {manual.paths} +

        +

        Merge the snippet with your existing file (create it if needed). Restart the client and complete OAuth when prompted.

        -
        -
        -													
        -														{manual.snippet}
        -													
        -												
        - -
        + { + analytics.mcpInstallCmdCopied() + setActiveStep(3) + }} + />
        ) })() @@ -599,47 +566,47 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
      -
      -

      - {detailSetup != null && - mcpClientShowsManual(detailSetup, effectiveSetupTab) - ? selectedClient === "chatgpt" - ? "Finish in ChatGPT" - : selectedClient === "claude" - ? "Finish in Claude Desktop" - : "Save and restart" - : selectedClient === "cursor" - ? "Finish in Cursor" - : "Run in terminal"} -

      - {activeStep === 3 && ( -

      +

      - {detailSetup != null && mcpClientShowsManual(detailSetup, effectiveSetupTab) ? selectedClient === "chatgpt" - ? "Complete app setup in ChatGPT, then enable it for your chat." + ? "Finish in ChatGPT" : selectedClient === "claude" - ? "After Connectors, complete any prompts so supermemory is enabled for chat." - : "Save your config, restart the client, and sign in if prompted." + ? "Finish in Claude Desktop" + : "Save and restart" : selectedClient === "cursor" - ? "Complete the install in Cursor and sign in with OAuth if asked." - : "Waiting for installation"} -

      - )} -

      + ? "Finish in Cursor" + : "Run in terminal"} + + {activeStep === 3 && ( +

      + + {detailSetup != null && + mcpClientShowsManual(detailSetup, effectiveSetupTab) + ? selectedClient === "chatgpt" + ? "Complete app setup in ChatGPT, then enable it for your chat." + : selectedClient === "claude" + ? "After Connectors, complete any prompts so supermemory is enabled for chat." + : "Save your config, restart the client, and sign in if prompted." + : selectedClient === "cursor" + ? "Complete the install in Cursor and sign in with OAuth if asked." + : "Waiting for installation"} +

      + )} +
      + )}
      )}