From 413688cc67dc449e05bb82fd616d45f386a1ff02 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 2 Sep 2026 19:07:21 +0545 Subject: [PATCH 1/5] Redesign the Plugins gallery --- bun.lock | 9 +- packages/plugins-ui/package.json | 1 + packages/plugins-ui/src/index.ts | 7 +- packages/plugins-ui/src/mcp-preset-cards.tsx | 186 ++++----- packages/plugins-ui/src/plugin-card.tsx | 57 +-- packages/plugins-ui/src/plugin-meta.ts | 70 ++-- packages/plugins-ui/src/plugins-gallery.tsx | 335 ++++++++++++----- packages/plugins-ui/src/styles.css | 41 +- .../plugins-ui/test/mcp-preset-cards.test.tsx | 71 ++-- packages/plugins-ui/test/plugin-meta.test.ts | 27 +- .../plugins-ui/test/plugins-gallery.test.tsx | 352 +++++++++--------- scripts/e2e/browser/walkthrough.ts | 155 ++++++++ 12 files changed, 791 insertions(+), 520 deletions(-) diff --git a/bun.lock b/bun.lock index 8f7f39c51..da47e3c23 100644 --- a/bun.lock +++ b/bun.lock @@ -1079,6 +1079,7 @@ "version": "0.0.1", "dependencies": { "@corbits/connections": "workspace:*", + "@corbits/error-sink": "workspace:*", "@corbits/icons": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", @@ -3581,10 +3582,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3607,10 +3604,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/plugins-ui/package.json b/packages/plugins-ui/package.json index 25e85d783..209be9d27 100644 --- a/packages/plugins-ui/package.json +++ b/packages/plugins-ui/package.json @@ -14,6 +14,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/error-sink": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", "@corbits/connections": "workspace:*", diff --git a/packages/plugins-ui/src/index.ts b/packages/plugins-ui/src/index.ts index 04c7a9fc2..22f8cee26 100644 --- a/packages/plugins-ui/src/index.ts +++ b/packages/plugins-ui/src/index.ts @@ -7,7 +7,6 @@ export type { SkillCardData } from "./skill-card"; export { InstalledStrip } from "./installed-strip"; export { PluginConnectPanel } from "./plugin-connect-panel"; export { McpServersSection } from "./mcp-servers-section"; -export { McpPresetCardsSection } from "./mcp-preset-cards"; export { PLUGINS_STRINGS } from "./strings"; @@ -27,10 +26,8 @@ export type { } from "./mcp-servers-api"; export { - FEATURED_CONNECTOR_IDS, - PLUGIN_CATEGORY_ORDER, - pluginCategory, + pluginCatalogCategory, pluginIcon, pluginOutcome, } from "./plugin-meta"; -export type { PluginCategory } from "./plugin-meta"; +export type { PluginCatalogCategory } from "./plugin-meta"; diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 27e9f0849..80b052f3b 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -2,6 +2,7 @@ // get the catalog's one-click installation path. Presets and previously // connected custom servers share the same server-side store. +import { reportError } from "@corbits/error-sink"; import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui"; import { CONNECTOR_REGISTRY, @@ -65,7 +66,7 @@ function mcpOauthConnectedReturn(): return { slug, toolCount }; } -function McpPresetCard({ +export function McpPresetCard({ tenantId, preset, toolCount, @@ -140,7 +141,9 @@ function McpPresetCard({ return (
@@ -161,32 +164,37 @@ function McpPresetCard({ ) : null}
- - {status} - - {preset.connected ? ( - - {busy ? "Disconnecting…" : "Disconnect"} - - ) : tokenFieldOpen ? null : ( - - )} +
+ {status} + {preset.connected ? ( + + Disconnect + {preset.displayName} + + } + disabled={busy} + onConfirm={handleDisconnect} + > + {busy ? "Disconnecting…" : "Manage"} + {preset.displayName} + + ) : tokenFieldOpen ? null : ( + + )} +
{tokenFieldOpen && !preset.connected ? (
@@ -248,22 +256,7 @@ function McpPresetCard({ ); } -export function McpPresetCardsSection({ - tenantId, - query = "", - autoConnectSlug = null, - onAutoConnectHandled, -}: { - readonly tenantId: string; - readonly query?: string; - /** A preset slug named by a `/plugins?connect=mcp:` deep link - * (CL-7141) — once the catalog has loaded, that preset's row gets - * focused so a person lands on the right card without hunting for it. - * Never auto-fires the connect action itself: connecting still takes - * a person's own click, the same as every other card here. */ - readonly autoConnectSlug?: string | null; - readonly onAutoConnectHandled?: () => void; -}) { +export function useMcpPresetCatalog(tenantId: string) { const [presets, setPresets] = useState([]); const [toolCounts, setToolCounts] = useState>( () => { @@ -274,86 +267,43 @@ export function McpPresetCardsSection({ }, ); const [loadError, setLoadError] = useState(null); - // Whether the presets fetch has resolved at least once — distinct from - // "zero presets": this catalog is the same ~10 curated apps for every - // tenant, so an empty `presets` array before this flips true is a - // loading gap, never a real "nothing to connect" state (CL-6472). The - // section used to `return null` whenever presets were empty regardless - // of why, which let a load-in-progress render as if the whole catalog - // had vanished — this component must never go quiet like that again. const [loaded, setLoaded] = useState(false); + const [reloadKey, setReloadKey] = useState(0); - function reload() { + useEffect(() => { + let cancelled = false; + setLoaded(false); listMcpPresets(tenantId) .then((data) => { - setPresets(data); - setLoadError(null); + if (!cancelled) { + setPresets(data); + setLoadError(null); + } }) - .catch((cause: unknown) => setLoadError(messageOf(cause))) - .finally(() => setLoaded(true)); - } - - useEffect(() => { - reload(); - }, [tenantId]); + .catch((cause: unknown) => { + reportError(cause, { + operation: "plugins.catalog.load-presets", + tenantId, + }); + if (!cancelled) { + setPresets([]); + setLoadError(messageOf(cause)); + } + }) + .finally(() => { + if (!cancelled) setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [tenantId, reloadKey]); - useEffect(() => { - if (!loaded || autoConnectSlug === null) return; - if (presets.some((preset) => preset.slug === autoConnectSlug)) { - const row = document.querySelector( - `[data-plugin-slug="${autoConnectSlug}"] button`, - ); - (row as HTMLButtonElement | null)?.focus(); + function handleChanged(slug: string, toolCount: number | undefined) { + if (toolCount !== undefined) { + setToolCounts((current) => new Map(current).set(slug, toolCount)); } - onAutoConnectHandled?.(); - }, [loaded, autoConnectSlug, presets, onAutoConnectHandled]); - - const needle = query.trim().toLowerCase(); - const visiblePresets = presets.filter( - (preset) => - needle === "" || - `${preset.displayName} ${preset.description}` - .toLowerCase() - .includes(needle), - ); + setReloadKey((key) => key + 1); + } - return ( -
-

- Connect apps -

- {loadError !== null ? ( -

- {loadError} -

- ) : !loaded ? ( -

Loading…

- ) : visiblePresets.length === 0 ? ( -

- {presets.length === 0 - ? "No apps to connect right now." - : `No app matches "${query.trim()}".`} -

- ) : ( -
- {visiblePresets.map((preset) => ( - { - if (toolCount !== undefined) { - setToolCounts((prev) => - new Map(prev).set(preset.slug, toolCount), - ); - } - reload(); - }} - /> - ))} -
- )} -
- ); + return { presets, toolCounts, loaded, loadError, handleChanged }; } diff --git a/packages/plugins-ui/src/plugin-card.tsx b/packages/plugins-ui/src/plugin-card.tsx index a3fc0cb99..1227fbd42 100644 --- a/packages/plugins-ui/src/plugin-card.tsx +++ b/packages/plugins-ui/src/plugin-card.tsx @@ -38,7 +38,11 @@ export function PluginCard({ : STATUS_CAPTION[plugin.status]; return ( -
+
- {/* A dense directory row's status column is core information, not - * an overflow item to drop on narrow viewports (CL-6467). */} - {caption} - {plugin.status === "not_connected" ? ( - // The same single Connect verb the MCP preset rows use — one - // idiom for "not connected yet" across the whole gallery, never - // a bare "+" glyph that hides the action. - - ) : ( - - )} +
+ {caption} + {plugin.status === "not_connected" ? ( + + ) : ( + + )} +
); } diff --git a/packages/plugins-ui/src/plugin-meta.ts b/packages/plugins-ui/src/plugin-meta.ts index fd8b93524..1c756991c 100644 --- a/packages/plugins-ui/src/plugin-meta.ts +++ b/packages/plugins-ui/src/plugin-meta.ts @@ -16,42 +16,25 @@ import { Stack, } from "@corbits/icons"; -export type PluginCategory = - | "Communication" - | "Productivity" - | "Sales & customer" - | "Engineering" - | "Research & data"; +export type PluginCatalogCategory = "work" | "developer" | "research"; -export const PLUGIN_CATEGORY_ORDER: readonly PluginCategory[] = [ - "Communication", - "Productivity", - "Sales & customer", - "Engineering", - "Research & data", -]; - -const CATEGORY_BY_ID: Readonly> = { - granola: "Productivity", - manus: "Productivity", - notion: "Productivity", - zoom: "Communication", - slack: "Communication", - google: "Productivity", - attio: "Sales & customer", - hubspot: "Sales & customer", - exa: "Research & data", - scrapecreators: "Research & data", - sumble: "Research & data", - linear: "Engineering", - github: "Engineering", - sentry: "Engineering", - vercel: "Engineering", - render: "Engineering", - railway: "Engineering", - posthog: "Engineering", - browserbase: "Engineering", -}; +const CATEGORY_BY_ID: Readonly>> = + { + granola: "work", + notion: "work", + attio: "work", + canva: "work", + manus: "work", + linear: "developer", + "github-mcp": "developer", + sentry: "developer", + railway: "developer", + posthog: "developer", + github: "developer", + exa: "research", + sumble: "research", + scrapecreators: "research", + }; /** One honest sentence: what connecting this plugin actually lets an agent * do. No metrics, no hype — the same rule `WorkflowCatalogEntry.whatItDoes` @@ -90,19 +73,10 @@ const ICON_BY_ID: Readonly> = { github: GitPullRequest, }; -/** The handful surfaced under "Featured", ahead of the category grid — a - * short, owner-editable literal list, not a derived ranking. */ -export const FEATURED_CONNECTOR_IDS: readonly string[] = [ - "granola", - "github", - "linear", - "openrouter", - "huggingface", - "exa", -]; - -export function pluginCategory(connectorId: string): PluginCategory { - return CATEGORY_BY_ID[connectorId] ?? "Productivity"; +export function pluginCatalogCategory( + catalogId: string, +): PluginCatalogCategory | undefined { + return CATEGORY_BY_ID[catalogId]; } export function pluginOutcome( diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index bbc18d84b..6c03aa437 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -1,31 +1,78 @@ -// The Plugins gallery (CL-6090): Plugins | Skills tabs over compact, -// categorized directory grids. Search is controlled by the composing page so -// it can live in the shared top bar. Presentational -// — every list arrives already loaded; the composing page (`apps/web`'s -// `plugins-page.tsx`) owns fetching `listPluginsForTenant` and the skill -// registry, the same split every other package in this repo draws between -// "owns the domain" and "stays generic." +import { useEffect, useMemo, useState } from "react"; -import { EmptyState, Tabs } from "@corbits/react-ui"; +import { Lightning } from "@corbits/icons"; +import { EmptyState, FilterChip, Tabs } from "@corbits/react-ui"; +import type { ConnectorDescriptor } from "@corbits/connections/registry"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { MCP_PRESETS, MCP_PRESET_CONNECTOR_IDS, } from "@workbench/templates/connectors"; -import { Lightning } from "@corbits/icons"; -import { useMemo } from "react"; import { McpServersSection } from "./mcp-servers-section"; -import { McpPresetCardsSection } from "./mcp-preset-cards"; +import { McpPresetCard, useMcpPresetCatalog } from "./mcp-preset-cards"; +import type { McpPreset } from "./mcp-servers-api"; import { - FEATURED_CONNECTOR_IDS, - PLUGIN_CATEGORY_ORDER, - pluginCategory, + pluginCatalogCategory, + type PluginCatalogCategory, + pluginOutcome, } from "./plugin-meta"; import { PluginCard } from "./plugin-card"; import { SkillCard, type SkillCardData } from "./skill-card"; export type PluginsGalleryTab = "plugins" | "skills"; +type PluginCatalogFilter = "all" | "connected" | PluginCatalogCategory; + +type PluginCatalogEntry = + | { + readonly kind: "preset"; + readonly id: string; + readonly name: string; + readonly outcome: string; + readonly category: PluginCatalogCategory | undefined; + readonly connected: boolean; + readonly preset: McpPreset; + } + | { + readonly kind: "native"; + readonly id: string; + readonly name: string; + readonly outcome: string; + readonly category: PluginCatalogCategory | undefined; + readonly connected: boolean; + readonly plugin: ResolvedPlugin; + }; + +const PLUGIN_FILTERS = [ + { id: "all", label: "All" }, + { id: "connected", label: "Connected" }, + { id: "work", label: "Work" }, + { id: "developer", label: "Developer" }, + { id: "research", label: "Research" }, +] satisfies readonly { + readonly id: PluginCatalogFilter; + readonly label: string; +}[]; + +const CATEGORY_LABELS: Record = { + work: "Work", + developer: "Developer", + research: "Research", +}; + +const MCP_PRESET_CATALOG_IDS = new Set([ + ...MCP_PRESETS.map((preset) => preset.slug), + ...MCP_PRESET_CONNECTOR_IDS, +]); + +export function isNativePluginCatalogDescriptor( + descriptor: ConnectorDescriptor, +): boolean { + return ( + descriptor.feedsTools.length > 0 && + !MCP_PRESET_CATALOG_IDS.has(descriptor.id) + ); +} function matchesQuery(haystacks: readonly string[], query: string): boolean { const needle = query.trim().toLowerCase(); @@ -33,84 +80,123 @@ function matchesQuery(haystacks: readonly string[], query: string): boolean { return haystacks.some((value) => value.toLowerCase().includes(needle)); } -function PluginGrid({ - plugins, - onOpen, +function matchesFilter( + entry: PluginCatalogEntry, + filter: PluginCatalogFilter, +): boolean { + if (filter === "all") return true; + if (filter === "connected") return entry.connected; + return entry.category === filter; +} + +function PluginFilterBar({ + entries, + active, + onChange, }: { - readonly plugins: readonly ResolvedPlugin[]; - readonly onOpen: (plugin: ResolvedPlugin) => void; + readonly entries: readonly PluginCatalogEntry[]; + readonly active: PluginCatalogFilter; + readonly onChange: (filter: PluginCatalogFilter) => void; }) { return ( -
- {plugins.map((plugin) => ( - onOpen(plugin)} - /> +
+ {PLUGIN_FILTERS.map((filter) => ( + matchesFilter(entry, filter.id)).length + } + onClick={() => onChange(filter.id)} + > + {filter.label} + ))}
); } -function PluginsTabPanel({ - plugins, +function PluginCatalogPanel({ + tenantId, + entries, query, - onOpen, + activeFilter, + onFilterChange, + toolCounts, + onPresetChanged, + onOpenPlugin, }: { - readonly plugins: readonly ResolvedPlugin[]; + readonly tenantId: string; + readonly entries: readonly PluginCatalogEntry[]; readonly query: string; - readonly onOpen: (plugin: ResolvedPlugin) => void; + readonly activeFilter: PluginCatalogFilter; + readonly onFilterChange: (filter: PluginCatalogFilter) => void; + readonly toolCounts: ReadonlyMap; + readonly onPresetChanged: ( + slug: string, + toolCount: number | undefined, + ) => void; + readonly onOpenPlugin: (plugin: ResolvedPlugin) => void; }) { - const nonPreset = plugins.filter( - (plugin) => !MCP_PRESET_CONNECTOR_IDS.includes(plugin.descriptor.id), - ); - const filtered = nonPreset.filter((plugin) => + const queryMatches = entries.filter((entry) => matchesQuery( - [plugin.descriptor.displayName, pluginCategory(plugin.descriptor.id)], + [ + entry.name, + entry.outcome, + ...(entry.category === undefined + ? [] + : [CATEGORY_LABELS[entry.category]]), + ], query, ), ); - - if (filtered.length === 0) return null; - - const showFeatured = query.trim() === ""; - const featured = showFeatured - ? filtered.filter((plugin) => - FEATURED_CONNECTOR_IDS.includes(plugin.descriptor.id), - ) - : []; - const rest = showFeatured - ? filtered.filter( - (plugin) => !FEATURED_CONNECTOR_IDS.includes(plugin.descriptor.id), - ) - : filtered; - - const byCategory = PLUGIN_CATEGORY_ORDER.map((category) => ({ - category, - plugins: rest.filter( - (plugin) => pluginCategory(plugin.descriptor.id) === category, - ), - })).filter((group) => group.plugins.length > 0); + const visibleEntries = queryMatches.filter((entry) => + matchesFilter(entry, activeFilter), + ); return ( -
- {featured.length > 0 ? ( -
-

- Featured -

- -
+
+ + {visibleEntries.length === 0 ? ( + } + title="Nothing matches" + description={ + query.trim() === "" + ? "No plugins are available in this filter." + : `No plugin matches "${query.trim()}" in this filter.` + } + /> + ) : null} + {visibleEntries.length > 0 ? ( +
+ {visibleEntries.map((entry) => + entry.kind === "preset" ? ( + onPresetChanged(entry.id, toolCount)} + /> + ) : ( + onOpenPlugin(entry.plugin)} + /> + ), + )} +
) : null} - {byCategory.map((group) => ( -
-

- {group.category} -

- -
- ))}
); } @@ -207,13 +293,56 @@ export function PluginsGallery({ readonly autoConnectPresetSlug?: string | null; readonly onAutoConnectPresetHandled?: () => void; }) { - // An inference-provider connector names no tool package it feeds - // (`feedsTools: []`) — providers live only in Shared Settings' - // Connections section, never in this directory (CL-6272.2). - const installablePlugins = useMemo( - () => plugins.filter((plugin) => plugin.descriptor.feedsTools.length > 0), + const [activeFilter, setActiveFilter] = useState("all"); + const presetCatalog = useMcpPresetCatalog(tenantId); + + const nativeEntries = useMemo( + () => + plugins + .filter((plugin) => isNativePluginCatalogDescriptor(plugin.descriptor)) + .map((plugin) => ({ + kind: "native", + id: plugin.descriptor.id, + name: plugin.descriptor.displayName, + outcome: pluginOutcome( + plugin.descriptor.id, + plugin.descriptor.displayName, + ), + category: pluginCatalogCategory(plugin.descriptor.id), + connected: plugin.status !== "not_connected", + plugin, + })), [plugins], ); + const catalogEntries = useMemo( + () => [ + ...presetCatalog.presets.map((preset) => ({ + kind: "preset" as const, + id: preset.slug, + name: preset.displayName, + outcome: preset.description, + category: pluginCatalogCategory(preset.slug), + connected: preset.connected, + preset, + })), + ...nativeEntries, + ], + [nativeEntries, presetCatalog.presets], + ); + + useEffect(() => { + if (!presetCatalog.loaded || autoConnectPresetSlug === null) return; + const row = Array.from( + document.querySelectorAll("[data-plugin-slug]"), + ).find((element) => element.dataset.pluginSlug === autoConnectPresetSlug); + row?.querySelector("button")?.focus(); + onAutoConnectPresetHandled?.(); + }, [ + autoConnectPresetSlug, + onAutoConnectPresetHandled, + presetCatalog.loaded, + presetCatalog.presets, + ]); const tabs = useMemo( () => [ @@ -221,14 +350,19 @@ export function PluginsGallery({ id: "plugins" as const, label: "Plugins", count: - installablePlugins.filter( - (plugin) => - !MCP_PRESET_CONNECTOR_IDS.includes(plugin.descriptor.id), - ).length + MCP_PRESETS.length, + nativeEntries.length + + (presetCatalog.loaded + ? presetCatalog.presets.length + : MCP_PRESETS.length), }, { id: "skills" as const, label: "Skills", count: skills.length }, ], - [installablePlugins.length, skills.length], + [ + nativeEntries.length, + presetCatalog.loaded, + presetCatalog.presets.length, + skills.length, + ], ); return ( @@ -243,21 +377,28 @@ export function PluginsGallery({
{active === "plugins" ? ( <> -

Plugins

- + {presetCatalog.loadError !== null ? ( +

+ {presetCatalog.loadError} +

+ ) : null} + {!presetCatalog.loaded ? ( +

+ Loading plugins… +

+ ) : ( + + )} - ) : ( .plugins-catalog-card:nth-last-child(2):nth-child(odd) { + border-bottom: 0; + } +} diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 1437c44c7..2f60d5004 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -10,7 +10,7 @@ import type { Root } from "react-dom/client"; import { MCP_PRESETS } from "@workbench/templates/connectors"; -import { McpPresetCardsSection } from "../src/mcp-preset-cards"; +import { McpPresetCard, useMcpPresetCatalog } from "../src/mcp-preset-cards"; const realFetch = globalThis.fetch; let mountedRoots: Root[] = []; @@ -30,11 +30,34 @@ function mountSection() { const root: Root = createRoot(container); mountedRoots.push(root); act(() => { - root.render(); + root.render(); }); return container; } +function PresetCatalogHarness() { + const catalog = useMcpPresetCatalog("tenant_test"); + if (!catalog.loaded) return null; + if (catalog.loadError !== null) { + return

{catalog.loadError}

; + } + return ( +
+ {catalog.presets.map((preset) => ( + + catalog.handleChanged(preset.slug, toolCount) + } + /> + ))} +
+ ); +} + const PRESETS = [ { slug: "granola", @@ -70,7 +93,7 @@ const PRESETS = [ }, ]; -describe("McpPresetCardsSection", () => { +describe("MCP preset catalog", () => { test("renders one compact row per preset with its outcome sentence", async () => { globalThis.fetch = (async () => new Response( @@ -283,9 +306,7 @@ describe("McpPresetCardsSection", () => { ) as HTMLElement; expect(canvaCard.textContent).toContain("40 tools"); expect(canvaCard.textContent).not.toContain("Not connected"); - expect( - canvaCard.querySelector('[aria-label="Disconnect Canva"]'), - ).not.toBeNull(); + expect(canvaCard.textContent).toContain("Manage"); const granolaCard = container.querySelector( '[data-plugin-slug="granola"]', @@ -329,7 +350,7 @@ describe("McpPresetCardsSection", () => { expect(canvaCard.textContent).not.toContain("tools"); }); - test("Disconnect's accessible name includes the preset display name (CL-6794)", async () => { + test("Manage reveals a named Disconnect confirmation (CL-6794)", async () => { globalThis.fetch = (async () => new Response( JSON.stringify({ @@ -342,11 +363,22 @@ describe("McpPresetCardsSection", () => { const container = mountSection(); await settle(); - const disconnectExa = container.querySelector( - '[aria-label="Disconnect Exa"]', + const exaCard = container.querySelector( + '[data-plugin-slug="exa"]', + ) as HTMLElement; + const manageExa = [...exaCard.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Manage") === true, + ); + expect(manageExa?.textContent).toContain("Exa"); + + act(() => { + manageExa?.click(); + }); + + const disconnectExa = [...exaCard.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Disconnect") === true, ); - expect(disconnectExa).not.toBeNull(); - expect(disconnectExa?.textContent?.trim()).toBe("Disconnect"); + expect(disconnectExa?.textContent).toContain("Exa"); }); test("connect calls the preset connect route with the preset's slug", async () => { @@ -503,18 +535,16 @@ describe("McpPresetCardsSection", () => { const exaCard = container.querySelector( '[data-plugin-slug="exa"]', ) as HTMLElement; - const disconnectButton = [...exaCard.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Disconnect"), + const manageButton = [...exaCard.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Manage"), ) as HTMLButtonElement; await act(async () => { - disconnectButton.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + manageButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 10)); }); const confirmButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Disconnect", + (button) => button.textContent?.includes("Disconnect") === true, ) as HTMLButtonElement; await act(async () => { confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -560,11 +590,7 @@ describe("McpPresetCardsSection", () => { } }); - // CL-6472: this section must never silently disappear. Before this - // fix it `return null`ed for any load that resolved with zero presets - // (including one still in flight), which is indistinguishable from the - // whole "Connect apps" catalog vanishing. - test("never renders nothing — a failed load surfaces an error, not silence", async () => { + test("a failed catalog load surfaces an error, not silence", async () => { globalThis.fetch = (async () => new Response("Internal Server Error", { status: 500, @@ -573,7 +599,6 @@ describe("McpPresetCardsSection", () => { const container = mountSection(); await settle(); - expect(container.textContent).toContain("Connect apps"); expect(container.querySelector('[role="alert"]')).not.toBeNull(); }); }); diff --git a/packages/plugins-ui/test/plugin-meta.test.ts b/packages/plugins-ui/test/plugin-meta.test.ts index 9b64056c9..0900abd01 100644 --- a/packages/plugins-ui/test/plugin-meta.test.ts +++ b/packages/plugins-ui/test/plugin-meta.test.ts @@ -1,12 +1,33 @@ import { expect, test } from "bun:test"; import { Stack } from "@corbits/icons"; +import { connectorDescriptors } from "@corbits/connections/registry"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; -import { pluginCategory, pluginIcon, pluginOutcome } from "../src/plugin-meta"; +import { + pluginCatalogCategory, + pluginIcon, + pluginOutcome, +} from "../src/plugin-meta"; +import { isNativePluginCatalogDescriptor } from "../src/plugins-gallery"; -test("manus is a Productivity plugin whose outcome names tasks and slide-deck files", () => { - expect(pluginCategory("manus")).toBe("Productivity"); +test("manus is a work plugin whose outcome names tasks and slide-deck files", () => { + expect(pluginCatalogCategory("manus")).toBe("work"); expect(pluginOutcome("manus", "Manus")).toBe( "Lets agents run Manus tasks and retrieve files — including slide decks.", ); expect(pluginIcon("manus")).toBe(Stack); }); + +test("every catalog entry used by the gallery has an explicit category", () => { + for (const preset of MCP_PRESETS) { + expect(pluginCatalogCategory(preset.slug)).toBeDefined(); + } + for (const descriptor of connectorDescriptors(CONNECTOR_REGISTRY).filter( + isNativePluginCatalogDescriptor, + )) { + expect(pluginCatalogCategory(descriptor.id)).toBeDefined(); + } +}); diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index 3aa093a5e..6880a63f5 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -1,11 +1,5 @@ -// The gallery renders from a fixture registry: cards for every resolved -// plugin, the installed strip only for connected/needs-attention ones, -// search filtering both tabs, outcome copy on every card, and the Skills -// tab mounting its own card grid rather than the Settings list rows. - -import { describe, expect, test } from "bun:test"; -import { act } from "react"; -import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, useState } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; @@ -27,45 +21,40 @@ function descriptor( authKind, docsUrl: `https://example.test/${id}`, credentialPlugin: "http", - // A non-empty `feedsTools` marks a real tool connector rather than an - // inference provider (CL-6272.2) — every fixture here is a tool - // connector, never a provider, so this suite exercises the plugin - // grid rather than the provider-exclusion rule. feedsTools: [`@corbits/${id}-tools`], }; } -const CONNECTED: ResolvedPlugin = { - descriptor: descriptor("github", "GitHub"), - status: "connected", - provenance: "this-workbench", - credentialId: "cred_github", - credentialName: "GitHub", -}; - -// Not "scrapecreators" — that id is now an MCP-preset connector -// (CL-6256) filtered out of this static grid, which would make this -// fixture's row vanish for a reason unrelated to what this suite tests. -const INHERITED: ResolvedPlugin = { - descriptor: descriptor("notion", "Notion"), - status: "connected", - provenance: "inherited", - credentialId: "cred_notion", - credentialName: "Notion", -}; - -const NOT_CONNECTED: ResolvedPlugin = { - descriptor: descriptor("huggingface", "Hugging Face"), - status: "not_connected", - provenance: null, - credentialId: null, - credentialName: null, -}; +function plugin( + id: string, + displayName: string, + status: ResolvedPlugin["status"], +): ResolvedPlugin { + const pluginDescriptor = descriptor(id, displayName); + if (status === "not_connected") { + return { + descriptor: pluginDescriptor, + status, + provenance: null, + credentialId: null, + credentialName: null, + }; + } + return { + descriptor: pluginDescriptor, + status, + provenance: "this-workbench", + credentialId: `cred_${id}`, + credentialName: displayName, + }; +} const PLUGINS: readonly ResolvedPlugin[] = [ - CONNECTED, - INHERITED, - NOT_CONNECTED, + plugin("github", "GitHub", "connected"), + plugin("notion", "Notion registry duplicate", "connected"), + plugin("huggingface", "Hugging Face", "not_connected"), + plugin("manus", "Manus", "not_connected"), + plugin("scrapecreators", "ScrapeCreators", "connected"), ]; const PROVIDER: ResolvedPlugin = { @@ -75,7 +64,6 @@ const PROVIDER: ResolvedPlugin = { authKind: "api-key", docsUrl: "https://example.test/anthropic", credentialPlugin: "http", - // Real inference-provider descriptors feed no tool package. feedsTools: [], }, status: "connected", @@ -99,17 +87,44 @@ const SKILLS: readonly SkillCardData[] = [ }, ]; -// `McpServersSection` fetches its server list on mount — stub `fetch` so -// this suite (which only exercises the plugin/skill grid) never issues a -// real request, and resolve within an `act()` flush so the state update -// isn't reported unwrapped. -globalThis.fetch = (async (input: string | URL | Request) => { - const url = String(input); - if (url.includes("/webhook-triggers") || url.includes("/routines")) { - return new Response(JSON.stringify({ items: [] })); - } - return new Response(JSON.stringify({ data: [], nextCursor: null })); -}) as unknown as typeof fetch; +const PRESETS = MCP_PRESETS.map((preset) => ({ + slug: preset.slug, + displayName: preset.displayName, + description: preset.description, + url: preset.url, + connectionMode: preset.connectionMode, + docsUrl: preset.docsUrl, + ...(preset.icon === undefined ? {} : { icon: preset.icon }), + ...(preset.tokenSteps === undefined ? {} : { tokenSteps: preset.tokenSteps }), + connected: preset.slug === "exa", +})); + +const realFetch = globalThis.fetch; +let mountedRoots: Root[] = []; + +function installFetch(presets: readonly (typeof PRESETS)[number][] = PRESETS) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/mcp-servers/presets")) { + return new Response(JSON.stringify({ data: presets })); + } + if (url.includes("/webhook-triggers") || url.includes("/routines")) { + return new Response(JSON.stringify({ items: [] })); + } + return new Response(JSON.stringify({ data: [], nextCursor: null })); + }) as unknown as typeof fetch; +} + +beforeEach(() => { + installFetch(); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + for (const root of mountedRoots) act(() => root.unmount()); + mountedRoots = []; + document.body.replaceChildren(); +}); function GalleryHarness({ plugins, @@ -133,195 +148,162 @@ function GalleryHarness({ ); } -function renderGallery( +async function renderGallery( plugins: readonly ResolvedPlugin[] = PLUGINS, initialQuery = "", ) { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); + mountedRoots.push(root); act(() => { root.render( , ); }); + await act(() => new Promise((resolve) => setTimeout(resolve, 20))); return { container, root }; } -describe("PluginsGallery", () => { - test("renders every tool connector regardless of connection state (CL-6386)", () => { - const { container } = renderGallery(); - - expect(container.textContent).toContain("GitHub"); - expect(container.textContent).toContain("Notion"); - expect(container.textContent).toContain("Hugging Face"); - expect(container.textContent).toContain( - "Lets agents read and open pull requests in your GitHub repos.", - ); - }); - - test("a not-connected tool connector renders a Connect affordance", () => { - const { container } = renderGallery(); - - const connectButton = container.querySelector( - '[aria-label="Connect Hugging Face"]', - ); - expect(connectButton).not.toBeNull(); - // Visible label stays the single verb (CL-6794). - expect(connectButton?.textContent?.trim()).toBe("Connect"); - }); +function chip(container: HTMLElement, label: string): HTMLButtonElement { + const match = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.startsWith(label) === true, + ); + if (match === undefined) throw new Error(`Missing ${label} filter chip`); + return match; +} - test("a connected tool connector's Manage button includes the plugin name in its accessible name (CL-6794)", () => { - const { container } = renderGallery(); +function catalogNames(container: HTMLElement): string[] { + return [...container.querySelectorAll("[data-plugin-name]")].map( + (element) => element.dataset.pluginName ?? "", + ); +} - const manageButton = container.querySelector( - '[aria-label="Manage GitHub"]', +describe("PluginsGallery", () => { + test("Plugins and Skills are the only tabs; catalog filters are pressed buttons", async () => { + const { container } = await renderGallery(); + + expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(2); + expect( + container.querySelectorAll( + '[aria-label="Plugin catalog filters"] [role="tab"]', + ), + ).toHaveLength(0); + expect(container.querySelector('[aria-label="Filter plugins"]')).toBeNull(); + expect(chip(container, "All").getAttribute("aria-pressed")).toBe("true"); + expect(chip(container, "Research").getAttribute("aria-pressed")).toBe( + "false", ); - expect(manageButton).not.toBeNull(); - expect(manageButton?.textContent?.trim()).toBe("Manage"); }); - test("filters out the old registry card for a connector an MCP preset now fronts", () => { - const { container } = renderGallery(); + test("chip counts describe the query-matched catalog", async () => { + const { container } = await renderGallery(); - expect(container.textContent).not.toContain( - "Lets agents run live web search and research lookups.", - ); + expect(chip(container, "All").textContent).toContain("15"); + expect(chip(container, "Connected").textContent).toContain("3"); + expect(chip(container, "Work").textContent).toContain("5"); + expect(chip(container, "Developer").textContent).toContain("6"); + expect(chip(container, "Research").textContent).toContain("3"); }); - test("does not duplicate connected plugins in an icon-only strip", () => { - const { container } = renderGallery(); - - const strip = container.querySelector('[aria-label="Installed plugins"]'); - expect(strip).toBeNull(); - }); + test("category filtering keeps one unified catalog", async () => { + const { container } = await renderGallery(); - test("provenance reads as plain words on a plugin card", () => { - const { container } = renderGallery(); + act(() => { + chip(container, "Research").click(); + }); - expect(container.textContent).toContain("Connected here"); - expect(container.textContent).toContain("Inherited"); + expect(catalogNames(container)).toEqual([ + "Exa", + "Sumble", + "ScrapeCreators", + ]); + expect( + container.querySelectorAll('[aria-label="Plugin catalog"]'), + ).toHaveLength(1); }); - test("the plugins tab renders the list heading", () => { - const { container } = renderGallery(); + test("search and a chip filter intersect instead of replacing each other", async () => { + const { container } = await renderGallery(PLUGINS, "live web"); - const heading = container.querySelector("h1"); - expect(heading?.textContent).toBe("Plugins"); + expect(chip(container, "All").textContent).toContain("1"); + expect(chip(container, "Research").textContent).toContain("1"); + act(() => { + chip(container, "Research").click(); + }); + expect(catalogNames(container)).toEqual(["Exa"]); }); - test("a plugin row's status caption is never hidden — it is a core column, not overflow (CL-6467)", () => { - const { container } = renderGallery(); + test("preset-backed registry entries and inference providers stay out of the catalog", async () => { + const { container } = await renderGallery([...PLUGINS, PROVIDER]); - expect(container.textContent).toContain("Connected here"); - const captions = [...container.querySelectorAll("span")].filter( - (span) => span.textContent === "Connected · Connected here", - ); - expect(captions.length).toBeGreaterThan(0); - for (const caption of captions) { - expect(caption.className).not.toContain("hidden"); - } + expect(container.textContent).toContain("Notion"); + expect(container.textContent).not.toContain("Notion registry duplicate"); + expect(container.textContent).not.toContain("Anthropic"); }); - test("an empty plugin directory renders no group rows", () => { - const { container } = renderGallery([]); - - expect(container.querySelector(".border.border-border")).toBeNull(); + test("connected and disconnected entries preserve Manage and Connect flows", async () => { + const { container } = await renderGallery(); + + expect( + container.querySelector('[aria-label="Manage GitHub"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Connect Manus"]'), + ).not.toBeNull(); + const exa = container.querySelector('[data-plugin-slug="exa"]'); + expect(exa?.textContent).toContain("Connected"); + expect(exa?.textContent).toContain("Manage"); }); - test("search narrows the plugin grid to matches only", () => { - const { container } = renderGallery(PLUGINS, "git"); + test("status remains visible as a core field", async () => { + const { container } = await renderGallery(); + const caption = [...container.querySelectorAll("span")].find( + (element) => element.textContent === "Connected · Connected here", + ); - expect(container.textContent).toContain("GitHub"); - expect(container.textContent).not.toContain("Hugging Face"); + expect(caption).not.toBeUndefined(); + expect(caption?.className).not.toContain("hidden"); }); - test("switching to the Skills tab renders skill cards with outcome copy and scope badges", () => { - const { container } = renderGallery(); + test("switching to Skills preserves the existing skill gallery", async () => { + const { container } = await renderGallery(); const skillsTab = [...container.querySelectorAll("button")].find( (button) => button.textContent?.includes("Skills") === true, ); - expect(skillsTab).not.toBeUndefined(); act(() => { - skillsTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + skillsTab?.click(); }); expect(container.textContent).toContain("Weekly digest"); - expect(container.textContent).toContain( - "Summarizes the week's channel activity.", - ); expect(container.textContent).toContain("Shared with everyone"); expect(container.textContent).toContain("Just you"); }); - test("an inference provider never appears in the plugin directory (CL-6272.2)", () => { - const { container } = renderGallery([...PLUGINS, PROVIDER]); + test("every preset returned by the route reaches a fresh catalog", async () => { + const { container } = await renderGallery([]); - expect(container.textContent).toContain("GitHub"); - expect(container.textContent).not.toContain("Anthropic"); - }); - - test("omits unverified MCP suggestions and API-key-only registry entries", () => { - const { container } = renderGallery(); - - for (const excluded of [ - "Slack", - "Vercel", - "Render", - "HubSpot", - "Zoom", - "Google Workspace", - "Browserbase", - "ScrapeCreators", - ]) { - expect(container.textContent).not.toContain(excluded); + expect(container.querySelectorAll("[data-plugin-slug]")).toHaveLength( + MCP_PRESETS.length, + ); + for (const preset of MCP_PRESETS) { + expect(container.textContent).toContain(preset.displayName); } - expect(container.textContent).not.toContain("Add MCP server"); }); - // CL-6472: the owner's live repro found the presets route returns all 10 - // curated MCP presets on a fresh bench with zero connections, but the - // gallery showed none of them. Reproduce that exact fetch shape here so - // a regression in how the gallery composes `McpPresetCardsSection` - // (as opposed to a bug in that component alone) gets caught too. - test("every MCP preset the route returns reaches the gallery on a fresh bench (CL-6472)", async () => { - const originalFetch = globalThis.fetch; + test("a failed preset load reports the error and still shows native plugins", async () => { globalThis.fetch = (async (input: string | URL | Request) => { - const url = String(input); - if (url.includes("/mcp-servers/presets")) { - return new Response( - JSON.stringify({ - data: MCP_PRESETS.map((preset) => ({ - slug: preset.slug, - displayName: preset.displayName, - description: preset.description, - url: preset.url, - connectionMode: preset.connectionMode, - docsUrl: preset.docsUrl, - ...(preset.icon === undefined ? {} : { icon: preset.icon }), - ...(preset.tokenSteps === undefined - ? {} - : { tokenSteps: preset.tokenSteps }), - connected: false, - })), - }), - ); + if (String(input).includes("/mcp-servers/presets")) { + return new Response("Internal Server Error", { status: 500 }); } - return new Response(JSON.stringify({ data: [] })); + return new Response(JSON.stringify({ data: [], nextCursor: null })); }) as unknown as typeof fetch; - try { - const { container } = renderGallery([]); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); + const { container } = await renderGallery(); - for (const preset of MCP_PRESETS) { - expect(container.textContent).toContain(preset.displayName); - } - } finally { - globalThis.fetch = originalFetch; - } + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + expect(container.textContent).toContain("GitHub"); }); }); diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index ea16d8bc9..c9d88dd1e 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -1138,6 +1138,161 @@ async function run(): Promise { }; }, ); + + await step( + () => page, + "12-plugins-catalog-filters-and-responsive-grid", + async () => { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${webBaseUrl}/plugins`, { + waitUntil: "domcontentloaded", + timeout: 20_000, + }); + await page.waitForSelector('[aria-label="Plugin catalog"]', { + timeout: 20_000, + }); + + const desktop = await page.evaluate(() => { + const grid = document.querySelector( + '[aria-label="Plugin catalog"]', + ); + const filterGroup = document.querySelector( + '[aria-label="Plugin catalog filters"]', + ); + if (grid === null || filterGroup === null) return null; + const cards = Array.from( + grid.querySelectorAll(".plugins-catalog-card"), + ); + const lastCard = cards.at(-1); + return { + tablists: document.querySelectorAll('[role="tablist"]').length, + filterTabs: filterGroup.querySelectorAll('[role="tab"]').length, + searchControls: document.querySelectorAll( + '[aria-label="Filter plugins"]', + ).length, + columns: getComputedStyle(grid).gridTemplateColumns, + lastBorder: + lastCard === undefined + ? null + : getComputedStyle(lastCard).borderBottomWidth, + }; + }); + if ( + desktop === null || + desktop.tablists !== 1 || + desktop.filterTabs !== 0 || + desktop.searchControls !== 1 || + desktop.columns.split(" ").length !== 2 || + desktop.lastBorder !== "0px" + ) { + throw new Error( + `desktop catalog contract failed: ${JSON.stringify(desktop)}`, + ); + } + + const developerClicked = await page.evaluate(() => { + const buttons = Array.from( + document.querySelectorAll( + '[aria-label="Plugin catalog filters"] button', + ), + ); + const developer = buttons.find((button) => + (button.textContent ?? "").startsWith("Developer"), + ); + developer?.click(); + return developer !== undefined; + }); + if (!developerClicked) throw new Error("Developer chip was not found"); + await page.waitForFunction( + () => + document.querySelectorAll( + '[aria-label="Plugin catalog"] .plugins-catalog-card', + ).length === 6, + ); + const evenFinalRowBorders = await page.$$eval( + '[aria-label="Plugin catalog"] .plugins-catalog-card', + (cards) => + cards + .slice(-2) + .map((card) => getComputedStyle(card).borderBottomWidth), + ); + if (evenFinalRowBorders.some((border) => border !== "0px")) { + throw new Error( + `even final row borders failed: ${JSON.stringify(evenFinalRowBorders)}`, + ); + } + + const researchClicked = await page.evaluate(() => { + const buttons = Array.from( + document.querySelectorAll( + '[aria-label="Plugin catalog filters"] button', + ), + ); + const research = buttons.find((button) => + (button.textContent ?? "").startsWith("Research"), + ); + research?.click(); + return research !== undefined; + }); + if (!researchClicked) throw new Error("Research chip was not found"); + await page.waitForFunction(() => { + const cards = Array.from( + document.querySelectorAll("[data-plugin-name]"), + ); + return ( + cards.length === 3 && + cards.every((card) => + ["Exa", "Sumble", "ScrapeCreators"].includes( + card.dataset.pluginName ?? "", + ), + ) + ); + }); + const oddFinalRowBorders = await page.$$eval( + '[aria-label="Plugin catalog"] .plugins-catalog-card', + (cards) => + cards + .slice(-2) + .map((card) => getComputedStyle(card).borderBottomWidth), + ); + if ( + oddFinalRowBorders.length !== 2 || + oddFinalRowBorders[0] === "0px" || + oddFinalRowBorders[1] !== "0px" + ) { + throw new Error( + `odd final row borders failed: ${JSON.stringify(oddFinalRowBorders)}`, + ); + } + + await page.click('button[aria-label="Filter plugins"]'); + await page.waitForSelector('input[aria-label="Filter plugins"]'); + await page.type('input[aria-label="Filter plugins"]', "live web"); + await page.waitForFunction(() => { + const cards = Array.from( + document.querySelectorAll("[data-plugin-name]"), + ); + return cards.length === 1 && cards[0]?.dataset.pluginName === "Exa"; + }); + + await page.setViewport({ width: 800, height: 900 }); + const narrowColumns = await page.$eval( + '[aria-label="Plugin catalog"]', + (grid) => getComputedStyle(grid).gridTemplateColumns, + ); + if (narrowColumns.split(" ").length !== 1) { + throw new Error( + `narrow catalog rendered ${narrowColumns} instead of one column`, + ); + } + + return { + status: "pass", + detail: + "one tablist, chip filtering, unique search label, odd/even final-row borders, two desktop columns, and one narrow column", + }; + }, + ); } finally { for (const cleanup of cleanups.splice(0).reverse()) { await cleanup().catch((error) => { From 40ad91210b620f7f38637606503de0da02b13b02 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Fri, 4 Sep 2026 19:44:17 +0545 Subject: [PATCH 2/5] Use Tailwind for the Plugins gallery layout --- apps/web/src/app.css | 1 - packages/plugins-ui/package.json | 3 +- packages/plugins-ui/src/mcp-preset-cards.tsx | 4 +-- packages/plugins-ui/src/plugin-card.tsx | 4 +-- packages/plugins-ui/src/plugins-gallery.tsx | 5 ++- packages/plugins-ui/src/styles.css | 36 -------------------- scripts/e2e/browser/walkthrough.ts | 8 ++--- 7 files changed, 13 insertions(+), 48 deletions(-) delete mode 100644 packages/plugins-ui/src/styles.css diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 83428f4f3..dcc8a3925 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -9,7 +9,6 @@ @import "@corbits/chat-ui/styles.css"; @import "@corbits/bench-ui/styles.css"; @import "@corbits/settings-ui/styles.css"; -@import "@corbits/plugins-ui/styles.css"; /* react-ui's `.corbits-toast` ships a hardcoded horizontal-center transform (`left: 50%; transform: translateX(-50%) !important`) regardless of the diff --git a/packages/plugins-ui/package.json b/packages/plugins-ui/package.json index 209be9d27..60d81b003 100644 --- a/packages/plugins-ui/package.json +++ b/packages/plugins-ui/package.json @@ -6,8 +6,7 @@ "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts", - "./styles.css": "./src/styles.css" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 80b052f3b..0c04790b3 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -141,7 +141,7 @@ export function McpPresetCard({ return (
) : null}
-
+
{status} {preset.connected ? ( @@ -56,7 +56,7 @@ export function PluginCard({ {pluginOutcome(plugin.descriptor.id, plugin.descriptor.displayName)}
-
+
{caption} {plugin.status === "not_connected" ? (