mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
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
This commit is contained in:
parent
bbfc059f96
commit
d2a2d1e325
2 changed files with 455 additions and 65 deletions
|
|
@ -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<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
|
@ -194,7 +238,7 @@ const catParam = parseAsStringEnum<CategoryFilter>([
|
|||
|
||||
const CATEGORY_LABEL: Record<CategoryFilter, string> = {
|
||||
all: "All",
|
||||
connected: "Connected",
|
||||
connected: "Active",
|
||||
plugins: "Plugins",
|
||||
"knowledge-bases": "Knowledge bases",
|
||||
"apps-extensions": "Apps & extensions",
|
||||
|
|
@ -432,7 +476,7 @@ function ProChip() {
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-[4px] border border-[#4BA0FA]/25 bg-[#4BA0FA]/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]",
|
||||
"ml-auto shrink-0 pl-2 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[104px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
"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]" />
|
||||
Connected
|
||||
<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 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 h-8 min-w-[104px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"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]" />
|
||||
|
|
@ -567,7 +633,7 @@ function ItemCard({
|
|||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{leftIndicator}
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -978,6 +1044,9 @@ export function IntegrationsView() {
|
|||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
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<ConnectedKey[]>(() => {
|
||||
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<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 out
|
||||
}, [apiKeys, keyPrefix])
|
||||
return map
|
||||
}, [activePlugins])
|
||||
|
||||
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[]> = {
|
||||
|
|
@ -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<Record<CategoryFilter, number>>(
|
||||
|
|
@ -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 (
|
||||
<ConnectedButton
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<ActiveButton
|
||||
count={activeCount}
|
||||
lastActive={activeKey.lastRequest}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setConnectedPluginId(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Connect ${item.name} to another agent`}
|
||||
title="Connect another agent"
|
||||
onClick={() => {
|
||||
if (needsProUpgrade) {
|
||||
handleUpgrade()
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (setupPluginIds.has(item.pluginId)) {
|
||||
return (
|
||||
<FinishSetupButton
|
||||
onClick={() => {
|
||||
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 <ConnectionsCountPill count={count} />
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<ConnectionsCountPill count={count} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another knowledge source"
|
||||
title="Add another knowledge source"
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
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 (needsProUpgrade) {
|
||||
return (
|
||||
<PillButton onClick={handleUpgrade}>
|
||||
|
|
@ -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 ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#00AC3F]" />
|
||||
) : 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 (
|
||||
<div className="flex-1 p-4 md:p-6 pt-2">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-5">
|
||||
{!q && category !== "connected" && (
|
||||
<FeaturedHero picks={featuredPicks} />
|
||||
)}
|
||||
{!q && <FeaturedHero picks={featuredPicks} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -1567,7 +1701,7 @@ export function IntegrationsView() {
|
|||
? `No integrations match “${search}”.`
|
||||
: "Nothing in this category yet."}
|
||||
</p>
|
||||
) : q ? (
|
||||
) : q || category !== "all" ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleItems.map((item) => renderItemCard(item))}
|
||||
</div>
|
||||
|
|
@ -1724,11 +1858,20 @@ export function IntegrationsView() {
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
{connectedDialogPlugin?.name ?? "Plugin"} connected
|
||||
{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">
|
||||
|
|
@ -1790,6 +1933,120 @@ export function IntegrationsView() {
|
|||
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}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ function ActivePill({
|
|||
>
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Connected</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Active</p>
|
||||
</div>
|
||||
<span className="size-3 rounded-full bg-[#37D67A]" />
|
||||
</div>
|
||||
|
|
@ -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 ? (
|
||||
<PillButton onClick={() => onFinishSetup(pluginId)}>
|
||||
<span className="size-[7px] rounded-full bg-[#EAB308]" />
|
||||
Finish setup
|
||||
</PillButton>
|
||||
) : needsProUpgrade ? (
|
||||
<PillButton onClick={onUpgrade}>
|
||||
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
|
||||
|
|
@ -449,6 +458,9 @@ export function PluginsDetail() {
|
|||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
|
|
@ -492,6 +504,28 @@ export function PluginsDetail() {
|
|||
},
|
||||
)
|
||||
|
||||
const setupPluginIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
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<ConnectedPlugin[]>(() => {
|
||||
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() {
|
|||
</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 && (
|
||||
<PluginIconBox
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
Complete install in the tool — status becomes 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,
|
||||
)}
|
||||
>
|
||||
{finishSetupPlugin?.installSteps?.length ? (
|
||||
<InstallSteps steps={finishSetupPlugin.installSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication.
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue