History

Fix the review findings on the quick-add group

Selection carries the CLICKED surface instead of re-finding by kind, and the kinds-only fallback disambiguates same-kind products by the row's product name against surface slugs — two same-kind products on one domain no longer merge one card's URL with the other's slug, auth, and overrides. The OpenAPI quick add replicates the full add page's method policy: preset OAuth wins outright; otherwise every spec-detected method is preserved (one preview call, only when the registry declared a header) and the registry key is appended only when the spec has no key method. The MCP remote add carries versionNegotiation through the payload schema and handler map, with a round-trip test — the legacy pin previously died at the HTTP boundary. URL presets hide only when registry-listed (built-ins and the provider catalogs declare it), so a deployment's custom preset keeps its card, and quick add matches the plugin's complete preset list. Quick-add bridges render only for plugins with the hook and call it unconditionally; duplicate plugin keys get one bridge.

Rhys Sullivan committed 2 months ago

Commit 82313271 parent 647612113 files · +278 −108Browse files

Unified · Split

13 files
  1. e2e/scenarios/google-photos-preset-ui.test.ts +21 −0
  2. packages/core/sdk/src/client.ts +6 −0
  3. packages/core/sdk/src/plugin.ts +4 −0
  4. packages/plugins/graphql/src/react/integration-plugin.ts +1 −1
  5. packages/plugins/mcp/src/api/group.ts +5 −0
  6. packages/plugins/mcp/src/api/handlers.test.ts +33 −0
  7. packages/plugins/mcp/src/api/handlers.ts +2 −0
  8. packages/plugins/mcp/src/react/integration-plugin.tsx +3 −1
  9. packages/plugins/openapi/src/providers/google/presets.ts +1 −0
  10. packages/plugins/openapi/src/providers/microsoft/presets.ts +1 −0
  11. packages/plugins/openapi/src/react/integration-plugin.ts +130 −87
  12. packages/react/src/lib/integrations-sh-catalog.ts +22 −4
  13. packages/react/src/pages/integration-browse.tsx +49 −15
