diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index e0b97e3f..c7bdb09a 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -238,8 +238,7 @@ async function getRelatedMemoriesForT3(actionSource: string) { } if (textareaElement) { - textareaElement.dataset.supermemories = - `\n\nSupermemories of user (only for the reference): ${response.data}` + textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` iconElement.dataset.memoriesData = response.data diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index c7b43f23..ade962ae 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -7,98 +7,161 @@ import { useAuth } from "@lib/auth-context" import { hasActivePlan } from "@lib/queries" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import * as DialogPrimitive from "@radix-ui/react-dialog" import { - ArrowRight, BookOpen, Check, - CheckCircle, + ChevronDown, Copy, - ExternalLink, Loader, - Trash2, + X, Zap, } from "lucide-react" import Image from "next/image" -import { useMemo, useState } from "react" +import { type ReactNode, useEffect, useMemo, useState } from "react" import { toast } from "sonner" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogPortal, -} from "@ui/components/dialog" +import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" + +/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */ +const INSET = + "shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" /** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */ +const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"] function isFreeTierPlugin(pluginId: string): boolean { - return pluginId === "hermes" + return FREE_TIER_PLUGIN_IDS.includes(pluginId) +} + +interface InstallStep { + title: string + description?: string + code?: string + copyLabel?: string + optional?: boolean + /** Blur the code block until hovered/focused (e.g. it contains the key). */ + secret?: boolean } interface PluginInfo { id: string name: string - description: string - features: string[] + tagline: string icon: string docsUrl?: string - repoUrl?: string + /** Steps shown after a key is minted. The literal `sm_...` is replaced + * with the freshly generated key when rendered. */ + installSteps?: InstallStep[] } const PLUGIN_CATALOG: Record = { claude_code: { id: "claude_code", name: "Claude Code", - description: - "Claude Code remembers your conventions, past decisions, and project context across every session — no re-explaining yourself.", - features: [ - "Picks up where you left off at session start", - "Captures decisions and patterns from tool usage", - "Builds a persistent profile of how you work", - ], + tagline: "Remembers your conventions, decisions, and project context", icon: "/images/plugins/claude-code.svg", docsUrl: "https://docs.supermemory.ai/integrations/claude-code", - repoUrl: "https://github.com/supermemoryai/claude-supermemory", + installSteps: [ + { + title: "Save your API key", + description: + "Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.", + code: 'export SUPERMEMORY_CC_API_KEY="sm_..."', + copyLabel: "API key", + secret: true, + }, + { + title: "Install the plugin", + description: "Run these commands inside a Claude Code session:", + code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory", + }, + ], + }, + codex: { + id: "codex", + name: "Codex", + tagline: "Persistent memory for the Codex CLI — free on every plan", + icon: "/images/plugins/codex.png", + docsUrl: "https://docs.supermemory.ai/integrations/codex", + installSteps: [ + { + title: "Save your API key", + description: + "Add this to your shell profile. This key is shown only once — save it now.", + code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."', + copyLabel: "API key", + secret: true, + }, + { + title: "Install the hooks", + description: "Run this to wire Supermemory into Codex CLI:", + code: "npx codex-supermemory@latest install", + }, + ], }, opencode: { id: "opencode", name: "OpenCode", - description: - "Gives OpenCode persistent memory — your patterns, preferences, and decisions carry forward automatically, session to session.", - features: [ - "Semantic search across previous sessions", - "Auto-capture of coding decisions", - "Context injected before each prompt", - ], + tagline: "Long-term memory for your OpenCode sessions", icon: "/images/plugins/opencode.svg", docsUrl: "https://docs.supermemory.ai/integrations/opencode", + installSteps: [ + { + title: "Save your API key", + description: + "Add this to your shell profile. This key is shown only once — save it now.", + code: 'export SUPERMEMORY_API_KEY="sm_..."', + copyLabel: "API key", + secret: true, + }, + { + title: "Install the plugin", + description: "Use --no-tui for non-interactive environments.", + code: "bunx opencode-supermemory@latest install", + }, + { + title: "Verify your config", + description: + "Ensure ~/.config/opencode/opencode.jsonc includes the plugin:", + code: '{\n "plugin": ["opencode-supermemory"]\n}', + optional: true, + }, + ], }, openclaw: { id: "openclaw", name: "OpenClaw", - description: - "Persists memory across Telegram, WhatsApp, Discord, and Slack. OpenClaw knows who users are and what they talked about before.", - features: [ - "Cross-channel memory that follows the user", - "Automatic conversation capture", - "User profiles built across every platform", - ], + tagline: "Cross-platform memory across Telegram, Discord, Slack", icon: "/images/plugins/openclaw.svg", docsUrl: "https://docs.supermemory.ai/integrations/openclaw", - repoUrl: "https://github.com/supermemoryai/openclaw-supermemory", + installSteps: [ + { + title: "Install the plugin", + description: "Run this in your OpenClaw project:", + code: "openclaw plugins install @supermemory/openclaw-supermemory", + }, + { + title: "Configure Supermemory", + description: + "Run the setup command and paste your API key when prompted:", + code: "openclaw supermemory setup", + }, + ], }, hermes: { id: "hermes", name: "Hermes", - description: - "Hermes never forgets. Conversations, user profiles, and context persist so every session feels like a continuation, not a cold start.", - features: [ - "Semantic search across previous sessions", - "Auto-capture of conversation context", - "Persistent user profile built over time", - ], + tagline: "Persistent memory for the Hermes agent — free on every plan", icon: "/images/plugins/hermes.svg", docsUrl: "https://docs.supermemory.ai/integrations/hermes", - repoUrl: "https://github.com/NousResearch/hermes-agent", + installSteps: [ + { + title: "Run Hermes memory setup", + description: + "On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:", + code: "hermes memory setup", + }, + ], }, } @@ -107,284 +170,447 @@ interface ConnectedPlugin { keyId: string pluginId: string createdAt: string - lastUsed?: string | null keyStart?: string | null } -function ProUpgradeNudge({ onUpgrade }: { onUpgrade: () => void }) { +function SectionHeader({ children }: { children: ReactNode }) { return ( -
-
- -

- Unlock Claude Code, OpenCode, OpenClaw and more with{" "} - Pro -

-
- +

+ {children} +

+ ) +} + +function PluginIconBox({ + src, + alt, + dimmed, +}: { + src: string + alt: string + dimmed?: boolean +}) { + return ( +
+ {alt}
) } -function ConnectedPluginRow({ - plugin, - info, +function ProChip() { + return ( + + Pro + + ) +} + +function PillButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick?: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function DocsLink({ href }: { href: string }) { + return ( + + Docs + + ) +} + +function DisconnectButton({ onConfirm }: { onConfirm: () => void }) { + const [confirming, setConfirming] = useState(false) + useEffect(() => { + if (!confirming) return + const t = setTimeout(() => setConfirming(false), 3000) + return () => clearTimeout(t) + }, [confirming]) + return ( + + ) +} + +function ConnectedPill({ + connectedKeys, onRevoke, }: { - plugin: ConnectedPlugin - info: PluginInfo | undefined + connectedKeys: ConnectedPlugin[] onRevoke: (keyId: string) => void }) { return ( -
-
- {info && ( -
- {info.name} -
+ + + + + -

- {info?.name || plugin.pluginId} -

-
-
- +

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

+
+ {connectedKeys.map((k) => ( +
- Connected - - {plugin.keyStart && ( - {plugin.keyStart}… + {k.keyStart ? `${k.keyStart}…` : "API key"} - )} -
+ onRevoke(k.keyId)} /> +
+ ))}
- -
-
+ + ) } -function PluginCard({ +function PluginRow({ plugin, pluginId, - isConnected, - isCurrentlyConnecting, - connectingPlugin, + connectedKeys, needsProUpgrade, + isConnecting, + actionsDisabled, onConnect, onUpgrade, + onRevoke, }: { plugin: PluginInfo pluginId: string - isConnected: boolean - isCurrentlyConnecting: boolean - connectingPlugin: string | null + connectedKeys: ConnectedPlugin[] needsProUpgrade: boolean + isConnecting: boolean + actionsDisabled: boolean onConnect: (id: string) => void onUpgrade: () => void + onRevoke: (keyId: string) => void }) { + const isConnected = connectedKeys.length > 0 return ( -
-
-
+ +
+
+ {isConnected && ( + + )} + + {plugin.name} + + {!isConnected && needsProUpgrade && } +
+

- {plugin.name} -

-
-
- - {plugin.name} - - {isConnected && ( - - Connected - - )} - {needsProUpgrade && ( - - PRO - - )} -
-

- {plugin.description} -

-
+ {plugin.tagline} +

- -
    - {plugin.features.map((feature) => ( -
  • - - - {feature} - -
  • - ))} -
- -
+
+ {plugin.docsUrl && } {isConnected ? ( - + ) : needsProUpgrade ? ( - + + Upgrade + ) : ( - + )} -
- {plugin.docsUrl && ( - - Docs - - )} - {plugin.repoUrl && ( - - GitHub - - )} -
) } +type TierFilter = "all" | "pro" | "free" + +const TIER_FILTERS: { value: TierFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "pro", label: "Pro" }, + { value: "free", label: "Free" }, +] + +function TierFilterToggle({ + value, + onChange, +}: { + value: TierFilter + onChange: (value: TierFilter) => void +}) { + return ( +
+ {TIER_FILTERS.map((filter) => ( + + ))} +
+ ) +} + +function CopyButton({ text, label }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +function CodeBlock({ + code, + copyLabel = "Command", + secret, +}: { + code: string + copyLabel?: string + secret?: boolean +}) { + return ( +
+
+				{code}
+			
+ +
+ ) +} + +function InstallSteps({ + steps, + apiKey, +}: { + steps: InstallStep[] + apiKey: string +}) { + return ( +
    + {steps.map((step, i) => ( +
  1. +
    + + {i + 1} + + {i < steps.length - 1 && ( + + )} +
    +
    +
    +
    +

    + {step.title} +

    + {step.optional && ( + + Optional + + )} +
    + {step.description && ( +

    + {step.description} +

    + )} +
    + {step.code && ( + + )} +
    +
  2. + ))} +
+ ) +} + export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() const queryClient = useQueryClient() + const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) - const [newKey, setNewKey] = useState<{ open: boolean; key: string }>({ + const [newKey, setNewKey] = useState<{ + open: boolean + key: string + pluginId: string | null + }>({ open: false, key: "", + pluginId: null, }) - const [keyCopied, setKeyCopied] = useState(false) const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro") @@ -432,7 +658,6 @@ export function PluginsDetail() { keyId: key.id, pluginId: metadata.sm_client, createdAt: key.createdAt.toISOString(), - lastUsed: key.lastRequest?.toISOString() ?? null, keyStart: key.start ?? null, }) } @@ -442,7 +667,7 @@ export function PluginsDetail() { }, [apiKeys]) const connectedPluginIds = useMemo( - () => connectedPlugins.map((p) => p.pluginId), + () => new Set(connectedPlugins.map((p) => p.pluginId)), [connectedPlugins], ) @@ -477,9 +702,8 @@ export function PluginsDetail() { setConnectingPlugin(null) queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] }) }, - onSuccess: (data) => { - setNewKey({ open: true, key: data.key }) - toast.success("Plugin connected!") + onSuccess: (data, pluginId) => { + setNewKey({ open: true, key: data.key, pluginId }) }, }) @@ -510,91 +734,98 @@ export function PluginsDetail() { } } - const handleCopyKey = async () => { - try { - await navigator.clipboard.writeText(newKey.key) - setKeyCopied(true) - setTimeout(() => setKeyCopied(false), 2000) - toast.success("API key copied!") - } catch { - toast.error("Failed to copy") - } - } - const isLoading = autumn.isLoading const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG) - const allCatalogPluginIds = useMemo( + const catalogRows = useMemo( () => availablePlugins.filter((id) => PLUGIN_CATALOG[id]), [availablePlugins], ) + const visibleRows = useMemo(() => { + const filtered = catalogRows.filter((id) => { + if (tierFilter === "free") return isFreeTierPlugin(id) + if (tierFilter === "pro") return !isFreeTierPlugin(id) + return true + }) + // Connected plugins float to the top (stable within each group). + return [...filtered].sort( + (a, b) => + Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)), + ) + }, [catalogRows, tierFilter, connectedPluginIds]) + + const dialogPlugin = newKey.pluginId + ? PLUGIN_CATALOG[newKey.pluginId] + : undefined + + const pluginSteps = dialogPlugin?.installSteps ?? [] + // If a step already embeds the key (an `export …="sm_…"` line), don't also + // show the bare key in its own step — that's the repetition to avoid. + // Otherwise (wizard-style installs) lead with a copy-the-key step. + const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) + const setupSteps: InstallStep[] = stepsEmbedKey + ? pluginSteps + : [ + { + title: "Copy your API key", + description: + "You won't be able to see it again — store it somewhere safe.", + code: newKey.key, + copyLabel: "API key", + secret: true, + }, + ...pluginSteps, + ] + return ( <>
- {!hasProProduct && !isLoading && ( - - )} - - {connectedPlugins.length > 0 && ( -
- - Connected - - {connectedPlugins.map((plugin) => ( - - ))} -
- )} -
- + Plugins + {catalogRows.length > 0 && ( + )} - > - {connectedPlugins.length > 0 - ? "Add more plugins" - : "Available plugins"} - -
- {allCatalogPluginIds.map((pluginId) => { +
+
+ {visibleRows.map((pluginId) => { const plugin = PLUGIN_CATALOG[pluginId] if (!plugin) return null - const isConnected = connectedPluginIds.includes(pluginId) - const isCurrentlyConnecting = connectingPlugin === pluginId const needsProUpgrade = !isLoading && !hasProProduct && !isFreeTierPlugin(pluginId) return ( - p.pluginId === pluginId, + )} needsProUpgrade={needsProUpgrade} + isConnecting={connectingPlugin === pluginId} + actionsDisabled={!!connectingPlugin} onConnect={(id) => createPluginKeyMutation.mutate(id)} onUpgrade={handleUpgrade} + onRevoke={handleRevoke} /> ) })} + {visibleRows.length === 0 && ( +

+ No plugins in this category. +

+ )}
@@ -602,65 +833,105 @@ export function PluginsDetail() { - setNewKey({ open, key: open ? newKey.key : "" }) + setNewKey((s) => ({ + open, + key: open ? s.key : "", + pluginId: open ? s.pluginId : null, + })) } > - - - - + + Set up {dialogPlugin?.name ?? "your plugin"} + + +
+ {dialogPlugin && ( + + )} +
+

- Plugin Connected - - -

-

- Save your API key now: you won't be able to see it again. + Set up {dialogPlugin?.name ?? "your plugin"}

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

- - +
+ {dialogPlugin?.docsUrl && ( + + Docs + + )} + + + +
+
+ +
+
+ +
+
+ +
+ +
+
) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 30227779..a7ae2d63 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -188,14 +188,21 @@ function formatOrgRole(role: string): string { } export default function Account() { - const { user, org, setActiveOrg, clearActiveOrg } = useAuth() + const { + user, + org, + organizations: allOrgs, + setActiveOrg, + clearActiveOrg, + } = useAuth() const autumn = useCustomer() const [isUpgrading, setIsUpgrading] = useState(false) const [emailConfirm, setEmailConfirm] = useState("") const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isClosingAccount, setIsClosingAccount] = useState(false) const [switchingOrgId, setSwitchingOrgId] = useState(null) - const { data: allOrgs } = authClient.useListOrganizations() + const [orgMenuOpen, setOrgMenuOpen] = useState(false) + const canSwitchOrg = (allOrgs?.length ?? 0) > 1 const { data: memberships, isPending: membershipsPending } = useAccountMemberships() @@ -363,10 +370,19 @@ export default function Account() { > Organization

- + { + if (canSwitchOrg) setOrgMenuOpen(open) + }} + > @@ -378,14 +394,16 @@ export default function Account() { > {org?.name ?? "Personal"} - + {canSwitchOrg && ( + + )} - {allOrgs && allOrgs.length > 1 && ( + {canSwitchOrg && ( - {allOrgs.map((organization) => { + {allOrgs?.map((organization) => { const isCurrent = organization.id === org?.id const isSwitching = switchingOrgId === organization.id return ( diff --git a/apps/web/globals.css b/apps/web/globals.css index 283c51f2..ac424c36 100644 --- a/apps/web/globals.css +++ b/apps/web/globals.css @@ -65,6 +65,14 @@ scrollbar-color: var(--sm-scrollbar-thumb) transparent; } +.scrollbar-none { + scrollbar-width: none; +} + +.scrollbar-none::-webkit-scrollbar { + display: none; +} + .sm-tweet-theme .react-tweet-theme { --tweet-container-margin: 0px; font-size: inherit !important; diff --git a/apps/web/public/images/plugins/codex.png b/apps/web/public/images/plugins/codex.png new file mode 100644 index 00000000..11155e4d Binary files /dev/null and b/apps/web/public/images/plugins/codex.png differ