From d2a2d1e325755d6fe5994dc6dffaa74d5e9616b1 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Thu, 4 Jun 2026 14:34:48 -0700 Subject: [PATCH] feat: rework plugin/connector integration cards around active state - Show plugins as Active (used) vs Finish setup (key, no use) vs Connect - Active pill becomes subtle ghost status; dedicated + button connects another agent - Support multiple connections per plugin with count + manage modal - Mirror pattern for connectors: subtle connected status + add-knowledge shortcut - Move Pro to subtle accent on name row, Docs alone top-right - Wrap items into a grid for any specific category filter (rails only on All) - Keep featured hero visible on the Active filter --- apps/web/components/integrations-view.tsx | 385 +++++++++++++++--- .../integrations/plugins-detail.tsx | 135 +++++- 2 files changed, 455 insertions(+), 65 deletions(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index fb77040f..e1d83257 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -24,16 +24,19 @@ import { BookOpen, Check, Loader, + Plus, Search, X, Zap, } from "lucide-react" +import { formatRelativeTime } from "@/components/settings/sync-utils" import { CHROME_EXTENSION_URL } from "@lib/constants" import { analytics } from "@/lib/analytics" import Image from "next/image" import { useViewMode } from "@/lib/view-mode-context" import type { ViewParamValue } from "@/lib/search-params" import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs" +import { addDocumentParam } from "@/lib/search-params" import { useCallback, useEffect, @@ -63,14 +66,55 @@ interface ConnectedKey { keyId: string keyStart: string | null pluginId: string + lastRequest?: string | null + createdAt?: string | null +} + +function toIsoDate(value: string | Date | null | undefined): string | null { + if (!value) return null + const d = value instanceof Date ? value : new Date(value) + if (Number.isNaN(d.getTime())) return null + return d.toISOString() +} + +function parsePluginAuthKeys( + apiKeys: ListedApiKey[], + keyPrefix: (key: ListedApiKey) => string | null, +): { active: ConnectedKey[]; setup: ConnectedKey[] } { + const active: ConnectedKey[] = [] + const setup: ConnectedKey[] = [] + for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.metadata) continue + try { + const metadata = + typeof key.metadata === "string" + ? (JSON.parse(key.metadata) as { + sm_type?: string + sm_client?: string + }) + : (key.metadata as { sm_type?: string; sm_client?: string }) + if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue + const entry: ConnectedKey = { + keyId: key.id, + keyStart: keyPrefix(key), + pluginId: normalizePluginClientId(metadata.sm_client), + lastRequest: toIsoDate(key.lastRequest), + createdAt: toIsoDate(key.createdAt), + } + if (key.lastRequest) active.push(entry) + else setup.push(entry) + } catch {} + } + return { active, setup } } type ListedApiKey = { id: string name?: string | null - createdAt?: string + createdAt?: string | Date | null enabled?: boolean - lastRequest?: string | null + lastRequest?: string | Date | null metadata: string | Record | null start?: string | null } @@ -194,7 +238,7 @@ const catParam = parseAsStringEnum([ const CATEGORY_LABEL: Record = { all: "All", - connected: "Connected", + connected: "Active", plugins: "Plugins", "knowledge-bases": "Knowledge bases", "apps-extensions": "Apps & extensions", @@ -432,7 +476,7 @@ function ProChip() { Pro @@ -496,30 +540,52 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) { ) } -function ConnectedButton({ onClick }: { onClick: () => void }) { +function ActiveButton({ + count, + lastActive, + onClick, +}: { + count: number + lastActive?: string | null + onClick: () => void +}) { return ( ) } +function FinishSetupButton({ onClick }: { onClick: () => void }) { + return ( + + + Finish setup + + ) +} + function ConnectionsCountPill({ count }: { count: number }) { return ( @@ -567,7 +633,7 @@ function ItemCard({
-
+
{leftIndicator} ( null, ) + const [finishSetupPluginId, setFinishSetupPluginId] = useState< + string | null + >(null) const { data: pluginsData } = useQuery({ queryFn: async () => { @@ -1029,31 +1098,40 @@ export function IntegrationsView() { return key.start ?? (key.name?.startsWith("sm_") ? key.name : null) }, []) - const connectedPlugins = useMemo(() => { - const out: ConnectedKey[] = [] - for (const key of apiKeys) { - if (key.enabled === false) continue - if (!key.lastRequest) 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) { - out.push({ - keyId: key.id, - keyStart: keyPrefix(key), - pluginId: normalizePluginClientId(metadata.sm_client), - }) - } - } catch {} + const { active: activePlugins, setup: setupPlugins } = useMemo( + () => parsePluginAuthKeys(apiKeys, keyPrefix), + [apiKeys, keyPrefix], + ) + + const activePluginById = useMemo(() => { + const map = new Map() + 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 out - }, [apiKeys, keyPrefix]) + return map + }, [activePlugins]) + + const activeCountByPlugin = useMemo(() => { + const map = new Map() + 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 = { @@ -1171,6 +1249,7 @@ export function IntegrationsView() { ) const [category, setCategory] = useQueryState("cat", catParam) + const [, setAddDoc] = useQueryState("add", addDocumentParam) const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString) const [mcpModalOpen, setMcpModalOpen] = useState(false) const [search, setSearch] = useState("") @@ -1201,14 +1280,14 @@ export function IntegrationsView() { const isItemConnected = useCallback( (item: Item): boolean => { if (item.kind === "plugin") { - return connectedPlugins.some((k) => k.pluginId === item.pluginId) + return activePluginById.has(item.pluginId) } if (item.kind === "connector") { return connectionsByProvider[item.provider].length > 0 } return false }, - [connectedPlugins, connectionsByProvider], + [activePluginById, connectionsByProvider], ) const counts = useMemo>( @@ -1234,9 +1313,7 @@ export function IntegrationsView() { } }, [category, counts, setCategory]) - const claudeCodeConnected = connectedPlugins.some( - (k) => k.pluginId === "claude_code", - ) + const claudeCodeConnected = activePluginById.has("claude_code") const claudeCodeNeedsPro = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code") @@ -1290,7 +1367,7 @@ export function IntegrationsView() { ), docsUrl: "https://docs.supermemory.ai/integrations/claude-code", ctaLabel: claudeCodeConnected - ? "Connected" + ? "Active" : claudeCodeNeedsPro ? "Upgrade" : "Connect", @@ -1345,17 +1422,55 @@ export function IntegrationsView() { const renderRight = (item: Item): ReactNode => { switch (item.kind) { case "plugin": { - const keys = connectedPlugins.filter( - (k) => k.pluginId === item.pluginId, - ) + const activeKey = activePluginById.get(item.pluginId) + const activeCount = activeCountByPlugin.get(item.pluginId) ?? 0 const needsProUpgrade = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId) - if (keys.length > 0) { + if (activeKey) { + const busy = connectingPlugin === item.pluginId return ( - + { + trackCard(item) + setConnectedPluginId(item.pluginId) + }} + /> + +
+ ) + } + if (setupPluginIds.has(item.pluginId)) { + return ( + { trackCard(item) - setConnectedPluginId(item.pluginId) + setFinishSetupPluginId(item.pluginId) }} /> ) @@ -1389,7 +1504,28 @@ export function IntegrationsView() { case "connector": { const count = connectionsByProvider[item.provider].length const needsProUpgrade = !isAutumnLoading && !hasProProduct - if (count > 0) return + if (count > 0) { + return ( +
+ + +
+ ) + } if (needsProUpgrade) { return ( @@ -1488,16 +1624,7 @@ export function IntegrationsView() { /> ) - const renderLeftIndicator = (item: Item): ReactNode => { - if (item.kind === "plugin") { - return null - } - if (item.kind === "connector") { - const count = connectionsByProvider[item.provider].length - return count > 0 ? ( - - ) : null - } + const renderLeftIndicator = (_item: Item): ReactNode => { return null } @@ -1508,8 +1635,17 @@ export function IntegrationsView() { ? PLUGIN_CATALOG[connectedPluginId] : undefined const connectedDialogKeys = connectedPluginId - ? connectedPlugins.filter((key) => key.pluginId === 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 @@ -1530,9 +1666,7 @@ export function IntegrationsView() { return (
- {!q && category !== "connected" && ( - - )} + {!q && }
- ) : q ? ( + ) : q || category !== "all" ? (
{visibleItems.map((item) => renderItemCard(item))}
@@ -1724,11 +1858,20 @@ export function IntegrationsView() { )}

- {connectedDialogPlugin?.name ?? "Plugin"} connected + {connectedDialogPlugin?.name ?? "Plugin"}

Active + {activePluginById.get(connectedPluginId ?? "")?.lastRequest && ( + + ·{" "} + {formatRelativeTime( + activePluginById.get(connectedPluginId ?? "") + ?.lastRequest, + )} + + )}

@@ -1790,6 +1933,120 @@ export function IntegrationsView() { No active connection was found.

)} +

+ Connect this plugin to another agent to run them in parallel. +

+
+
+ {connectedDialogNeedsPro ? ( + + Upgrade to connect + more + + ) : ( + { + if (!connectedPluginId) return + const pluginId = connectedPluginId + setConnectedPluginId(null) + createPluginKeyMutation.mutate(pluginId) + }} + disabled={!!connectingPlugin} + > + {connectingPlugin === connectedPluginId ? ( + <> + Connecting… + + ) : ( + <> + Connect another + + )} + + )} + + + +
+ + + + { + if (!open) setFinishSetupPluginId(null) + }} + > + + + Finish setup {finishSetupPlugin?.name ?? "plugin"} + +
+ {finishSetupPlugin && ( + + {finishSetupPlugin.name} + + )} +
+

+ Finish setup {finishSetupPlugin?.name ?? "plugin"} +

+

+ Complete install in the tool — this card turns active after the + first API call. +

+
+ + + +
+
+
+ {finishSetupSteps.length > 0 ? ( + + ) : ( +

+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish + authentication, then send a test memory. +

+ )} +
diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index e121869c..0bd044ae 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -246,7 +246,7 @@ function ActivePill({ > {plugin.name}

-

Connected

+

Active

@@ -326,22 +326,26 @@ function PluginRow({ plugin, pluginId, connectedKeys, + needsSetup, needsProUpgrade, isConnecting, actionsDisabled, onConnect, onUpgrade, onRevoke, + onFinishSetup, }: { plugin: PluginInfo pluginId: string connectedKeys: ConnectedPlugin[] + needsSetup: boolean needsProUpgrade: boolean isConnecting: boolean actionsDisabled: boolean onConnect: (id: string) => void onUpgrade: () => void onRevoke: (keyId: string) => void + onFinishSetup: (id: string) => void }) { const isConnected = connectedKeys.length > 0 return ( @@ -383,6 +387,11 @@ function PluginRow({ connectedKeys={connectedKeys} onRevoke={onRevoke} /> + ) : needsSetup ? ( + onFinishSetup(pluginId)}> + + Finish setup + ) : needsProUpgrade ? ( Upgrade @@ -449,6 +458,9 @@ export function PluginsDetail() { const queryClient = useQueryClient() const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) + const [finishSetupPluginId, setFinishSetupPluginId] = useState( + null, + ) const [newKey, setNewKey] = useState<{ open: boolean key: string @@ -492,6 +504,28 @@ export function PluginsDetail() { }, ) + const setupPluginIds = useMemo(() => { + const ids = new Set() + for (const key of apiKeys) { + if (key.enabled === false) continue + if (key.lastRequest) 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) { + ids.add(normalizePluginClientId(metadata.sm_client)) + } + } catch {} + } + return ids + }, [apiKeys]) + const connectedPlugins = useMemo(() => { const plugins: ConnectedPlugin[] = [] for (const key of apiKeys) { @@ -616,6 +650,9 @@ export function PluginsDetail() { const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] : undefined + const finishSetupPlugin = finishSetupPluginId + ? PLUGIN_CATALOG[finishSetupPluginId] + : undefined const pluginSteps = dialogPlugin?.installSteps ?? [] // If a step already embeds the key (an `export …="sm_…"` line), don't also @@ -667,10 +704,15 @@ export function PluginsDetail() { connectedKeys={connectedPlugins.filter( (p) => p.pluginId === pluginId, )} + needsSetup={ + !connectedPluginIds.has(pluginId) && + setupPluginIds.has(pluginId) + } needsProUpgrade={needsProUpgrade} isConnecting={connectingPlugin === pluginId} actionsDisabled={!!connectingPlugin} onConnect={(id) => createPluginKeyMutation.mutate(id)} + onFinishSetup={(id) => setFinishSetupPluginId(id)} onUpgrade={handleUpgrade} onRevoke={handleRevoke} /> @@ -793,6 +835,97 @@ export function PluginsDetail() {
+ + { + if (!open) setFinishSetupPluginId(null) + }} + > + + + Finish setup {finishSetupPlugin?.name ?? "plugin"} + +
+ {finishSetupPlugin && ( + + )} +
+

+ Finish setup {finishSetupPlugin?.name ?? "plugin"} +

+

+ Complete install in the tool — status becomes active after the + first API call. +

+
+ + + +
+
+
+ {finishSetupPlugin?.installSteps?.length ? ( + + ) : ( +

+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish + authentication. +

+ )} +
+
+
+ + + +
+
+
) }