e2e/scenarios/google-photos-preset-ui.test.ts+21−0
@@ -93,6 +93,27 @@ scenario( · 92 unmodified lines
9393 .getByRole("button", { name: "Add Google Photos Picker API" })
9494 .waitFor();
9595 });
96+
97+ await step("Adding the SECOND same-kind card keeps its own identity", async () => {
98+ // The regression this guards: selection re-derived the surface by
99+ // KIND, so the second product of a kind inherited the first's slug,
100+ // auth, and overrides.
101+ await page
102+ .getByTestId("catalog-photos.google.com-openapi")
103+ .filter({ hasText: "Google Photos Picker API" })
104+ .getByRole("button", { name: "Add Google Photos Picker API" })
105+ .click();
106+ const pickerCard = page
107+ .getByTestId("catalog-photos.google.com-openapi")
108+ .filter({ hasText: "Google Photos Picker API" });
109+ await pickerCard
110+ .getByRole("link", { name: "View Google Photos Picker API" })
111+ .waitFor({ timeout: 120_000 });
112+ const href = await pickerCard
113+ .getByRole("link", { name: "View Google Photos Picker API" })
114+ .getAttribute("href");
115+ expect(href).toContain("/integrations/google_photos_picker");
116+ });
96117 });
97118 }),
98119 );
packages/core/sdk/src/client.ts+6−0
@@ -120,6 +120,12 @@ export interface IntegrationPreset { · 119 unmodified lines
120120 readonly specOverrides?: readonly unknown[];
121121 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
122122 readonly healthCheck?: HealthCheckSpec;
123+ /** The public registry lists this product, so the picker shows the
124+ * registry's card instead of a preset card (the preset's knowledge still
125+ * rides quick add). Built-in presets set this; a deployment's custom
126+ * preset does not, and keeps its own card — hiding it would make a
127+ * private API undiscoverable. */
128+ readonly registryListed?: boolean;
123129 }
124130
125131 export type IntegrationPresetAuthentication =
packages/core/sdk/src/plugin.ts+4−0
@@ -608,6 +608,10 @@ export interface IntegrationPreset { · 607 unmodified lines
608608 readonly specOverrides?: readonly unknown[];
609609 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
610610 readonly healthCheck?: HealthCheckSpec;
611+ /** The public registry lists this product: the picker shows the registry's
612+ * card, and the preset's knowledge rides quick add instead. A custom
613+ * deployment preset leaves this unset and keeps its own card. */
614+ readonly registryListed?: boolean;
611615 readonly transport?: "remote" | "stdio";
612616 readonly command?: string;
613617 readonly args?: readonly string[];
packages/plugins/graphql/src/react/integration-plugin.ts+1−1
@@ -55,7 +55,7 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 54 unmodified lines
5555 label: "GraphQL",
5656 add: lazy(importAdd),
5757 accounts: lazy(importAccounts),
58− presets: graphqlPresets,
58+ presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })),
5959 preload: () => {
6060 void importAdd();
6161 void importAccounts();
packages/plugins/mcp/src/api/group.ts+5−0
@@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines
3636 description: Schema.optional(Schema.String),
3737 endpoint: Schema.String,
3838 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),
39+ /** Pin legacy protocol negotiation for a server that echoes the modern
40+ * revision but breaks its contract (the probe reports when only legacy
41+ * worked). Omitting this here silently stripped the pin from the stored
42+ * integration, leaving it unusable against exactly that server. */
43+ versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])),
3944 slug: Schema.optional(Schema.String),
4045 queryParams: Schema.optional(StringMap),
4146 headers: Schema.optional(StringMap),
packages/plugins/mcp/src/api/handlers.test.ts+33−0
@@ -119,4 +119,37 @@ describe("McpHandlers", () => { · 118 unmodified lines
119119 expect(body.message).toContain("Do you need to provide an API key");
120120 }),
121121 );
122+
123+ it.effect("the remote add carries versionNegotiation through to the extension", () =>
124+ Effect.gen(function* () {
125+ // The probe's legacy retry is pointless if the pin dies at the HTTP
126+ // boundary: this exact field was silently stripped by the payload
127+ // schema and the handler's explicit field map.
128+ let received: unknown;
129+ const web = yield* webHandlerFor({
130+ ...failingExtension,
131+ addServer: (input) => {
132+ received = input;
133+ return Effect.succeed({ slug: "pinned", tools: [] } as never);
134+ },
135+ });
136+ const response = yield* Effect.promise(() =>
137+ (web.handler as (request: Request) => Promise<Response>)(
138+ new Request("http://localhost/mcp/servers", {
139+ method: "POST",
140+ headers: { "content-type": "application/json" },
141+ body: JSON.stringify({
142+ transport: "remote",
143+ name: "Echoing server",
144+ endpoint: "https://example.com/mcp",
145+ versionNegotiation: "legacy",
146+ authenticationTemplate: [{ kind: "none" }],
147+ }),
148+ }),
149+ ),
150+ );
151+ expect(response.status).toBeLessThan(500);
152+ expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy");
153+ }),
154+ );
122155 });
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -65,6 +65,7 @@ const toServerInput = ( · 64 unmodified lines
6565 description?: string;
6666 endpoint: string;
6767 remoteTransport?: "streamable-http" | "sse" | "auto";
68+ versionNegotiation?: "auto" | "legacy";
6869 queryParams?: Record<string, string>;
6970 headers?: Record<string, string>;
7071 slug?: string;
@@ -81,6 +82,7 @@ const toServerInput = ( · 10 unmodified lines
8182 description: p.description,
8283 endpoint: p.endpoint,
8384 remoteTransport: p.remoteTransport,
85+ versionNegotiation: p.versionNegotiation,
8486 queryParams: p.queryParams,
8587 headers: p.headers,
8688 slug: p.slug,
packages/plugins/mcp/src/react/integration-plugin.tsx+3−1
@@ -92,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 91 unmodified lines
9292 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />
9393 );
9494
95− const presets = allowStdio
95+ const base = allowStdio
9696 ? mcpPresets
9797 : mcpPresets.filter(
9898 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),
9999 );
100+ // Built-ins are registry-listed; a deployment's custom presets would not be.
101+ const presets = base.map((p) => ({ ...p, registryListed: true }));
100102
101103 return {
102104 key: "mcp",
packages/plugins/openapi/src/providers/google/presets.ts+1−0
@@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines
346346 ...(preset.featured ? { featured: preset.featured } : {}),
347347 family: "google",
348348 specFormat: "google-discovery",
349+ registryListed: true,
349350 defaultSlug: googleServiceSlug(preset.id),
350351 authTemplate: googleCatalogAuthTemplate(preset.id),
351352 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
packages/plugins/openapi/src/providers/microsoft/presets.ts+1−0
@@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines
569569 ...(preset.featured ? { featured: preset.featured } : {}),
570570 family: "microsoft",
571571 specFormat: "microsoft-graph",
572+ registryListed: true,
572573 defaultSlug: microsoftServiceSlug(preset.id),
573574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574575 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),
packages/plugins/openapi/src/react/integration-plugin.ts+130−87
@@ -11,8 +11,9 @@ import { AuthTemplateSlug } from "@executor-js/sdk/shared"; · 10 unmodified lines
1111 import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
1212 import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
1313 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
14−import { openApiPresets, type OpenApiPreset } from "../sdk/presets";
15−import { addOpenApiSpec } from "./atoms";
14+import { openApiPresets } from "../sdk/presets";
15+import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16+import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
1617 import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
1718 import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
1819
@@ -26,10 +27,15 @@ const normalizedSpecUrl = (url: string): string => { · 7 unmodified lines
2627
2728 /** The preset table still knows things no spec can say — GitHub's OAuth
2829 * endpoints against a spec that declares NO security at all. A registry row
29− * whose URL is a preset's URL gets that knowledge pulled across. */
30−const presetForSpecUrl = (url: string): OpenApiPreset | undefined => {
30+ * whose URL is a preset's URL gets that knowledge pulled across. Matches the
31+ * plugin's COMPLETE preset list, so a deployment's custom presets
32+ * contribute too, not just the built-ins. */
33+const presetForSpecUrl = (
34+ presets: readonly IntegrationPreset[],
35+ url: string,
36+): IntegrationPreset | undefined => {
3137 const target = normalizedSpecUrl(url);
32− return openApiPresets.find(
38+ return presets.find(
3339 (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,
3440 );
3541 };
@@ -38,72 +44,103 @@ const presetForSpecUrl = (url: string): OpenApiPreset | undefined => { · 2 unmodified lines
3844 * configuration: omitting `authenticationTemplate` and `baseUrl` tells the
3945 * server to derive both from the document, exactly what the add page's
4046 * untouched defaults submit. Registry spec overrides ride along. */
41−function useOpenApiQuickAdd(): (
42− input: IntegrationQuickAddInput,
43−) => Promise<IntegrationQuickAddResult> {
44− const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
45− return useCallback(
46− async (input) => {
47− const slug = slugifyNamespace(input.slug ?? input.name);
48− if (!slug) return { ok: false, reason: "no derivable slug" };
49− // Registry overrides arrive as untyped JSON; a malformed patch goes to
50− // the configuration screen (its editor renders the parse error) rather
51− // than being silently dropped from a "successful" quick add.
52− const specOverrides = input.specOverrides
53− ? decodeOpenApiSpecOverrides(input.specOverrides)
54− : undefined;
55− if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
56− return { ok: false, reason: "unparseable spec overrides" };
57− }
58− const preset = presetForSpecUrl(input.url);
59− // Declared methods travel from BOTH knowledge sources: the preset's
60− // OAuth template (endpoints a spec cannot carry) and the registry's
61− // header pattern (GitHub's spec declares no security at all, but the
62− // registry knows a PAT goes in the Authorization header). When neither
63− // knows anything, omitting the field lets the spec's own schemes derive.
64− const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
65− template.kind === "oauth2"
66− ? [
67− openApiWireAuthInput({
68− ...template,
69− slug: AuthTemplateSlug.make(template.slug),
70− resource: template.resource ?? undefined,
71− }),
72− ]
73− : [],
74− );
75− const registryPlacement = input.authHeader
76− ? placementFromHeaderPattern(input.authHeader)
77− : null;
78− const authenticationTemplate = [
79− ...presetMethods,
80− ...(registryPlacement
81− ? [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]
82− : []),
83− ];
84− const presetOverrides =
85− specOverrides && specOverrides.length > 0 ? specOverrides : preset?.specOverrides;
86− const exit = await doAdd({
87− payload: {
88− spec: { kind: "url" as const, url: input.url },
89− slug,
90− name: input.name,
91− ...(input.domain ? { displayDomain: input.domain } : {}),
92− ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),
93− ...(preset?.family ? { family: preset.family } : {}),
94− ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),
95− ...(presetOverrides && presetOverrides.length > 0
96− ? { specOverrides: presetOverrides }
97− : {}),
98− ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),
99− },
100− reactivityKeys: integrationWriteKeys,
101− });
102− if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
103− return { ok: true, slug: String(exit.value.slug) };
104− },
105− [doAdd],
106− );
47+function makeUseQuickAdd(presets: readonly IntegrationPreset[]) {
48+ return function useOpenApiQuickAdd(): (
49+ input: IntegrationQuickAddInput,
50+ ) => Promise<IntegrationQuickAddResult> {
51+ const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
52+ const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
53+ return useCallback(
54+ async (input) => {
55+ const slug = slugifyNamespace(input.slug ?? input.name);
56+ if (!slug) return { ok: false, reason: "no derivable slug" };
57+ // Registry overrides arrive as untyped JSON; a malformed patch goes to
58+ // the configuration screen (its editor renders the parse error) rather
59+ // than being silently dropped from a "successful" quick add.
60+ const specOverrides = input.specOverrides
61+ ? decodeOpenApiSpecOverrides(input.specOverrides)
62+ : undefined;
63+ if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
64+ return { ok: false, reason: "unparseable spec overrides" };
65+ }
66+ const preset = presetForSpecUrl(presets, input.url);
67+ // The FULL add page's method policy, replicated exactly: a preset's
68+ // OAuth template wins outright; otherwise EVERY spec-detected method is
69+ // preserved (an explicit template suppresses server-side derivation, so
70+ // sending only the registry header would erase a spec's declared OAuth
71+ // and key schemes); the registry's header pattern is appended only when
72+ // the detected set has no API-key method (GitHub declares no security
73+ // at all, and a PAT header is how most calls actually authenticate).
74+ const registryPlacement = input.authHeader
75+ ? placementFromHeaderPattern(input.authHeader)
76+ : null;
77+ const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
78+ template.kind === "oauth2"
79+ ? [
80+ openApiWireAuthInput({
81+ ...template,
82+ slug: AuthTemplateSlug.make(template.slug),
83+ resource: template.resource ?? undefined,
84+ }),
85+ ]
86+ : [],
87+ );
88+ let authenticationTemplate = presetMethods;
89+ if (presetMethods.length === 0 && registryPlacement) {
90+ // Composing with spec knowledge needs the spec: one preview call,
91+ // only on this path (a plain registry row with no auth facts still
92+ // adds with zero extra round trips).
93+ const previewExit = await doPreview({ payload: { spec: input.url } });
94+ if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" };
95+ const previewSummary = previewExit.value;
96+ const detected = detectedAuthenticationTemplates(
97+ previewSummary.headerPresets,
98+ previewSummary.oauth2Presets,
99+ previewSummary.servers[0]?.url ?? "",
100+ );
101+ const detectedHasApiKey = detected.some((template) => template.kind === "apikey");
102+ authenticationTemplate = [
103+ ...detected.map(openApiWireAuthInput),
104+ ...(detectedHasApiKey
105+ ? []
106+ : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]),
107+ ];
108+ } else if (presetMethods.length > 0 && registryPlacement) {
109+ authenticationTemplate = [
110+ ...presetMethods,
111+ openApiWireAuthInput(templateFromPlacements([registryPlacement])),
112+ ];
113+ }
114+ // Preset overrides arrive as the same untyped JSON shape the registry
115+ // sends; decode both through the one boundary.
116+ const presetOverrides =
117+ specOverrides && specOverrides.length > 0
118+ ? specOverrides
119+ : preset?.specOverrides
120+ ? decodeOpenApiSpecOverrides(preset.specOverrides)
121+ : undefined;
122+ const exit = await doAdd({
123+ payload: {
124+ spec: { kind: "url" as const, url: input.url },
125+ slug,
126+ name: input.name,
127+ ...(input.domain ? { displayDomain: input.domain } : {}),
128+ ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),
129+ ...(preset?.family ? { family: preset.family } : {}),
130+ ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),
131+ ...(presetOverrides && presetOverrides.length > 0
132+ ? { specOverrides: presetOverrides }
133+ : {}),
134+ ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),
135+ },
136+ reactivityKeys: integrationWriteKeys,
137+ });
138+ if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
139+ return { ok: true, slug: String(exit.value.slug) };
140+ },
141+ [doAdd, doPreview],
142+ );
143+ };
107144 }
108145
109146 const importAdd = () => import("./AddOpenApiIntegration");
@@ -114,21 +151,27 @@ export interface OpenApiClientConfig { · 4 unmodified lines
114151 readonly presets?: readonly IntegrationPreset[];
115152 }
116153
117−export const createOpenApiIntegrationPlugin = (
118− config?: OpenApiClientConfig,
119−): IntegrationPlugin => ({
120− key: "openapi",
121− label: "OpenAPI",
122− add: lazy(importAdd),
123− editSheet: lazy(importEditSheet),
124− accounts: lazy(importAccounts),
125− presets: [...openApiPresets, ...(config?.presets ?? [])],
126− preload: () => {
127− void importAdd();
128− void importEditSheet();
129− void importAccounts();
130− },
131− useQuickAdd: useOpenApiQuickAdd,
132−});
154+export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
155+ // Built-ins are registry-listed (the picker shows the registry's card);
156+ // a deployment's custom presets are not, and keep their own cards.
157+ const presets: readonly IntegrationPreset[] = [
158+ ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
159+ ...(config?.presets ?? []),
160+ ];
161+ return {
162+ key: "openapi",
163+ label: "OpenAPI",
164+ add: lazy(importAdd),
165+ editSheet: lazy(importEditSheet),
166+ accounts: lazy(importAccounts),
167+ presets,
168+ preload: () => {
169+ void importAdd();
170+ void importEditSheet();
171+ void importAccounts();
172+ },
173+ useQuickAdd: makeUseQuickAdd(presets),
174+ };
175+};
133176
134177 export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/react/src/lib/integrations-sh-catalog.ts+22−4
@@ -250,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 249 unmodified lines
250250 ? surface.url
251251 : undefined;
252252
253+const normalizedProductToken = (value: string): string =>
254+ value.toLowerCase().replace(/[^a-z0-9]/g, "");
255+
253256 export const pickConnectTarget = (
254257 payload: unknown,
255258 kind: CatalogKind,
259+ preferredName?: string,
256260 ): CatalogConnectTarget | undefined =>
257261 Option.match(decodeSurfaceDocument(payload), {
258262 onNone: () => undefined,
259263 onSome: ({ surfaces }) => {
260− for (const surface of surfaces) {
264+ // One domain can carry several same-kind products (Google Photos
265+ // Library and Picker share photos.google.com). When the caller names
266+ // the product, a slug whose normalized form matches wins; first-of-kind
267+ // is only the answer when nothing identifies the product.
268+ const usable = surfaces.flatMap((surface) => {
261269 const url = connectUrlOf(surface, kind);
262− if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) };
270+ return url
271+ ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget]
272+ : [];
273+ });
274+ if (preferredName) {
275+ const token = normalizedProductToken(preferredName);
276+ const named = usable.find(
277+ (target) => target.slug && normalizedProductToken(target.slug) === token,
278+ );
279+ if (named) return named;
263280 }
264− return undefined;
281+ return usable[0];
265282 },
266283 });
267284
@@ -270,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines
270287 export const resolveConnectTarget = (
271288 domain: string,
272289 kinds: readonly CatalogKind[],
290+ preferredName?: string,
273291 ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {
274292 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
275293 return Effect.map(fetchCatalogJson(url), (payload) => {
276294 for (const kind of kinds) {
277− const target = pickConnectTarget(payload, kind);
295+ const target = pickConnectTarget(payload, kind, preferredName);
278296 if (target) return target;
279297 }
280298 return undefined;
packages/react/src/pages/integration-browse.tsx+49−15
@@ -331,18 +331,33 @@ type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickA · 330 unmodified lines
331331 * and mounting one bridge per plugin is how a fixed set of per-plugin hooks
332332 * stays rules-of-hooks clean. */
333333 function QuickAddBridge(props: {
334− readonly plugin: IntegrationPlugin;
334+ readonly pluginKey: string;
335+ /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge
336+ * only for plugins that have one, so this component's hook sequence never
337+ * depends on a plugin object's shape. */
338+ readonly useQuickAdd: () => QuickAddFn;
335339 readonly register: (key: string, fn: QuickAddFn | null) => void;
336340 }) {
337− const { plugin, register } = props;
338− const fn = plugin.useQuickAdd?.() ?? null;
341+ const { pluginKey, useQuickAdd, register } = props;
342+ const fn = useQuickAdd();
339343 useEffect(() => {
340− register(plugin.key, fn);
341− return () => register(plugin.key, null);
342− }, [plugin.key, fn, register]);
344+ register(pluginKey, fn);
345+ return () => register(pluginKey, null);
346+ }, [pluginKey, fn, register]);
343347 return null;
344348 }
345349
350+/** One bridge per plugin KEY: a duplicate key would share the React key and
351+ * the callback slot, letting either copy's cleanup delete the other. */
352+function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) {
353+ const seen = new Set<string>();
354+ return plugins.filter((plugin) => {
355+ if (!plugin.useQuickAdd || seen.has(plugin.key)) return false;
356+ seen.add(plugin.key);
357+ return true;
358+ });
359+}
360+
346361 // ---------------------------------------------------------------------------
347362 // Page
348363 // ---------------------------------------------------------------------------
@@ -599,11 +614,13 @@ export function IntegrationBrowsePage() { · 250 unmodified lines
599614 kind: CatalogKind,
600615 title: string,
601616 rowKey: string,
602− knownUrl?: string,
617+ // The EXACT surface this card was built from. Never re-derived from
618+ // `kind`: one domain can carry two same-kind products (Google Photos
619+ // Library and Picker), and a kind lookup silently merged the clicked
620+ // card's URL with the FIRST surface's slug, auth, and overrides.
621+ surface?: CatalogSurface,
603622 ) => {
604− // The registry already told us where this surface lives — go, rather than
605− // spending a round trip re-asking for something we were handed.
606− const surface = entry.surfaces?.find((candidate) => candidate.kind === kind);
623+ const knownUrl = surface?.url;
607624 if (knownUrl) {
608625 const added = await tryQuickAdd({
609626 kind,
@@ -629,7 +646,11 @@ export function IntegrationBrowsePage() { · 19 unmodified lines
629646 if (resolvingDomain !== null) return;
630647 setResolvingDomain(entry.domain);
631648 setRowError(null);
632− const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, [kind]));
649+ // The row's product name disambiguates same-kind surfaces in the doc
650+ // (the registry row itself was kinds-only, so the name is all we have).
651+ const exit = await Effect.runPromiseExit(
652+ resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined),
653+ );
633654 setResolvingDomain(null);
634655 if (Exit.isFailure(exit) || !exit.value) {
635656 setRowError({
@@ -682,7 +703,14 @@ export function IntegrationBrowsePage() { · 46 unmodified lines
682703 const presetRows = useMemo<readonly Row[]>(() => {
683704 const rows: Row[] = [];
684705 for (const entry of allPresets) {
685− if (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) continue;
706+ // A URL preset the registry lists is the registry row's job. A custom
707+ // deployment preset has no registry row — hiding it would make a
708+ // private API undiscoverable, so it keeps its card.
709+ if (
710+ (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) &&
711+ entry.preset.registryListed === true
712+ )
713+ continue;
686714 if (text.length > 0) {
687715 const corpus =
688716 `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase();
@@ -748,7 +776,8 @@ export function IntegrationBrowsePage() { · 59 unmodified lines
748776 ...(description ? { description } : {}),
749777 iconUrl:
750778 (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10),
751− onSelect: () => void pickCatalogEntry(entry, surface.kind, title, rowKey, known?.url),
779+ onSelect: () =>
780+ void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined),
752781 added:
753782 quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title),
754783 ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}),
@@ -880,8 +909,13 @@ export function IntegrationBrowsePage() { · 125 unmodified lines
880909 // search, and ONLY the results grid scrolls — an endless list makes the
881910 // page scrollbar meaningless and drags the search box off screen.
882911 <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container">
883− {integrationPlugins.map((plugin) => (
884− <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} />
912+ {quickAddCapablePlugins(integrationPlugins).map((plugin) => (
913+ <QuickAddBridge
914+ key={plugin.key}
915+ pluginKey={plugin.key}
916+ useQuickAdd={plugin.useQuickAdd!}
917+ register={registerQuickAdd}
918+ />
885919 ))}
886920 <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14">
887921 <PageHeader