mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
## Summary - Reorder Apps & extensions so Import X bookmarks appears in the top row and Apple Shortcuts uses the open space below. - Keep both Apple Shortcut actions inline on larger screens while allowing the card to grow only as much as needed. - Rework the Company Brain promo on phones so its logo, copy, close control, and CTA remain readable and aligned.
4302 lines
113 KiB
TypeScript
4302 lines
113 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 { $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 { useConnectorAccess } from "@/hooks/use-connector-access"
|
|
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"
|
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
|
|
|
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
|
|
}
|
|
|
|
interface ConnectedMcpKey {
|
|
keyId: string
|
|
keyStart: string | null
|
|
lastRequest?: string | null
|
|
createdAt?: string | null
|
|
}
|
|
|
|
function isMcpAuthMetadata(metadata: { sm_source?: string; sm_kind?: string }) {
|
|
return (
|
|
metadata.sm_source === "mcp" || metadata.sm_kind === "mcp_oauth_exchange"
|
|
)
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
function parseMcpAuthKeys(
|
|
apiKeys: ListedApiKey[],
|
|
keyPrefix: (key: ListedApiKey) => string | null,
|
|
): ConnectedMcpKey[] {
|
|
const keys: ConnectedMcpKey[] = []
|
|
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_source?: string
|
|
sm_kind?: string
|
|
})
|
|
: (key.metadata as { sm_source?: string; sm_kind?: string })
|
|
if (!isMcpAuthMetadata(metadata)) continue
|
|
keys.push({
|
|
keyId: key.id,
|
|
keyStart: keyPrefix(key),
|
|
lastRequest: toIsoDate(key.lastRequest),
|
|
createdAt: toIsoDate(key.createdAt),
|
|
})
|
|
} catch {}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
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: "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,
|
|
},
|
|
{
|
|
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: "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 },
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
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 McpConnectedPill({
|
|
connectedAt,
|
|
lastActive,
|
|
}: {
|
|
connectedAt?: string | null
|
|
lastActive?: string | 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]" />
|
|
Connected
|
|
{(lastActive ?? connectedAt) && (
|
|
<span className="text-[11px] font-normal text-[#737373]">
|
|
· {formatRelativeTime(lastActive ?? connectedAt)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
interface McpEntry {
|
|
kind: "mcp"
|
|
id: string
|
|
name: string
|
|
icon: ReactNode
|
|
connectionCount: number
|
|
createdAt: string | null
|
|
lastActive: string | null
|
|
onManage: () => void
|
|
}
|
|
|
|
type RailEntry = PluginEntry | ConnectorEntry | McpEntry
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
function McpRailRow({ entry }: { entry: McpEntry }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const lastTime = entry.lastActive ?? entry.createdAt
|
|
const suffix = [
|
|
entry.connectionCount > 1 ? `${entry.connectionCount} connections` : 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="MCP keys"
|
|
value={`${entry.connectionCount} connected`}
|
|
/>
|
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
<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} />
|
|
) : entry.kind === "mcp" ? (
|
|
<McpRailRow 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} />
|
|
) : entry.kind === "mcp" ? (
|
|
<McpRailRow 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,
|
|
layoutClassName,
|
|
}: {
|
|
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
|
|
layoutClassName?: string
|
|
}) {
|
|
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)]",
|
|
id === "shortcuts" &&
|
|
"max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center",
|
|
layoutClassName,
|
|
)}
|
|
>
|
|
<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={cn(
|
|
"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",
|
|
id === "shortcuts" && "max-sm:contents",
|
|
)}
|
|
>
|
|
<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={cn(
|
|
"flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between",
|
|
id === "shortcuts" &&
|
|
"max-sm:col-span-2 max-sm:row-start-2 max-sm:w-full",
|
|
)}
|
|
>
|
|
{/* 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={cn(
|
|
"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]",
|
|
id === "shortcuts" &&
|
|
"max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full",
|
|
)}
|
|
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,
|
|
headerSlot,
|
|
}: {
|
|
label: string
|
|
children: ReactNode
|
|
headerSlot?: 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">
|
|
{headerSlot}
|
|
<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 promoCode = usePromoCode()
|
|
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
|
// stay on hasProProduct. See useConnectorAccess.
|
|
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
|
enabled: !publicMode,
|
|
})
|
|
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 && connectorAccess,
|
|
})
|
|
|
|
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 activeMcpKeys = useMemo(
|
|
() => parseMcpAuthKeys(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 activeMcpKey = useMemo(() => {
|
|
let latest: ConnectedMcpKey | null = null
|
|
for (const key of activeMcpKeys) {
|
|
if (!latest) {
|
|
latest = key
|
|
continue
|
|
}
|
|
const a = toMs(key.lastRequest ?? key.createdAt)
|
|
const b = toMs(latest.lastRequest ?? latest.createdAt)
|
|
if (a >= b) latest = key
|
|
}
|
|
return latest
|
|
}, [activeMcpKeys])
|
|
|
|
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) {
|
|
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,
|
|
discounts: promoCode.getDiscounts(),
|
|
successUrl: `${window.location.origin}/integrations`,
|
|
})
|
|
promoCode.clear()
|
|
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, promoCode],
|
|
)
|
|
|
|
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 (!connectorAccess) {
|
|
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,
|
|
connectorAccess,
|
|
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),
|
|
},
|
|
})
|
|
}
|
|
if (activeMcpKey) {
|
|
rows.push({
|
|
ts: toMs(activeMcpKey.lastRequest ?? activeMcpKey.createdAt),
|
|
entry: {
|
|
kind: "mcp",
|
|
id: "mcp",
|
|
name: "Supermemory MCP",
|
|
icon: <MCPIcon className="size-6" />,
|
|
connectionCount: activeMcpKeys.length,
|
|
createdAt: activeMcpKey.createdAt ?? null,
|
|
lastActive: activeMcpKey.lastRequest ?? null,
|
|
onManage: () => {
|
|
void setMcpClient("mcp-url")
|
|
setMcpModalOpen(true)
|
|
},
|
|
},
|
|
})
|
|
}
|
|
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)
|
|
}, [
|
|
activeMcpKey,
|
|
activeMcpKeys.length,
|
|
activePluginById,
|
|
activeCountByPlugin,
|
|
connectionsByProvider,
|
|
allProjects,
|
|
setAddDoc,
|
|
setMcpClient,
|
|
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 mcpConnected = !!activeMcpKey
|
|
|
|
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: mcpConnected ? "Connected" : "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 && !connectorAccess
|
|
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 (!connectorAccess) {
|
|
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 (!connectorAccess) {
|
|
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 (
|
|
<button
|
|
type="button"
|
|
aria-label={`Connect ${item.name}`}
|
|
title="Connect"
|
|
onClick={() => {
|
|
trackCard(item)
|
|
openMcpClient(item.clientKey)
|
|
}}
|
|
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>
|
|
)
|
|
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, layoutClassName?: string) => (
|
|
<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)}
|
|
layoutClassName={layoutClassName}
|
|
/>
|
|
)
|
|
|
|
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,
|
|
item.id === "shortcuts"
|
|
? "sm:w-max sm:min-w-full"
|
|
: undefined,
|
|
),
|
|
)}
|
|
</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]}
|
|
headerSlot={
|
|
cat === "ai-clients" && activeMcpKey ? (
|
|
<McpConnectedPill
|
|
connectedAt={activeMcpKey.createdAt}
|
|
lastActive={activeMcpKey.lastRequest}
|
|
/>
|
|
) : null
|
|
}
|
|
>
|
|
{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={connectorAccess && granolaModalOpen}
|
|
onOpenChange={(open) => {
|
|
setGranolaModalOpen(open && connectorAccess)
|
|
if (!open) void setConnectTarget(null)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|