From b4cae4ad9076b328e42584c8043ca68d7fcfee07 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Tue, 19 May 2026 20:48:20 -0700 Subject: [PATCH 1/2] feat(web): overhaul integrations page with featured hero, category rails and MCP setup modal - Rebuild the integrations catalog as horizontally-scrollable category rails (MCP, Plugins, Knowledge bases, Apps & extensions) with header chevrons - Add an auto-rotating Featured hero with problem-led headlines, backdrop imagery and a gradient FEATURED badge - Surface all 9 MCP clients as individual cards that open a setup modal styled like the plugin connect flow (reuses MCPSteps embedded variant) - Align MCP setup steps and the Claude Desktop timeline to the design system - Merge the Integrations and Connections settings tabs into one entry --- .../app/(app)/settings/integrations/page.tsx | 5 +- apps/web/components/integrations-view.tsx | 1852 +++++++++++++++-- .../claude-desktop-manual-timeline.tsx | 78 +- .../components/mcp-modal/mcp-detail-view.tsx | 434 ++-- 4 files changed, 1900 insertions(+), 469 deletions(-) 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..00ad6bbe5 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,376 @@ 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, + Sparkles, + 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 { 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, + type PluginInfo, +} 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", }, { - id: "mcp", - title: "Connect to AI", - description: "Set up MCP to use your memory in Cursor, Claude, and more", - icon: ( - MCP - ), + key: "chatgpt", + name: "ChatGPT", + tagline: "Apps via ChatGPT developer mode", + simpleTitle: "Let ChatGPT recall what you've saved", }, { - id: "chrome", - title: "Chrome Extension", - description: "Save any webpage, import bookmarks, sync ChatGPT memories", - icon: , - externalHref: CHROME_EXTENSION_URL, + 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" + 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 +394,7 @@ export function DetailWrapper({ children, }: { onBack: () => void - children: React.ReactNode + children: ReactNode }) { return (
@@ -164,20 +413,598 @@ 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 && ( +
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 = () => { + 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() + } + }, []) + + 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 +1020,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 +1038,720 @@ 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.") + } + } + + 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 = (item: Item): boolean => { + if (item.kind === "plugin") { + return connectedPlugins.some((k) => k.pluginId === item.pluginId) } - if (id === "plugins") { - return connectedPluginCount > 0 - ? { label: `${connectedPluginCount} connected`, variant: "connected" } - : undefined + if (item.kind === "connector") { + return connectionsByProvider[item.provider].length > 0 } - return undefined + return false + } + + const counts: Record = { + 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, } + 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 + + ) + } + } + + 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..fe98a855e 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,14 +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) @@ -203,17 +256,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 +291,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 +301,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {

      {item.title}

      -

      {item.subtitle}

      +

      + {item.subtitle} +

      @@ -270,51 +314,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 +451,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 +472,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 +486,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 +544,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 +567,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"} +

      + )} +
      + )}
      )}
      From ebd1da21bacf037436de01a1ca3ae1a5af62a40b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 20:39:16 +0000 Subject: [PATCH 2/2] fix: resolve biome lint errors in integrations view Remove unused imports (Sparkles, PluginInfo), fix import type style, wrap update function in useCallback with deps, memoize counts object, and add a11y suppression for event propagation wrapper. Co-Authored-By: Claude Opus 4.5 --- apps/web/components/integrations-view.tsx | 100 ++++++++++-------- .../components/mcp-modal/mcp-detail-view.tsx | 1 - 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 00ad6bbe5..e602f070c 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -26,7 +26,6 @@ import { ChevronDown, Loader, Search, - Sparkles, X, Zap, } from "lucide-react" @@ -34,9 +33,16 @@ import { CHROME_EXTENSION_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 type { ViewParamValue } from "@/lib/search-params" import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs" -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react" +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" @@ -46,7 +52,6 @@ import { FREE_TIER_PLUGIN_IDS, isFreeTierPlugin, type InstallStep, - type PluginInfo, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" import { MCPSteps } from "./mcp-modal/mcp-detail-view" @@ -194,7 +199,9 @@ const SECTION_ORDER: Array> = [ "apps-extensions", ] -function itemCategory(item: Item): Exclude { +function itemCategory( + item: Item, +): Exclude { switch (item.kind) { case "plugin": return "plugins" @@ -380,9 +387,7 @@ const SECTIONS: Array<{ name: "Import X bookmarks", tagline: "Turn your X/Twitter bookmarks into memories", simpleTitle: "Turn your X bookmarks into memory", - icon: ( - X - ), + icon: X, viewMode: "import" as ViewParamValue, }, ], @@ -577,7 +582,12 @@ function ItemCard({
      {icon} {docsUrl && ( -
      e.stopPropagation()}> + // biome-ignore lint/a11y/noStaticElementInteractions: wrapper to stop event propagation +
      e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + >
      )} @@ -749,8 +759,8 @@ function FeaturedHero({ picks }: { picks: FeaturedPick[] }) { "text-[13px] leading-snug text-[#A1A1AA]", )} > - {pick.name}{" "} - · {pick.support} + {pick.name} ·{" "} + {pick.support}

      @@ -901,12 +911,12 @@ function SectionRail({ const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) - const update = () => { + const update = useCallback(() => { const el = scrollRef.current if (!el) return setCanScrollLeft(el.scrollLeft > 4) setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) - } + }, []) useEffect(() => { update() @@ -921,7 +931,7 @@ function SectionRail({ el.removeEventListener("scrollend", update) ro.disconnect() } - }, []) + }, [update]) const scrollBy = (dir: 1 | -1) => { scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" }) @@ -1197,35 +1207,40 @@ export function IntegrationsView() { const allItems = useMemo( () => SECTIONS.flatMap((s) => s.items(PLUGIN_CATALOG)).filter( - (item) => - item.kind !== "plugin" || enabledPluginIds.has(item.pluginId), + (item) => item.kind !== "plugin" || enabledPluginIds.has(item.pluginId), ), [enabledPluginIds], ) - const isItemConnected = (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 - } + 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: Record = { - 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, - } + 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) { @@ -1473,7 +1488,9 @@ export function IntegrationsView() { return null } - const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] : undefined + 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 @@ -1544,10 +1561,7 @@ export function IntegrationsView() { return ( {items.map((item) => ( -
      +
      {renderItemCard(item)}
      ))} diff --git a/apps/web/components/mcp-modal/mcp-detail-view.tsx b/apps/web/components/mcp-modal/mcp-detail-view.tsx index fe98a855e..61b176e20 100644 --- a/apps/web/components/mcp-modal/mcp-detail-view.tsx +++ b/apps/web/components/mcp-modal/mcp-detail-view.tsx @@ -231,7 +231,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { return command } - const copyManualSnippet = (text: string) => { navigator.clipboard.writeText(text) analytics.mcpInstallCmdCopied()