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
93 .getByRole("button", { name: "Add Google Photos Picker API" })93 .getByRole("button", { name: "Add Google Photos Picker API" })
94 .waitFor();94 .waitFor();
95 });95 });
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 });
96 });117 });
97 }),118 }),
98);119);
packages/core/sdk/src/client.ts+6−0
@@ -120,6 +120,12 @@ export interface IntegrationPreset { · 119 unmodified lines
120 readonly specOverrides?: readonly unknown[];120 readonly specOverrides?: readonly unknown[];
121 readonly authTemplate?: readonly IntegrationPresetAuthentication[];121 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
122 readonly healthCheck?: HealthCheckSpec;122 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;
123}129}
124 130
125export type IntegrationPresetAuthentication =131export type IntegrationPresetAuthentication =
packages/core/sdk/src/plugin.ts+4−0
@@ -608,6 +608,10 @@ export interface IntegrationPreset { · 607 unmodified lines
608 readonly specOverrides?: readonly unknown[];608 readonly specOverrides?: readonly unknown[];
609 readonly authTemplate?: readonly IntegrationPresetAuthentication[];609 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
610 readonly healthCheck?: HealthCheckSpec;610 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;
611 readonly transport?: "remote" | "stdio";615 readonly transport?: "remote" | "stdio";
612 readonly command?: string;616 readonly command?: string;
613 readonly args?: readonly string[];617 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
55 label: "GraphQL",55 label: "GraphQL",
56 add: lazy(importAdd),56 add: lazy(importAdd),
57 accounts: lazy(importAccounts),57 accounts: lazy(importAccounts),
58 presets: graphqlPresets,58 presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })),
59 preload: () => {59 preload: () => {
60 void importAdd();60 void importAdd();
61 void importAccounts();61 void importAccounts();
packages/plugins/mcp/src/api/group.ts+5−0
@@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines
36 description: Schema.optional(Schema.String),36 description: Schema.optional(Schema.String),
37 endpoint: Schema.String,37 endpoint: Schema.String,
38 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),38 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"])),
39 slug: Schema.optional(Schema.String),44 slug: Schema.optional(Schema.String),
40 queryParams: Schema.optional(StringMap),45 queryParams: Schema.optional(StringMap),
41 headers: Schema.optional(StringMap),46 headers: Schema.optional(StringMap),
packages/plugins/mcp/src/api/handlers.test.ts+33−0
@@ -119,4 +119,37 @@ describe("McpHandlers", () => { · 118 unmodified lines
119 expect(body.message).toContain("Do you need to provide an API key");119 expect(body.message).toContain("Do you need to provide an API key");
120 }),120 }),
121 );121 );
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 );
122});155});
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -65,6 +65,7 @@ const toServerInput = ( · 64 unmodified lines
65 description?: string;65 description?: string;
66 endpoint: string;66 endpoint: string;
67 remoteTransport?: "streamable-http" | "sse" | "auto";67 remoteTransport?: "streamable-http" | "sse" | "auto";
68 versionNegotiation?: "auto" | "legacy";
68 queryParams?: Record<string, string>;69 queryParams?: Record<string, string>;
69 headers?: Record<string, string>;70 headers?: Record<string, string>;
70 slug?: string;71 slug?: string;
@@ -81,6 +82,7 @@ const toServerInput = ( · 10 unmodified lines
81 description: p.description,82 description: p.description,
82 endpoint: p.endpoint,83 endpoint: p.endpoint,
83 remoteTransport: p.remoteTransport,84 remoteTransport: p.remoteTransport,
85 versionNegotiation: p.versionNegotiation,
84 queryParams: p.queryParams,86 queryParams: p.queryParams,
85 headers: p.headers,87 headers: p.headers,
86 slug: p.slug,88 slug: p.slug,
packages/plugins/mcp/src/react/integration-plugin.tsx+3−1
@@ -92,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 91 unmodified lines
92 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />92 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />
93 );93 );
94 94
95 const presets = allowStdio95 const base = allowStdio
96 ? mcpPresets96 ? mcpPresets
97 : mcpPresets.filter(97 : mcpPresets.filter(
98 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),98 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),
99 );99 );
100 // Built-ins are registry-listed; a deployment's custom presets would not be.
101 const presets = base.map((p) => ({ ...p, registryListed: true }));
100 102
101 return {103 return {
102 key: "mcp",104 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
346 ...(preset.featured ? { featured: preset.featured } : {}),346 ...(preset.featured ? { featured: preset.featured } : {}),
347 family: "google",347 family: "google",
348 specFormat: "google-discovery",348 specFormat: "google-discovery",
349 registryListed: true,
349 defaultSlug: googleServiceSlug(preset.id),350 defaultSlug: googleServiceSlug(preset.id),
350 authTemplate: googleCatalogAuthTemplate(preset.id),351 authTemplate: googleCatalogAuthTemplate(preset.id),
351 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),352 ...(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
569 ...(preset.featured ? { featured: preset.featured } : {}),569 ...(preset.featured ? { featured: preset.featured } : {}),
570 family: "microsoft",570 family: "microsoft",
571 specFormat: "microsoft-graph",571 specFormat: "microsoft-graph",
572 registryListed: true,
572 defaultSlug: microsoftServiceSlug(preset.id),573 defaultSlug: microsoftServiceSlug(preset.id),
573 authTemplate: microsoftGraphCatalogAuthTemplate(preset),574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),575 ...(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
11import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";11import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
12import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";12import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
13import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";13import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
14import { openApiPresets, type OpenApiPreset } from "../sdk/presets";14import { openApiPresets } from "../sdk/presets";
15import { addOpenApiSpec } from "./atoms";15import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
16import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";17import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
17import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";18import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
18 19
@@ -26,10 +27,15 @@ const normalizedSpecUrl = (url: string): string => { · 7 unmodified lines
26 27
27/** The preset table still knows things no spec can say — GitHub's OAuth28/** The preset table still knows things no spec can say — GitHub's OAuth
28 * endpoints against a spec that declares NO security at all. A registry row29 * 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 * whose URL is a preset's URL gets that knowledge pulled across. Matches the
30const presetForSpecUrl = (url: string): OpenApiPreset | undefined => {31 * plugin's COMPLETE preset list, so a deployment's custom presets
32 * contribute too, not just the built-ins. */
33const presetForSpecUrl = (
34 presets: readonly IntegrationPreset[],
35 url: string,
36): IntegrationPreset | undefined => {
31 const target = normalizedSpecUrl(url);37 const target = normalizedSpecUrl(url);
32 return openApiPresets.find(38 return presets.find(
33 (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,39 (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,
34 );40 );
35};41};
@@ -38,72 +44,103 @@ const presetForSpecUrl = (url: string): OpenApiPreset | undefined => { · 2 unmodified lines
38 * configuration: omitting `authenticationTemplate` and `baseUrl` tells the44 * configuration: omitting `authenticationTemplate` and `baseUrl` tells the
39 * server to derive both from the document, exactly what the add page's45 * server to derive both from the document, exactly what the add page's
40 * untouched defaults submit. Registry spec overrides ride along. */46 * untouched defaults submit. Registry spec overrides ride along. */
41function useOpenApiQuickAdd(): (47function makeUseQuickAdd(presets: readonly IntegrationPreset[]) {
42 input: IntegrationQuickAddInput,48 return function useOpenApiQuickAdd(): (
43) => Promise<IntegrationQuickAddResult> {49 input: IntegrationQuickAddInput,
44 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });50 ) => Promise<IntegrationQuickAddResult> {
45 return useCallback(51 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
46 async (input) => {52 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
47 const slug = slugifyNamespace(input.slug ?? input.name);53 return useCallback(
48 if (!slug) return { ok: false, reason: "no derivable slug" };54 async (input) => {
49 // Registry overrides arrive as untyped JSON; a malformed patch goes to55 const slug = slugifyNamespace(input.slug ?? input.name);
50 // the configuration screen (its editor renders the parse error) rather56 if (!slug) return { ok: false, reason: "no derivable slug" };
51 // than being silently dropped from a "successful" quick add.57 // Registry overrides arrive as untyped JSON; a malformed patch goes to
52 const specOverrides = input.specOverrides58 // the configuration screen (its editor renders the parse error) rather
53 ? decodeOpenApiSpecOverrides(input.specOverrides)59 // than being silently dropped from a "successful" quick add.
54 : undefined;60 const specOverrides = input.specOverrides
55 if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {61 ? decodeOpenApiSpecOverrides(input.specOverrides)
56 return { ok: false, reason: "unparseable spec overrides" };62 : undefined;
57 }63 if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
58 const preset = presetForSpecUrl(input.url);64 return { ok: false, reason: "unparseable spec overrides" };
59 // Declared methods travel from BOTH knowledge sources: the preset's65 }
60 // OAuth template (endpoints a spec cannot carry) and the registry's66 const preset = presetForSpecUrl(presets, input.url);
61 // header pattern (GitHub's spec declares no security at all, but the67 // The FULL add page's method policy, replicated exactly: a preset's
62 // registry knows a PAT goes in the Authorization header). When neither68 // OAuth template wins outright; otherwise EVERY spec-detected method is
63 // knows anything, omitting the field lets the spec's own schemes derive.69 // preserved (an explicit template suppresses server-side derivation, so
64 const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>70 // sending only the registry header would erase a spec's declared OAuth
65 template.kind === "oauth2"71 // and key schemes); the registry's header pattern is appended only when
66 ? [72 // the detected set has no API-key method (GitHub declares no security
67 openApiWireAuthInput({73 // at all, and a PAT header is how most calls actually authenticate).
68 ...template,74 const registryPlacement = input.authHeader
69 slug: AuthTemplateSlug.make(template.slug),75 ? placementFromHeaderPattern(input.authHeader)
70 resource: template.resource ?? undefined,76 : null;
71 }),77 const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
72 ]78 template.kind === "oauth2"
73 : [],79 ? [
74 );80 openApiWireAuthInput({
75 const registryPlacement = input.authHeader81 ...template,
76 ? placementFromHeaderPattern(input.authHeader)82 slug: AuthTemplateSlug.make(template.slug),
77 : null;83 resource: template.resource ?? undefined,
78 const authenticationTemplate = [84 }),
79 ...presetMethods,85 ]
80 ...(registryPlacement86 : [],
81 ? [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]87 );
82 : []),88 let authenticationTemplate = presetMethods;
83 ];89 if (presetMethods.length === 0 && registryPlacement) {
84 const presetOverrides =90 // Composing with spec knowledge needs the spec: one preview call,
85 specOverrides && specOverrides.length > 0 ? specOverrides : preset?.specOverrides;91 // only on this path (a plain registry row with no auth facts still
86 const exit = await doAdd({92 // adds with zero extra round trips).
87 payload: {93 const previewExit = await doPreview({ payload: { spec: input.url } });
88 spec: { kind: "url" as const, url: input.url },94 if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" };
89 slug,95 const previewSummary = previewExit.value;
90 name: input.name,96 const detected = detectedAuthenticationTemplates(
91 ...(input.domain ? { displayDomain: input.domain } : {}),97 previewSummary.headerPresets,
92 ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),98 previewSummary.oauth2Presets,
93 ...(preset?.family ? { family: preset.family } : {}),99 previewSummary.servers[0]?.url ?? "",
94 ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),100 );
95 ...(presetOverrides && presetOverrides.length > 0101 const detectedHasApiKey = detected.some((template) => template.kind === "apikey");
96 ? { specOverrides: presetOverrides }102 authenticationTemplate = [
97 : {}),103 ...detected.map(openApiWireAuthInput),
98 ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),104 ...(detectedHasApiKey
99 },105 ? []
100 reactivityKeys: integrationWriteKeys,106 : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]),
101 });107 ];
102 if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };108 } else if (presetMethods.length > 0 && registryPlacement) {
103 return { ok: true, slug: String(exit.value.slug) };109 authenticationTemplate = [
104 },110 ...presetMethods,
105 [doAdd],111 openApiWireAuthInput(templateFromPlacements([registryPlacement])),
106 );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 };
107}144}
108 145
109const importAdd = () => import("./AddOpenApiIntegration");146const importAdd = () => import("./AddOpenApiIntegration");
@@ -114,21 +151,27 @@ export interface OpenApiClientConfig { · 4 unmodified lines
114 readonly presets?: readonly IntegrationPreset[];151 readonly presets?: readonly IntegrationPreset[];
115}152}
116 153
117export const createOpenApiIntegrationPlugin = (154export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
118 config?: OpenApiClientConfig,155 // Built-ins are registry-listed (the picker shows the registry's card);
119): IntegrationPlugin => ({156 // a deployment's custom presets are not, and keep their own cards.
120 key: "openapi",157 const presets: readonly IntegrationPreset[] = [
121 label: "OpenAPI",158 ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
122 add: lazy(importAdd),159 ...(config?.presets ?? []),
123 editSheet: lazy(importEditSheet),160 ];
124 accounts: lazy(importAccounts),161 return {
125 presets: [...openApiPresets, ...(config?.presets ?? [])],162 key: "openapi",
126 preload: () => {163 label: "OpenAPI",
127 void importAdd();164 add: lazy(importAdd),
128 void importEditSheet();165 editSheet: lazy(importEditSheet),
129 void importAccounts();166 accounts: lazy(importAccounts),
130 },167 presets,
131 useQuickAdd: useOpenApiQuickAdd,168 preload: () => {
132});169 void importAdd();
170 void importEditSheet();
171 void importAccounts();
172 },
173 useQuickAdd: makeUseQuickAdd(presets),
174 };
175};
133 176
134export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();177export 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
250 ? surface.url250 ? surface.url
251 : undefined;251 : undefined;
252 252
253const normalizedProductToken = (value: string): string =>
254 value.toLowerCase().replace(/[^a-z0-9]/g, "");
255
253export const pickConnectTarget = (256export const pickConnectTarget = (
254 payload: unknown,257 payload: unknown,
255 kind: CatalogKind,258 kind: CatalogKind,
259 preferredName?: string,
256): CatalogConnectTarget | undefined =>260): CatalogConnectTarget | undefined =>
257 Option.match(decodeSurfaceDocument(payload), {261 Option.match(decodeSurfaceDocument(payload), {
258 onNone: () => undefined,262 onNone: () => undefined,
259 onSome: ({ surfaces }) => {263 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) => {
261 const url = connectUrlOf(surface, kind);269 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;
263 }280 }
264 return undefined;281 return usable[0];
265 },282 },
266 });283 });
267 284
@@ -270,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines
270export const resolveConnectTarget = (287export const resolveConnectTarget = (
271 domain: string,288 domain: string,
272 kinds: readonly CatalogKind[],289 kinds: readonly CatalogKind[],
290 preferredName?: string,
273): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {291): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {
274 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);292 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
275 return Effect.map(fetchCatalogJson(url), (payload) => {293 return Effect.map(fetchCatalogJson(url), (payload) => {
276 for (const kind of kinds) {294 for (const kind of kinds) {
277 const target = pickConnectTarget(payload, kind);295 const target = pickConnectTarget(payload, kind, preferredName);
278 if (target) return target;296 if (target) return target;
279 }297 }
280 return undefined;298 return undefined;
packages/react/src/pages/integration-browse.tsx+49−15
@@ -331,18 +331,33 @@ type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickA · 330 unmodified lines
331 * and mounting one bridge per plugin is how a fixed set of per-plugin hooks331 * and mounting one bridge per plugin is how a fixed set of per-plugin hooks
332 * stays rules-of-hooks clean. */332 * stays rules-of-hooks clean. */
333function QuickAddBridge(props: {333function 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;
335 readonly register: (key: string, fn: QuickAddFn | null) => void;339 readonly register: (key: string, fn: QuickAddFn | null) => void;
336}) {340}) {
337 const { plugin, register } = props;341 const { pluginKey, useQuickAdd, register } = props;
338 const fn = plugin.useQuickAdd?.() ?? null;342 const fn = useQuickAdd();
339 useEffect(() => {343 useEffect(() => {
340 register(plugin.key, fn);344 register(pluginKey, fn);
341 return () => register(plugin.key, null);345 return () => register(pluginKey, null);
342 }, [plugin.key, fn, register]);346 }, [pluginKey, fn, register]);
343 return null;347 return null;
344}348}
345 349
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. */
352function 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
346// ---------------------------------------------------------------------------361// ---------------------------------------------------------------------------
347// Page362// Page
348// ---------------------------------------------------------------------------363// ---------------------------------------------------------------------------
@@ -599,11 +614,13 @@ export function IntegrationBrowsePage() { · 250 unmodified lines
599 kind: CatalogKind,614 kind: CatalogKind,
600 title: string,615 title: string,
601 rowKey: string,616 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,
603 ) => {622 ) => {
604 // The registry already told us where this surface lives — go, rather than623 const knownUrl = surface?.url;
605 // spending a round trip re-asking for something we were handed.
606 const surface = entry.surfaces?.find((candidate) => candidate.kind === kind);
607 if (knownUrl) {624 if (knownUrl) {
608 const added = await tryQuickAdd({625 const added = await tryQuickAdd({
609 kind,626 kind,
@@ -629,7 +646,11 @@ export function IntegrationBrowsePage() { · 19 unmodified lines
629 if (resolvingDomain !== null) return;646 if (resolvingDomain !== null) return;
630 setResolvingDomain(entry.domain);647 setResolvingDomain(entry.domain);
631 setRowError(null);648 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 );
633 setResolvingDomain(null);654 setResolvingDomain(null);
634 if (Exit.isFailure(exit) || !exit.value) {655 if (Exit.isFailure(exit) || !exit.value) {
635 setRowError({656 setRowError({
@@ -682,7 +703,14 @@ export function IntegrationBrowsePage() { · 46 unmodified lines
682 const presetRows = useMemo<readonly Row[]>(() => {703 const presetRows = useMemo<readonly Row[]>(() => {
683 const rows: Row[] = [];704 const rows: Row[] = [];
684 for (const entry of allPresets) {705 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;
686 if (text.length > 0) {714 if (text.length > 0) {
687 const corpus =715 const corpus =
688 `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase();716 `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase();
@@ -748,7 +776,8 @@ export function IntegrationBrowsePage() { · 59 unmodified lines
748 ...(description ? { description } : {}),776 ...(description ? { description } : {}),
749 iconUrl:777 iconUrl:
750 (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10),778 (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),
752 added:781 added:
753 quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title),782 quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title),
754 ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}),783 ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}),
@@ -880,8 +909,13 @@ export function IntegrationBrowsePage() { · 125 unmodified lines
880 // search, and ONLY the results grid scrolls — an endless list makes the909 // search, and ONLY the results grid scrolls — an endless list makes the
881 // page scrollbar meaningless and drags the search box off screen.910 // page scrollbar meaningless and drags the search box off screen.
882 <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container">911 <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container">
883 {integrationPlugins.map((plugin) => (912 {quickAddCapablePlugins(integrationPlugins).map((plugin) => (
884 <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} />913 <QuickAddBridge
914 key={plugin.key}
915 pluginKey={plugin.key}
916 useQuickAdd={plugin.useQuickAdd!}
917 register={registerQuickAdd}
918 />
885 ))}919 ))}
886 <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14">920 <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14">
887 <PageHeader921 <PageHeader