diff --git a/DESIGN.md b/DESIGN.md
index 57b0aa16d..5dc60b370 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -91,12 +91,17 @@ grid:
identifying column never drops.
This is a default, not a mandate. A directory that scans better as dense
-grouped rows than as a table — the plugins gallery is the standing
-example — keeps that idiom. Density over cards: one row per item, a small
-logo tile, name, a single-line outcome sentence, a status/provenance
-caption, and one honest action button that reflects the item's actual
-state. Extend an existing idiom to a new directory before inventing a
-third pattern; three ways to list things is a defect, not a design system.
+grouped rows than as a table — Settings > Connections is the standing
+example (`ConnectorRow` / `OAuthConnectorRow`,
+`packages/settings-ui/src/connections-section.tsx`) — keeps that idiom.
+Density over cards: one row per item, a small logo tile, name, a
+single-line outcome sentence, a status/provenance caption, and one honest
+action button that reflects the item's actual state. A browseable catalog
+of installable things, each with its own status and actions — the plugins
+gallery is the standing example — is the one case where a two-column card
+catalog with status/category chip filters is the right idiom instead.
+Extend an existing idiom to a new directory before inventing a fourth
+pattern; three ways to list things is a defect, not a design system.
## Detail Pages
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 5721abcb9..12a9fea0b 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/bun.lock b/bun.lock
index 16a9ce7b0..356dd48cd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1108,6 +1108,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:*",
@@ -3609,17 +3610,13 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="],
-
- "@corbits/chat-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/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/context-menu/@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/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="],
"@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="],
- "@corbits/plugins-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/settings-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=="],
@@ -3643,7 +3640,9 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="],
- "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="],
+ "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="],
+
+ "@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=="],
@@ -3701,6 +3700,8 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="],
+
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -3747,6 +3748,8 @@
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+ "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="],
+
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
diff --git a/packages/plugins-ui/package.json b/packages/plugins-ui/package.json
index 4dc1e4c19..335e67203 100644
--- a/packages/plugins-ui/package.json
+++ b/packages/plugins-ui/package.json
@@ -6,14 +6,14 @@
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
- ".": "./src/index.ts",
- "./styles.css": "./src/styles.css"
+ ".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"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..d4b5b6f25 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,7 +26,6 @@ export type {
} from "./mcp-servers-api";
export {
- FEATURED_CONNECTOR_IDS,
PLUGIN_CATEGORY_ORDER,
pluginCategory,
pluginIcon,
diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx
index 27e9f0849..0c04790b3 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 : (
-
- {busy ? "Connecting…" : "Connect"}
-
- )}
+
+ {status}
+ {preset.connected ? (
+
+ Disconnect
+ {preset.displayName}
+ >
+ }
+ disabled={busy}
+ onConfirm={handleDisconnect}
+ >
+ {busy ? "Disconnecting…" : "Manage"}
+ {preset.displayName}
+
+ ) : tokenFieldOpen ? null : (
+
+ {busy ? "Connecting…" : "Connect"}
+
+ )}
+
{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..8a239af6f 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.
-
- Connect
-
- ) : (
-
- Manage
-
- )}
+
+ {caption}
+ {plugin.status === "not_connected" ? (
+
+ Connect
+
+ ) : (
+
+ Manage
+
+ )}
+
);
}
diff --git a/packages/plugins-ui/src/plugin-meta.ts b/packages/plugins-ui/src/plugin-meta.ts
index fd8b93524..1d234615a 100644
--- a/packages/plugins-ui/src/plugin-meta.ts
+++ b/packages/plugins-ui/src/plugin-meta.ts
@@ -31,13 +31,14 @@ export const PLUGIN_CATEGORY_ORDER: readonly PluginCategory[] = [
"Research & data",
];
-const CATEGORY_BY_ID: Readonly> = {
+const CATEGORY_BY_ID: Readonly>> = {
granola: "Productivity",
manus: "Productivity",
notion: "Productivity",
zoom: "Communication",
slack: "Communication",
google: "Productivity",
+ canva: "Productivity",
attio: "Sales & customer",
hubspot: "Sales & customer",
exa: "Research & data",
@@ -45,6 +46,7 @@ const CATEGORY_BY_ID: Readonly> = {
sumble: "Research & data",
linear: "Engineering",
github: "Engineering",
+ "github-mcp": "Engineering",
sentry: "Engineering",
vercel: "Engineering",
render: "Engineering",
@@ -90,19 +92,8 @@ 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 pluginCategory(catalogId: string): PluginCategory | 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..da84ab2e6 100644
--- a/packages/plugins-ui/src/plugins-gallery.tsx
+++ b/packages/plugins-ui/src/plugins-gallery.tsx
@@ -1,31 +1,68 @@
-// 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,
+ type PluginCategory,
+ 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" | PluginCategory;
+
+type PluginCatalogEntry =
+ | {
+ readonly kind: "preset";
+ readonly id: string;
+ readonly name: string;
+ readonly outcome: string;
+ readonly category: PluginCategory | undefined;
+ readonly connected: boolean;
+ readonly preset: McpPreset;
+ }
+ | {
+ readonly kind: "native";
+ readonly id: string;
+ readonly name: string;
+ readonly outcome: string;
+ readonly category: PluginCategory | undefined;
+ readonly connected: boolean;
+ readonly plugin: ResolvedPlugin;
+ };
+
+const PLUGIN_FILTERS: readonly PluginCatalogFilter[] = [
+ "All",
+ "Connected",
+ ...PLUGIN_CATEGORY_ORDER,
+];
+
+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 +70,122 @@ 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)).length}
+ onClick={() => onChange(filter)}
+ >
+ {filter}
+
))}
);
}
-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 ? [] : [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 ? (
-
+
+
+ {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 +282,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: pluginCategory(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: pluginCategory(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 +339,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 +366,28 @@ export function PluginsGallery({
{active === "plugins" ? (
<>
-
Plugins
-
+ {presetCatalog.loadError !== null ? (
+
+ {presetCatalog.loadError}
+
+ ) : null}
+ {!presetCatalog.loaded ? (
+
+ Loading plugins…
+
+ ) : (
+
+ )}
-
>
) : (
{
- 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..c77ce3e20 100644
--- a/packages/plugins-ui/test/plugin-meta.test.ts
+++ b/packages/plugins-ui/test/plugin-meta.test.ts
@@ -1,12 +1,42 @@
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 { isNativePluginCatalogDescriptor } from "../src/plugins-gallery";
-test("manus is a Productivity plugin whose outcome names tasks and slide-deck files", () => {
+test("manus is a productivity plugin whose outcome names tasks and slide-deck files", () => {
expect(pluginCategory("manus")).toBe("Productivity");
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(pluginCategory(preset.slug)).toBeDefined();
+ }
+ for (const descriptor of connectorDescriptors(CONNECTOR_REGISTRY).filter(
+ isNativePluginCatalogDescriptor,
+ )) {
+ expect(pluginCategory(descriptor.id)).toBeDefined();
+ }
+});
+
+test("categories preserve connector ID assignments and cover added presets", () => {
+ expect(pluginCategory("slack")).toBe("Communication");
+ expect(pluginCategory("zoom")).toBe("Communication");
+ expect(pluginCategory("google")).toBe("Productivity");
+ expect(pluginCategory("canva")).toBe("Productivity");
+ expect(pluginCategory("attio")).toBe("Sales & customer");
+ expect(pluginCategory("hubspot")).toBe("Sales & customer");
+ expect(pluginCategory("github-mcp")).toBe("Engineering");
+ expect(pluginCategory("github")).toBe("Engineering");
+ expect(pluginCategory("exa")).toBe("Research & data");
+ expect(pluginCategory("unknown-plugin")).toBeUndefined();
+});
diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx
index 3aa093a5e..dfccde63a 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,199 @@ 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();
+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;
+}
- 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.",
- );
+function catalogNames(container: HTMLElement): string[] {
+ return [...container.querySelectorAll("[data-plugin-name]")].map(
+ (element) => element.dataset.pluginName ?? "",
+ );
+}
+
+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 & data").getAttribute("aria-pressed"),
+ ).toBe("false");
});
- test("a not-connected tool connector renders a Connect affordance", () => {
- const { container } = renderGallery();
+ test("chip counts describe the query-matched catalog", async () => {
+ const { container } = await 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");
+ expect(chip(container, "All").textContent).toContain("15");
+ expect(chip(container, "Connected").textContent).toContain("3");
+ expect(chip(container, "Communication").textContent).toContain("0");
+ expect(chip(container, "Productivity").textContent).toContain("4");
+ expect(chip(container, "Sales & customer").textContent).toContain("1");
+ expect(chip(container, "Engineering").textContent).toContain("6");
+ expect(chip(container, "Research & data").textContent).toContain("3");
});
- test("a connected tool connector's Manage button includes the plugin name in its accessible name (CL-6794)", () => {
- const { container } = renderGallery();
+ test("category filtering keeps one unified catalog", async () => {
+ const { container } = await renderGallery();
- const manageButton = container.querySelector(
- '[aria-label="Manage GitHub"]',
- );
- expect(manageButton).not.toBeNull();
- expect(manageButton?.textContent?.trim()).toBe("Manage");
+ act(() => {
+ chip(container, "Research & data").click();
+ });
+
+ expect(catalogNames(container)).toEqual([
+ "Exa",
+ "Sumble",
+ "ScrapeCreators",
+ ]);
+ expect(
+ container.querySelectorAll('[aria-label="Plugin catalog"]'),
+ ).toHaveLength(1);
});
- test("filters out the old registry card for a connector an MCP preset now fronts", () => {
- const { container } = renderGallery();
+ test("search and a chip filter intersect instead of replacing each other", async () => {
+ const { container } = await renderGallery(PLUGINS, "live web");
- expect(container.textContent).not.toContain(
- "Lets agents run live web search and research lookups.",
- );
+ expect(chip(container, "All").textContent).toContain("1");
+ expect(chip(container, "Research & data").textContent).toContain("1");
+ act(() => {
+ chip(container, "Research & data").click();
+ });
+ expect(catalogNames(container)).toEqual(["Exa"]);
});
- test("does not duplicate connected plugins in an icon-only strip", () => {
- const { container } = renderGallery();
+ test("original categories separate productivity and sales and handle empty groups", async () => {
+ const { container } = await renderGallery();
- const strip = container.querySelector('[aria-label="Installed plugins"]');
- expect(strip).toBeNull();
- });
+ act(() => chip(container, "Productivity").click());
+ expect(catalogNames(container)).toEqual([
+ "Granola",
+ "Notion",
+ "Canva",
+ "Manus",
+ ]);
- test("provenance reads as plain words on a plugin card", () => {
- const { container } = renderGallery();
+ act(() => chip(container, "Sales & customer").click());
+ expect(catalogNames(container)).toEqual(["Attio"]);
- expect(container.textContent).toContain("Connected here");
- expect(container.textContent).toContain("Inherited");
+ act(() => chip(container, "Communication").click());
+ expect(catalogNames(container)).toEqual([]);
+ expect(container.textContent).toContain(
+ "No plugins are available in this filter.",
+ );
});
- test("the plugins tab renders the list heading", () => {
- const { container } = renderGallery();
-
- const heading = container.querySelector("h1");
- expect(heading?.textContent).toBe("Plugins");
+ test("search matches the original category labels", async () => {
+ const { container } = await renderGallery(PLUGINS, "engineering");
+
+ expect(catalogNames(container)).toEqual([
+ "Linear",
+ "GitHub MCP",
+ "Sentry",
+ "Railway",
+ "PostHog",
+ "GitHub",
+ ]);
+ expect(chip(container, "Engineering").textContent).toContain("6");
});
- 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]);
-
- expect(container.textContent).toContain("GitHub");
- expect(container.textContent).not.toContain("Anthropic");
- });
+ test("every preset returned by the route reaches a fresh catalog", async () => {
+ const { container } = await renderGallery([]);
- 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/packages/tool-registry-publish/src/freshness-check.test.ts b/packages/tool-registry-publish/src/freshness-check.test.ts
index 4e5faf0af..064841abb 100644
--- a/packages/tool-registry-publish/src/freshness-check.test.ts
+++ b/packages/tool-registry-publish/src/freshness-check.test.ts
@@ -379,6 +379,41 @@ describe("checkToolPackageFreshness", () => {
await checkToolPackageFreshness({ packageDirs: [pkg] });
});
+ test.each([
+ ["GIT_INDEX_FILE", ".git/index"],
+ ["GIT_DIR", ".git"],
+ ["GIT_WORK_TREE", "."],
+ ["GIT_COMMON_DIR", ".git"],
+ ["GIT_OBJECT_DIRECTORY", ".git/objects"],
+ ])(
+ "ignores a foreign %s while detecting real source changes",
+ async (key, suffix) => {
+ const foreign = await committedPackage("export const n = 99;\n");
+ const { pkg } = await committedPackage("export const n = 1;\n");
+ const inherited = process.env;
+ process.env = { ...inherited, [key]: path.join(foreign.root, suffix) };
+ try {
+ const snapshots = await snapshotToolPackages([pkg]);
+ expect(snapshots).toEqual([
+ {
+ name: "@corbits/fake-tools",
+ dir: pkg,
+ currentVersion: "0.0.1",
+ publishedVersion: "0.0.1",
+ srcChangedSincePublished: false,
+ },
+ ]);
+ await checkToolPackageFreshness({ packageDirs: [pkg] });
+ await writePkg(pkg, "0.0.1", "export const n = 2;\n");
+ await expect(
+ checkToolPackageFreshness({ packageDirs: [pkg] }),
+ ).rejects.toBeInstanceOf(StaleToolPackageError);
+ } finally {
+ process.env = inherited;
+ }
+ },
+ );
+
test("committed src change after the version-introducing commit is loud", async () => {
const { root, pkg } = await committedPackage("export const n = 1;\n");
await writePkg(pkg, "0.0.1", "export const n = 2;\n");
diff --git a/packages/tool-registry-publish/src/freshness-check.ts b/packages/tool-registry-publish/src/freshness-check.ts
index c9eca402b..d6fa85d27 100644
--- a/packages/tool-registry-publish/src/freshness-check.ts
+++ b/packages/tool-registry-publish/src/freshness-check.ts
@@ -240,8 +240,23 @@ async function git(
"src/ cannot be compared to the published version",
);
}
+ // Hooks export repository selectors; cwd must select the package's repo,
+ // not the index or object database of the repository running the hook.
+ const repositoryEnvKeys = new Set([
+ "GIT_DIR",
+ "GIT_WORK_TREE",
+ "GIT_COMMON_DIR",
+ "GIT_INDEX_FILE",
+ "GIT_OBJECT_DIRECTORY",
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
+ "GIT_PREFIX",
+ ]);
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !repositoryEnvKeys.has(key)),
+ );
const proc = Bun.spawn(["git", ...args], {
cwd,
+ env,
stdout: "pipe",
stderr: "pipe",
});
diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts
index ea16d8bc9..105ad3842 100644
--- a/scripts/e2e/browser/walkthrough.ts
+++ b/scripts/e2e/browser/walkthrough.ts
@@ -1138,6 +1138,162 @@ 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("[data-plugin-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 engineeringClicked = await page.evaluate(() => {
+ const buttons = Array.from(
+ document.querySelectorAll(
+ '[aria-label="Plugin catalog filters"] button',
+ ),
+ );
+ const engineering = buttons.find((button) =>
+ (button.textContent ?? "").startsWith("Engineering"),
+ );
+ engineering?.click();
+ return engineering !== undefined;
+ });
+ if (!engineeringClicked)
+ throw new Error("Engineering chip was not found");
+ await page.waitForFunction(
+ () =>
+ document.querySelectorAll(
+ '[aria-label="Plugin catalog"] [data-plugin-card]',
+ ).length === 6,
+ );
+ const evenFinalRowBorders = await page.$$eval(
+ '[aria-label="Plugin catalog"] [data-plugin-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 & data"),
+ );
+ 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"] [data-plugin-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) => {