From ba90f107b70f085e504ceb0db9a588c12b617ff8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:27:18 -0700 Subject: [PATCH 1/4] Search the integrations.sh catalog from the connect dialog --- packages/react/src/api/analytics.tsx | 3 +- .../src/lib/integrations-sh-catalog.test.ts | 122 +++++++++ .../react/src/lib/integrations-sh-catalog.ts | 255 ++++++++++++++++++ packages/react/src/pages/integrations.tsx | 123 ++++++++- 4 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/lib/integrations-sh-catalog.test.ts create mode 100644 packages/react/src/lib/integrations-sh-catalog.ts diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 48ded24923..915c87ffa0 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -39,8 +39,9 @@ export interface AnalyticsEvents { }; integration_add_started: { plugin_key: string; - via: "detect" | "manual" | "preset" | "command_palette"; + via: "detect" | "manual" | "preset" | "command_palette" | "catalog"; preset_id?: string; + catalog_domain?: string; }; integration_added: { plugin_key: string; integration_slug?: string }; integration_add_cancelled: { plugin_key: string }; diff --git a/packages/react/src/lib/integrations-sh-catalog.test.ts b/packages/react/src/lib/integrations-sh-catalog.test.ts new file mode 100644 index 0000000000..dd7251b926 --- /dev/null +++ b/packages/react/src/lib/integrations-sh-catalog.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { IntegrationPlugin } from "@executor-js/sdk/client"; + +import { + availableCatalogKinds, + filterCatalogEntries, + parseCatalogSearch, + pickConnectTarget, + presetDomains, +} from "./integrations-sh-catalog"; + +const plugin = (key: string, presets: IntegrationPlugin["presets"]): IntegrationPlugin => ({ + key, + label: key, + add: () => null, + presets, +}); + +describe("parseCatalogSearch", () => { + it("keeps connectable kinds and drops CLI-only entries", () => { + const entries = parseCatalogSearch({ + results: [ + { domain: "linear.app", description: "Issues", kinds: ["mcp", "cli"] }, + { domain: "cli-only.dev", description: "A CLI", kinds: ["cli"] }, + ], + }); + expect(entries).toEqual([{ domain: "linear.app", description: "Issues", kinds: ["mcp"] }]); + }); + + it("returns nothing for a malformed payload", () => { + expect(parseCatalogSearch({ nope: true })).toEqual([]); + expect(parseCatalogSearch(undefined)).toEqual([]); + }); +}); + +describe("pickConnectTarget", () => { + const payload = { + surfaces: [ + { type: "graphql", url: "https://api.linear.app/graphql", slug: "linear-graphql-api" }, + { type: "mcp", url: "https://mcp.linear.app/mcp", slug: "linear" }, + { type: "http", spec: "https://example.com/openapi.json", slug: "example-rest" }, + { type: "cli", slug: "linear-cli" }, + ], + }; + + it("resolves the MCP endpoint for the mcp kind", () => { + expect(pickConnectTarget(payload, "mcp")).toEqual({ + kind: "mcp", + url: "https://mcp.linear.app/mcp", + slug: "linear", + }); + }); + + it("resolves the spec URL (not the base URL) for the openapi kind", () => { + expect(pickConnectTarget(payload, "openapi")).toEqual({ + kind: "openapi", + url: "https://example.com/openapi.json", + slug: "example-rest", + }); + }); + + it("resolves the GraphQL endpoint for the graphql kind", () => { + expect(pickConnectTarget(payload, "graphql")).toEqual({ + kind: "graphql", + url: "https://api.linear.app/graphql", + slug: "linear-graphql-api", + }); + }); + + it("skips specless http surfaces for the openapi kind", () => { + const specless = { surfaces: [{ type: "http", url: "https://api.example.com" }] }; + expect(pickConnectTarget(specless, "openapi")).toBeUndefined(); + }); + + it("returns undefined for a malformed document", () => { + expect(pickConnectTarget({ surfaces: "nope" }, "mcp")).toBeUndefined(); + }); +}); + +describe("preset filtering", () => { + const plugins: IntegrationPlugin[] = [ + plugin("mcp", [ + { + id: "linear", + name: "Linear", + summary: "Issues", + icon: "https://integrations.sh/logo/linear.app", + }, + ]), + plugin("openapi", [ + { + id: "stripe", + name: "Stripe", + summary: "Payments", + url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", + icon: "https://integrations.sh/logo/stripe.com", + }, + ]), + ]; + + it("derives preset domains from logo-proxy icons and preset URLs", () => { + const domains = presetDomains(plugins); + expect(domains.has("linear.app")).toBe(true); + expect(domains.has("stripe.com")).toBe(true); + }); + + it("lists only kinds a loaded plugin can add", () => { + expect(availableCatalogKinds(plugins)).toEqual(["mcp", "openapi"]); + }); + + it("hides preset-covered domains and unaddable kinds", () => { + const entries = filterCatalogEntries( + [ + { domain: "linear.app", description: "Issues", kinds: ["mcp"] }, + { domain: "shopify.dev", description: "Commerce", kinds: ["graphql"] }, + { domain: "notion.com", description: "Notes", kinds: ["mcp", "graphql"] }, + ], + { excludeDomains: presetDomains(plugins), availableKinds: availableCatalogKinds(plugins) }, + ); + expect(entries).toEqual([{ domain: "notion.com", description: "Notes", kinds: ["mcp"] }]); + }); +}); diff --git a/packages/react/src/lib/integrations-sh-catalog.ts b/packages/react/src/lib/integrations-sh-catalog.ts new file mode 100644 index 0000000000..24c84822ab --- /dev/null +++ b/packages/react/src/lib/integrations-sh-catalog.ts @@ -0,0 +1,255 @@ +// --------------------------------------------------------------------------- +// integrations.sh catalog client — the connect dialog's long-tail search. +// +// The curated presets stay the featured list; this module reaches the rest of +// the public registry. Search stays server-side (`/api/search`, edge-cached) +// so the client never downloads the full multi-thousand-entry catalog, and the +// per-domain surface document is fetched only when the user picks a result, +// to resolve the URL the add form needs (MCP endpoint, OpenAPI spec URL, or +// GraphQL endpoint). +// --------------------------------------------------------------------------- + +import { useEffect, useRef, useState } from "react"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { getDomain } from "tldts"; +import type { IntegrationPlugin } from "@executor-js/sdk/client"; + +export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; + +/** Registry kinds executor can connect (the registry also lists CLIs). Kind + * strings deliberately match the plugin keys. */ +export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; +export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; + +const isConnectableKind = (kind: string): kind is CatalogKind => + (CONNECTABLE_KINDS as readonly string[]).includes(kind); + +export interface CatalogSearchEntry { + readonly domain: string; + readonly description: string; + readonly kinds: readonly CatalogKind[]; +} + +export const catalogLogoUrl = (domain: string, size: number): string => + `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; + +class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ + readonly message: string; +}> {} + +const fetchCatalogJson = (url: URL): Effect.Effect => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => fetch(url), + catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), + }); + if (!response.ok) { + return yield* new CatalogRequestError({ + message: `Unexpected status ${response.status} from ${url.host}.`, + }); + } + return yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CatalogRequestError({ message: "Response was not valid JSON." }), + }); + }); + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +const SearchResponse = Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + domain: Schema.String, + description: Schema.String, + kinds: Schema.Array(Schema.String), + }), + ), +}); +const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); + +export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => + Option.match(decodeSearchResponse(payload), { + onNone: () => [], + onSome: ({ results }) => + results + .map((entry) => ({ + domain: entry.domain, + description: entry.description, + kinds: entry.kinds.filter(isConnectableKind), + })) + .filter((entry) => entry.kinds.length > 0), + }); + +export const searchCatalog = ( + query: string, + limit = 10, +): Effect.Effect => { + const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(limit)); + return Effect.map(fetchCatalogJson(url), parseCatalogSearch); +}; + +// --------------------------------------------------------------------------- +// Connect-target resolution (per-domain surface document) +// --------------------------------------------------------------------------- + +const SurfaceDocument = Schema.Struct({ + surfaces: Schema.Array( + Schema.Struct({ + type: Schema.String, + slug: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + spec: Schema.optional(Schema.String), + }), + ), +}); +const decodeSurfaceDocument = Schema.decodeUnknownOption(SurfaceDocument); + +type SurfaceRecord = (typeof SurfaceDocument.Type)["surfaces"][number]; + +export interface CatalogConnectTarget { + readonly kind: CatalogKind; + /** What the add form's URL field expects: the MCP endpoint, the OpenAPI + * spec URL, or the GraphQL endpoint. */ + readonly url: string; + /** The registry's stable slug for the surface — seeds the namespace field. */ + readonly slug?: string; +} + +// The surface document's `type` vocabulary: OpenAPI surfaces are `http` (spec +// present ⇒ machine-readable), and `spec` on a GraphQL surface is an SDL +// pointer or the literal "introspection" — the endpoint is what executor adds. +const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undefined => + kind === "mcp" && surface.type === "mcp" + ? surface.url + : kind === "openapi" && surface.type === "http" + ? surface.spec + : kind === "graphql" && surface.type === "graphql" + ? surface.url + : undefined; + +export const pickConnectTarget = ( + payload: unknown, + kind: CatalogKind, +): CatalogConnectTarget | undefined => + Option.match(decodeSurfaceDocument(payload), { + onNone: () => undefined, + onSome: ({ surfaces }) => { + for (const surface of surfaces) { + const url = connectUrlOf(surface, kind); + if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; + } + return undefined; + }, + }); + +/** The connect target for the first of `kinds` (caller's preference order) + * that has a usable locator in the domain's surface document. */ +export const resolveConnectTarget = ( + domain: string, + kinds: readonly CatalogKind[], +): Effect.Effect => { + const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); + return Effect.map(fetchCatalogJson(url), (payload) => { + for (const kind of kinds) { + const target = pickConnectTarget(payload, kind); + if (target) return target; + } + return undefined; + }); +}; + +// --------------------------------------------------------------------------- +// Filtering against the curated presets +// --------------------------------------------------------------------------- + +/** Domains already represented by a loaded plugin's presets, so the catalog + * section doesn't repeat what the preset list above it already shows. */ +export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet => { + const domains = new Set(); + for (const plugin of plugins) { + for (const preset of plugin.presets ?? []) { + for (const candidate of [preset.icon, preset.url]) { + if (!candidate) continue; + const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); + const domain = logoMatch?.[1] ?? getDomain(candidate); + if (domain) domains.add(domain); + } + } + } + return domains; +}; + +/** Kinds this deployment can actually add — the plugin key vocabulary matches + * the catalog kind vocabulary. */ +export const availableCatalogKinds = ( + plugins: readonly IntegrationPlugin[], +): readonly CatalogKind[] => + CONNECTABLE_KINDS.filter((kind) => plugins.some((plugin) => plugin.key === kind)); + +export const filterCatalogEntries = ( + entries: readonly CatalogSearchEntry[], + opts: { + readonly excludeDomains: ReadonlySet; + readonly availableKinds: readonly CatalogKind[]; + }, +): readonly CatalogSearchEntry[] => + entries + .filter((entry) => !opts.excludeDomains.has(entry.domain)) + .map((entry) => ({ + ...entry, + kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), + })) + .filter((entry) => entry.kinds.length > 0); + +// --------------------------------------------------------------------------- +// Hook — debounced search with an in-session response cache +// --------------------------------------------------------------------------- + +const SEARCH_DEBOUNCE_MS = 250; +const MIN_QUERY_LENGTH = 2; +const searchCache = new Map(); + +export interface CatalogSearchState { + readonly entries: readonly CatalogSearchEntry[]; + readonly loading: boolean; +} + +export function useCatalogSearch(rawQuery: string): CatalogSearchState { + const query = rawQuery.trim().toLowerCase(); + const [state, setState] = useState({ entries: [], loading: false }); + const generation = useRef(0); + + useEffect(() => { + const requestId = ++generation.current; + if (query.length < MIN_QUERY_LENGTH) { + setState({ entries: [], loading: false }); + return; + } + const cached = searchCache.get(query); + if (cached) { + setState({ entries: cached, loading: false }); + return; + } + setState((previous) => ({ ...previous, loading: true })); + const timer = setTimeout(() => { + void Effect.runPromiseExit(searchCatalog(query)).then((exit) => { + if (Exit.isSuccess(exit)) searchCache.set(query, exit.value); + if (generation.current !== requestId) return; + // Reachability is best-effort — the presets and URL detection above + // the catalog section keep working without it. + setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + return state; +} diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index f1780b75cb..c60ff622fe 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -2,6 +2,7 @@ import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react" import { Link, useNavigate } from "@tanstack/react-router"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { PlusIcon } from "lucide-react"; import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; @@ -41,6 +42,16 @@ import { integrationPresetIconUrl, } from "../components/integration-favicon"; import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; +import { + availableCatalogKinds, + catalogLogoUrl, + filterCatalogEntries, + presetDomains, + resolveConnectTarget, + useCatalogSearch, + type CatalogKind, + type CatalogSearchEntry, +} from "../lib/integrations-sh-catalog"; import { IntegrationHealthSummary } from "../components/integration-health-summary"; import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; import { Skeleton } from "../components/skeleton"; @@ -327,6 +338,12 @@ type PresetEntry = { pluginLabel: string; }; +const CATALOG_KIND_LABEL: Record = { + mcp: "MCP", + openapi: "OpenAPI", + graphql: "GraphQL", +}; + function PresetGrid(props: { plugins: readonly IntegrationPlugin[]; onPick: () => void; @@ -334,6 +351,7 @@ function PresetGrid(props: { * search/URL input. Empty string disables filtering. */ searchQuery?: string; }) { + const navigate = useNavigate(); const allPresets = useMemo(() => { const entries: PresetEntry[] = []; for (const plugin of props.plugins) { @@ -348,15 +366,66 @@ function PresetGrid(props: { return entries; }, [props.plugins]); + const query = (props.searchQuery ?? "").trim(); + const filtered = useMemo(() => { - const q = (props.searchQuery ?? "").trim().toLowerCase(); + const q = query.toLowerCase(); if (q.length === 0) return allPresets; return allPresets.filter(({ preset, pluginLabel }) => { const corpus = `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); return corpus.includes(q); }); - }, [allPresets, props.searchQuery]); + }, [allPresets, query]); + + // Long-tail search over the public integrations.sh registry, shown under the + // curated presets. Domains a preset already covers stay preset-only. + const catalog = useCatalogSearch(query); + const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]); + const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]); + const catalogEntries = useMemo( + () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }), + [catalog.entries, excludeDomains, availableKinds], + ); + const [resolvingDomain, setResolvingDomain] = useState(null); + const [catalogError, setCatalogError] = useState(null); + + const pickCatalogEntry = useCallback( + async (entry: CatalogSearchEntry) => { + if (resolvingDomain !== null) return; + setResolvingDomain(entry.domain); + setCatalogError(null); + const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds)); + setResolvingDomain(null); + if (Exit.isFailure(exit)) { + setCatalogError( + `Couldn't reach integrations.sh for ${entry.domain}. Paste a URL above instead.`, + ); + return; + } + const target = exit.value; + if (!target) { + setCatalogError( + `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`, + ); + return; + } + trackEvent("integration_add_started", { + plugin_key: target.kind, + via: "catalog", + catalog_domain: entry.domain, + }); + props.onPick(); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey: target.kind }, + search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) }, + }); + }, + [navigate, props, resolvingDomain], + ); + + const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading); if (allPresets.length === 0) return null; @@ -368,9 +437,9 @@ function PresetGrid(props: { * inner area scrolls when the list overflows and shows an empty * state when no presets match. */} - {filtered.length === 0 ? ( + {filtered.length === 0 && !showCatalogSection ? (
-

No matching presets

+

No matching integrations

Paste a URL above to auto-detect, or pick an integration type manually.

@@ -420,8 +489,54 @@ function PresetGrid(props: { ); }) )} + {showCatalogSection && ( + <> +
+

+ {catalog.loading && catalogEntries.length === 0 + ? "searching integrations.sh…" + : "from integrations.sh"} +

+
+ {catalogEntries.map((entry) => ( + + + + ))} + + )} + {catalogError &&

{catalogError}

}
); } From add272a43d5a57b7eb95cefe1cfbbf8423444874 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:32:43 -0700 Subject: [PATCH 2/4] Add e2e scenario for catalog search connect flow --- .../integrations-catalog-search.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 e2e/scenarios/integrations-catalog-search.test.ts diff --git a/e2e/scenarios/integrations-catalog-search.test.ts b/e2e/scenarios/integrations-catalog-search.test.ts new file mode 100644 index 0000000000..04ddbf7ff9 --- /dev/null +++ b/e2e/scenarios/integrations-catalog-search.test.ts @@ -0,0 +1,78 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { clickToReveal, visit } from "../src/surfaces/browser"; + +// The connect dialog's long-tail search goes to the public integrations.sh +// registry from the browser. CI must not depend on the live service, so both +// registry endpoints are fulfilled at the network layer here — including the +// CORS header a real cross-origin browser call needs. +scenario( + "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Stub the integrations.sh registry endpoints", async () => { + await page.route("https://integrations.sh/api/search*", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + results: [ + { + domain: "todoist.com", + name: "todoist.com", + description: "Tasks, projects, and collaboration.", + kinds: ["mcp", "cli"], + url: "https://integrations.sh/todoist.com/", + }, + ], + }, + }), + ); + await page.route("https://integrations.sh/api/todoist.com/surface", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + version: 3, + domain: "todoist.com", + surfaces: [ + { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" }, + { type: "cli", slug: "todoist-cli" }, + ], + }, + }), + ); + }); + + await step("Searching surfaces the catalog row under the presets", async () => { + await visit(page, "/integrations"); + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); + await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); + await dialog.getByText("from integrations.sh").waitFor(); + // The CLI-only surface is not offered; the connectable kind is. + await dialog + .getByRole("button", { name: /todoist\.com/ }) + .getByText("MCP") + .waitFor(); + }); + + await step("Picking the row lands on the MCP add flow, prefilled", async () => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await dialog.getByRole("button", { name: /todoist\.com/ }).click(); + await page.waitForURL(/\/integrations\/add\/mcp/); + const url = new URL(page.url()); + expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); + expect(url.searchParams.get("namespace")).toBe("todoist"); + }); + }); + }), +); From ed3a36ad71291b10015acdc3197a94d255e6df5d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:24:23 -0700 Subject: [PATCH 3/4] Catalog section: skeleton loading rows, neutral labels --- .../integrations-catalog-search.test.ts | 2 +- packages/react/src/pages/integrations.tsx | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/e2e/scenarios/integrations-catalog-search.test.ts b/e2e/scenarios/integrations-catalog-search.test.ts index 04ddbf7ff9..dd2ccc6b15 100644 --- a/e2e/scenarios/integrations-catalog-search.test.ts +++ b/e2e/scenarios/integrations-catalog-search.test.ts @@ -57,7 +57,7 @@ scenario( const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); - await dialog.getByText("from integrations.sh").waitFor(); + await dialog.getByText("More integrations").waitFor(); // The CLI-only surface is not offered; the connectable kind is. await dialog .getByRole("button", { name: /todoist\.com/ }) diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index c60ff622fe..422a097ff2 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -399,7 +399,7 @@ function PresetGrid(props: { setResolvingDomain(null); if (Exit.isFailure(exit)) { setCatalogError( - `Couldn't reach integrations.sh for ${entry.domain}. Paste a URL above instead.`, + `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`, ); return; } @@ -492,11 +492,7 @@ function PresetGrid(props: { {showCatalogSection && ( <>
-

- {catalog.loading && catalogEntries.length === 0 - ? "searching integrations.sh…" - : "from integrations.sh"} -

+

More integrations

{catalogEntries.map((entry) => ( @@ -532,6 +528,18 @@ function PresetGrid(props: { ))} + {catalog.loading && + catalogEntries.length === 0 && + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} )}
From 7a56cca09e96c67906efc0aece92853e3cf9b751 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:46:51 -0700 Subject: [PATCH 4/4] Slot catalog results directly into the preset list --- e2e/scenarios/integrations-catalog-search.test.ts | 1 - packages/react/src/pages/integrations.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/e2e/scenarios/integrations-catalog-search.test.ts b/e2e/scenarios/integrations-catalog-search.test.ts index dd2ccc6b15..4645a3e073 100644 --- a/e2e/scenarios/integrations-catalog-search.test.ts +++ b/e2e/scenarios/integrations-catalog-search.test.ts @@ -57,7 +57,6 @@ scenario( const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); - await dialog.getByText("More integrations").waitFor(); // The CLI-only surface is not offered; the connectable kind is. await dialog .getByRole("button", { name: /todoist\.com/ }) diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 422a097ff2..4b579a2857 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -491,9 +491,6 @@ function PresetGrid(props: { )} {showCatalogSection && ( <> -
-

More integrations

-
{catalogEntries.map((entry) => (