From f03fd74e3486ff6a8fda96fb6b6194ca1bc1d2ab Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 20:05:05 +0000 Subject: [PATCH 01/16] Make the connect picker browsable instead of an 85-row scroll box The Connect dialog listed every preset flat in a 560px dialog with a fixed 224px scroll area. Two providers contribute 48 of those 85 rows as bare service names ("Users", "Directory", "Profile"), so the list was mostly context-free noise and the only way through it was the search box. - Group each multi-service provider into one card that opens into its services, so browsing shows 39 cards instead of 85 rows. - Searching ungroups: typing "outlook" returns the three Outlook services rather than the Microsoft card they were trying to see past. - Add protocol facets (All/OpenAPI/MCP/GraphQL) counting the cards each reveals for the active query. - Lead with the curated `featured` presets so the providers hiding half the library are on the first screen rather than row 12. - Widen the dialog and lay the catalog out in two columns; move the manual add-by-protocol links to the footer. Catalog behavior is pure and unit-tested; the dialog moves out of the page into its own component. Extracts the kind-to-plugin-key map that the picker, the grid, and the favicon resolver each had a private copy of. --- packages/react/src/api/analytics.tsx | 3 + .../components/connect-integration-dialog.tsx | 348 ++++++++++++++++++ .../src/components/integration-favicon.tsx | 11 +- .../react/src/lib/integration-plugin-keys.ts | 13 + packages/react/src/lib/preset-catalog.test.ts | 167 +++++++++ packages/react/src/lib/preset-catalog.ts | 181 +++++++++ packages/react/src/pages/integrations.tsx | 319 +--------------- 7 files changed, 725 insertions(+), 317 deletions(-) create mode 100644 packages/react/src/components/connect-integration-dialog.tsx create mode 100644 packages/react/src/lib/integration-plugin-keys.ts create mode 100644 packages/react/src/lib/preset-catalog.test.ts create mode 100644 packages/react/src/lib/preset-catalog.ts diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 2b68f57ad8..41bffcd198 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -42,6 +42,9 @@ export interface AnalyticsEvents { via: "detect" | "manual" | "preset" | "command_palette"; preset_id?: string; }; + /** A multi-service provider card was opened in the connect picker. `family` + * is a curated catalog value (e.g. "google"), never user-entered text. */ + integration_picker_family_opened: { family: string }; integration_added: { plugin_key: string; integration_slug?: string }; integration_add_cancelled: { plugin_key: string }; integration_removed: { integration_slug: string; success: boolean }; diff --git a/packages/react/src/components/connect-integration-dialog.tsx b/packages/react/src/components/connect-integration-dialog.tsx new file mode 100644 index 0000000000..4b6038914e --- /dev/null +++ b/packages/react/src/components/connect-integration-dialog.tsx @@ -0,0 +1,348 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import { ArrowLeftIcon, SearchIcon } from "lucide-react"; +import type { IntegrationDetectionResult } from "@executor-js/sdk/shared"; +import { useIntegrationPlugins } from "@executor-js/sdk/client"; + +import { detectIntegration } from "../api/atoms"; +import { trackEvent } from "../api/analytics"; +import { Button } from "./button"; +import { Badge } from "./badge"; +import { Input } from "./input"; +import { FilterTabs } from "./filter-tabs"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog"; +import { familyLabel } from "../lib/integration-grouping"; +import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; +import { + familyMemberEntries, + presetCatalogEntries, + presetCatalogItems, + presetTypeFacets, + type PresetEntry, +} from "../lib/preset-catalog"; + +const detectionRank: Record = { + high: 3, + medium: 2, + low: 1, +}; + +const bestDetection = ( + results: readonly IntegrationDetectionResult[], +): IntegrationDetectionResult | undefined => + [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; + +// Heuristic: the input either looks like a URL (auto-detect) or a free-text +// search query (filter the catalog). Anything with a scheme, slash, or +// host-with-TLD is treated as a URL; everything else is search. +const looksLikeUrl = (raw: string): boolean => { + const v = raw.trim(); + if (v.length === 0) return false; + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; + if (v.includes("/")) return true; + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; + return false; +}; + +/** The route a preset card links to: the plugin's add flow, pre-filled. */ +const presetLinkSearch = (entry: PresetEntry): Record => { + const search: Record = { preset: entry.preset.id }; + if (entry.preset.url) search.url = entry.preset.url; + return search; +}; + +const PresetIcon = (props: { src?: string; alt?: string; className?: string }) => + props.src ? ( + {props.alt + ) : ( + + + + ); + +// --------------------------------------------------------------------------- +// Connect dialog — search/detect, protocol facets, and a browsable catalog +// where multi-service providers collapse into one card you can open. +// --------------------------------------------------------------------------- + +export function ConnectIntegrationDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const integrationPlugins = useIntegrationPlugins(); + const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); + const navigate = useNavigate(); + + const [query, setQuery] = useState(""); + const [pluginFilter, setPluginFilter] = useState("all"); + const [openFamily, setOpenFamily] = useState(null); + const [detecting, setDetecting] = useState(false); + const [error, setError] = useState(null); + + const isUrl = looksLikeUrl(query); + const presetSearch = isUrl ? "" : query; + + const entries = useMemo(() => presetCatalogEntries(integrationPlugins), [integrationPlugins]); + const facets = useMemo(() => presetTypeFacets(entries, presetSearch), [entries, presetSearch]); + + // Browsing groups providers; opening one drills into its services. Searching + // or switching protocol leaves the drill-down, so a query always searches the + // whole catalog rather than silently scoping to the open provider. + const items = useMemo(() => { + const filter = { + query: presetSearch, + pluginKey: pluginFilter === "all" ? null : pluginFilter, + }; + if (openFamily === null) return presetCatalogItems(entries, filter); + return familyMemberEntries(entries, openFamily) + .filter((entry) => filter.pluginKey === null || entry.pluginKey === filter.pluginKey) + .map((entry) => ({ type: "single", entry }) as const); + }, [entries, presetSearch, pluginFilter, openFamily]); + + const openFamilyLabel = openFamily === null ? null : familyLabel(openFamily); + + const resultsRef = useRef(null); + const scrollResultsToTop = () => resultsRef.current?.scrollTo({ top: 0 }); + + const closeAndReset = useCallback(() => { + setQuery(""); + setPluginFilter("all"); + setOpenFamily(null); + setError(null); + setDetecting(false); + props.onOpenChange(false); + }, [props]); + + const handleDetect = useCallback(async () => { + const trimmed = query.trim(); + if (!trimmed) return; + setDetecting(true); + setError(null); + // Detection is read-only — it inspects a URL and returns candidates without + // mutating the catalog, so it invalidates nothing. + const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); + if (Exit.isFailure(exit)) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Detection failed. Try adding an integration manually."); + setDetecting(false); + return; + } + const detected = exit.value.length === 0 ? undefined : bestDetection(exit.value); + if (!detected) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Could not detect an integration type from this URL. Try adding manually."); + setDetecting(false); + return; + } + trackEvent("integration_detect_submitted", { + success: true, + detected_kind: detected.kind, + confidence: detected.confidence, + }); + const pluginKey = pluginKeyForIntegrationKind(detected.kind); + if (integrationPlugins.some((p) => p.key === pluginKey)) { + trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); + closeAndReset(); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey }, + search: { url: trimmed, namespace: detected.slug }, + }); + } else { + setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); + setDetecting(false); + } + }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); + + return ( + { + if (!open) closeAndReset(); + else props.onOpenChange(open); + }} + > + + + Connect an integration + Search the library, or paste a URL to auto-detect. + + +
+
+
+ + { + setQuery((e.target as HTMLInputElement).value); + setOpenFamily(null); + setError(null); + scrollResultsToTop(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && isUrl) void handleDetect(); + }} + placeholder="Search or paste a URL…" + disabled={detecting} + className="pl-9" + /> +
+ {isUrl && ( + + )} +
+ {error &&

{error}

} +
+ + ({ + label: facet.label, + value: facet.key ?? "all", + count: facet.count, + }))} + value={pluginFilter} + onChange={(value) => { + setPluginFilter(value); + setOpenFamily(null); + scrollResultsToTop(); + }} + /> + + {openFamilyLabel !== null && ( +
+ + {openFamilyLabel} + + {items.length} {items.length === 1 ? "service" : "services"} + +
+ )} + +
+ {items.length === 0 ? ( +
+

No matching integrations

+

+ Paste a URL above to auto-detect, or add one manually below. +

+
+ ) : ( +
+ {items.map((item) => + item.type === "family" ? ( + + ) : ( + { + trackEvent("integration_add_started", { + plugin_key: item.entry.pluginKey, + via: "preset", + preset_id: item.entry.preset.id, + }); + closeAndReset(); + }} + className="flex items-center gap-3 bg-background px-4 py-3 transition-colors hover:bg-muted" + > + +
+

{item.entry.preset.name}

+

+ {item.entry.preset.summary} +

+
+ + {item.entry.pluginLabel} + + + ), + )} + {items.length % 2 === 1 &&
} +
+ )} +
+ +
+

Not listed? Add manually:

+ {integrationPlugins.map((p) => ( + { + trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); + closeAndReset(); + }} + className="rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted" + > + {p.label} + + ))} +
+ +
+ ); +} diff --git a/packages/react/src/components/integration-favicon.tsx b/packages/react/src/components/integration-favicon.tsx index cfe12df431..98fd0c82ea 100644 --- a/packages/react/src/components/integration-favicon.tsx +++ b/packages/react/src/components/integration-favicon.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; import { getDomain } from "tldts"; +import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; + // --------------------------------------------------------------------------- // IntegrationFavicon — renders a small favicon derived from an integration URL. // Falls back to a neutral icon if the URL is missing or the image fails to load. @@ -28,13 +30,6 @@ export function integrationLocalIconUrl(integrationId: string | undefined): stri return "/favicon-32.png"; } -const KIND_TO_PLUGIN_KEY: Record = { - openapi: "openapi", - mcp: "mcp", - graphql: "graphql", - googleDiscovery: "google", -}; - const normalizeUrl = (url: string | undefined): string | null => { if (!url) return null; try { @@ -104,7 +99,7 @@ export function integrationPresetIconUrl( }, integrationPlugins: readonly IntegrationPlugin[], ): string | null { - const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; + const pluginKey = pluginKeyForIntegrationKind(integration.kind); const plugin = integrationPlugins.find((p) => p.key === pluginKey); const presets = plugin?.presets ?? []; const exactSlugIcon = presets.find((p) => p.defaultSlug === integration.id)?.icon; diff --git a/packages/react/src/lib/integration-plugin-keys.ts b/packages/react/src/lib/integration-plugin-keys.ts new file mode 100644 index 0000000000..71365b05c5 --- /dev/null +++ b/packages/react/src/lib/integration-plugin-keys.ts @@ -0,0 +1,13 @@ +// An integration's stored `kind` mostly matches the plugin key that owns its +// add/edit surfaces, except where a provider ships under a protocol plugin +// (Google Discovery specs are served by the OpenAPI plugin's Google provider). +// The picker, the grid, and the favicon resolver all need the same answer. +const KIND_TO_PLUGIN_KEY: Record = { + openapi: "openapi", + mcp: "mcp", + graphql: "graphql", + googleDiscovery: "google", +}; + +export const pluginKeyForIntegrationKind = (kind: string): string => + KIND_TO_PLUGIN_KEY[kind] ?? kind; diff --git a/packages/react/src/lib/preset-catalog.test.ts b/packages/react/src/lib/preset-catalog.test.ts new file mode 100644 index 0000000000..08f56747de --- /dev/null +++ b/packages/react/src/lib/preset-catalog.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + familyMemberEntries, + presetCatalogItems, + filterPresetEntries, + groupPresetEntriesByFamily, + presetCatalogEntries, + presetTypeFacets, + type PresetSourcePlugin, +} from "./preset-catalog"; + +// A realistic slice of the shipped catalog: OpenAPI carries both standalone +// presets and the two multi-service provider families, MCP repeats some of the +// same vendors under a different protocol, GraphQL contributes one. +const plugins: readonly PresetSourcePlugin[] = [ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "stripe", name: "Stripe", summary: "Payments, subscriptions, and invoices." }, + { + id: "google-gmail", + name: "Gmail", + summary: "Read and send mail.", + family: "google", + featured: true, + }, + { id: "google-drive", name: "Google Drive", summary: "Files and folders.", family: "google" }, + { id: "google-chat", name: "Google Chat", summary: "Spaces and messages.", family: "google" }, + { id: "microsoft-mail", name: "Outlook Mail", summary: "Mail.", family: "microsoft" }, + { + id: "microsoft-calendar", + name: "Outlook Calendar", + summary: "Events.", + family: "microsoft", + }, + { id: "microsoft-users", name: "Users", summary: "Directory users.", family: "microsoft" }, + ], + }, + { + key: "mcp", + label: "MCP", + presets: [ + { id: "linear-mcp", name: "Linear", summary: "Issues and projects.", featured: true }, + { id: "stripe-mcp", name: "Stripe", summary: "Payments over MCP." }, + ], + }, + { + key: "graphql", + label: "GraphQL", + presets: [{ id: "anilist", name: "AniList", summary: "Anime and manga." }], + }, +]; + +const entries = presetCatalogEntries(plugins); + +const titles = (items: ReturnType): readonly string[] => + items.map((item) => (item.type === "family" ? item.label : item.entry.preset.name)); + +describe("preset catalog", () => { + it("collapses a multi-service provider into one card and leaves standalone presets alone", () => { + const items = groupPresetEntriesByFamily(entries); + + // Ten presets, but a browsable six cards: Google and Microsoft each + // collapse to one, in the position of their first member. (Raw grouping + // keeps catalog order; `presetCatalogItems` is what re-sorts for display.) + expect(titles(items)).toEqual(["Stripe", "Google", "Microsoft", "Linear", "Stripe", "AniList"]); + + const google = items.find((item) => item.type === "family" && item.family === "google"); + expect(google?.type === "family" && google.members.length).toBe(3); + }); + + it("keeps a family with a single service as an ordinary card", () => { + const solo = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [{ id: "google-gmail", name: "Gmail", summary: "Mail.", family: "google" }], + }, + ]); + + expect(titles(groupPresetEntriesByFamily(solo))).toEqual(["Gmail"]); + }); + + it("searches inside families so buried services surface as themselves", () => { + // "Outlook Mail" is one of 26 Microsoft services — invisible behind the + // family card until searched for. Matching siblings must NOT re-collapse + // into that same card, or the search returns you to where you started. + expect(titles(presetCatalogItems(entries, { query: "outlook" }))).toEqual([ + "Outlook Mail", + "Outlook Calendar", + ]); + }); + + it("floats curated favourites to the front, provider cards included", () => { + // Linear (MCP) is flagged featured, and Google's services are too, so both + // lead — otherwise the two providers hiding 6 of the 10 presets sort to + // wherever their plugin happened to be registered. + expect(titles(presetCatalogItems(entries, {}))).toEqual([ + "Google", + "Linear", + "Stripe", + "Microsoft", + "Stripe", + "AniList", + ]); + }); + + it("groups providers while browsing and ungroups them while searching", () => { + expect(titles(presetCatalogItems(entries, { query: "google" }))).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + }); + + it("matches the summary and the provider name, not just the preset name", () => { + expect(titles(presetCatalogItems(entries, { query: "payments" }))).toEqual([ + "Stripe", + "Stripe", + ]); + + // Typing the provider name finds its services even though no preset is + // literally called "Google". + const google = filterPresetEntries(entries, { query: "google" }); + expect(google.map((entry) => entry.preset.name)).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + }); + + it("narrows to one protocol and counts the cards each protocol contributes", () => { + expect(presetTypeFacets(entries, "")).toEqual([ + { key: null, label: "All", count: 6 }, + { key: "openapi", label: "OpenAPI", count: 3 }, + { key: "mcp", label: "MCP", count: 2 }, + { key: "graphql", label: "GraphQL", count: 1 }, + ]); + + const mcpOnly = filterPresetEntries(entries, { pluginKey: "mcp" }); + expect(mcpOnly.map((entry) => entry.preset.name)).toEqual(["Linear", "Stripe"]); + }); + + it("composes search with the protocol filter and recounts the facets", () => { + const stripeMcp = filterPresetEntries(entries, { query: "stripe", pluginKey: "mcp" }); + expect(stripeMcp.map((entry) => entry.pluginKey)).toEqual(["mcp"]); + + // Facet counts follow the query so a protocol that can't serve it reads 0. + expect(presetTypeFacets(entries, "outlook")).toEqual([ + { key: null, label: "All", count: 2 }, + { key: "openapi", label: "OpenAPI", count: 2 }, + { key: "mcp", label: "MCP", count: 0 }, + { key: "graphql", label: "GraphQL", count: 0 }, + ]); + }); + + it("drills into a family and lists only that provider's services", () => { + expect(familyMemberEntries(entries, "google").map((entry) => entry.preset.name)).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + expect(familyMemberEntries(entries, "nope")).toEqual([]); + }); +}); diff --git a/packages/react/src/lib/preset-catalog.ts b/packages/react/src/lib/preset-catalog.ts new file mode 100644 index 0000000000..164063c964 --- /dev/null +++ b/packages/react/src/lib/preset-catalog.ts @@ -0,0 +1,181 @@ +import type { IntegrationPreset } from "@executor-js/sdk/client"; + +import { familyLabel } from "./integration-grouping"; + +// --------------------------------------------------------------------------- +// The connect picker's browsable catalog. +// +// Plugins contribute a flat preset list each, which adds up to ~85 entries — +// half of them services of two providers ("Users", "Directory", "Profile" mean +// nothing on their own). Browsing wants those collapsed per provider; searching +// wants them flat, because someone typing "outlook" is looking for the service, +// not the provider card hiding it. +// +// Everything here is pure so the picker's behavior is testable without a DOM. +// --------------------------------------------------------------------------- + +/** The slice of `IntegrationPlugin` the catalog reads. */ +export interface PresetSourcePlugin { + readonly key: string; + readonly label: string; + readonly presets?: readonly IntegrationPreset[]; +} + +export interface PresetEntry { + readonly preset: IntegrationPreset; + readonly pluginKey: string; + readonly pluginLabel: string; +} + +export interface PresetFamilyCard { + readonly type: "family"; + readonly family: string; + readonly label: string; + readonly members: readonly PresetEntry[]; +} + +export interface PresetSingleCard { + readonly type: "single"; + readonly entry: PresetEntry; +} + +export type PresetCatalogItem = PresetFamilyCard | PresetSingleCard; + +export interface PresetTypeFacet { + /** `null` is the "All" facet. */ + readonly key: string | null; + readonly label: string; + /** Cards this protocol contributes for the active query. */ + readonly count: number; +} + +export interface PresetFilter { + readonly query?: string; + /** Plugin key, or `null`/absent for every protocol. */ + readonly pluginKey?: string | null; +} + +/** Flatten every plugin's presets, keeping the curated order plugins ship. */ +export const presetCatalogEntries = ( + plugins: readonly PresetSourcePlugin[], +): readonly PresetEntry[] => + plugins.flatMap((plugin) => + (plugin.presets ?? []).map((preset) => ({ + preset, + pluginKey: plugin.key, + pluginLabel: plugin.label, + })), + ); + +/** The searchable text for one entry: what it is, what it does, whose it is, + * and how it connects. */ +const searchCorpus = (entry: PresetEntry): string => { + const { preset } = entry; + const family = preset.family ? `${preset.family} ${familyLabel(preset.family)}` : ""; + return `${preset.name} ${preset.summary} ${family} ${preset.specFormat ?? ""} ${entry.pluginLabel}`.toLowerCase(); +}; + +export const filterPresetEntries = ( + entries: readonly PresetEntry[], + filter: PresetFilter, +): readonly PresetEntry[] => { + const query = (filter.query ?? "").trim().toLowerCase(); + const pluginKey = filter.pluginKey ?? null; + + return entries.filter((entry) => { + if (pluginKey !== null && entry.pluginKey !== pluginKey) return false; + return query.length === 0 || searchCorpus(entry).includes(query); + }); +}; + +/** Collapse each provider with more than one service into a single card, in the + * position of its first member. A family of one browses better as itself. */ +export const groupPresetEntriesByFamily = ( + entries: readonly PresetEntry[], +): readonly PresetCatalogItem[] => { + const counts = new Map(); + for (const entry of entries) { + const family = entry.preset.family; + if (family) counts.set(family, (counts.get(family) ?? 0) + 1); + } + + const items: PresetCatalogItem[] = []; + const indexByFamily = new Map(); + + for (const entry of entries) { + const family = entry.preset.family; + if (!family || (counts.get(family) ?? 0) < 2) { + items.push({ type: "single", entry }); + continue; + } + + const at = indexByFamily.get(family); + if (at === undefined) { + indexByFamily.set(family, items.length); + items.push({ type: "family", family, label: familyLabel(family), members: [entry] }); + } else { + const card = items[at] as PresetFamilyCard; + items[at] = { ...card, members: [...card.members, entry] }; + } + } + + return items; +}; + +const isFeaturedCard = (item: PresetCatalogItem): boolean => + item.type === "family" + ? item.members.some((member) => member.preset.featured === true) + : item.entry.preset.featured === true; + +/** Curated favourites first, everything else in catalog order. Plugins are + * registered in an order nobody chose for browsing, so without this the two + * provider cards standing in for half the library sort into the middle. */ +const featuredFirst = (items: readonly PresetCatalogItem[]): readonly PresetCatalogItem[] => [ + ...items.filter(isFeaturedCard), + ...items.filter((item) => !isFeaturedCard(item)), +]; + +/** What the picker shows for the current search and protocol filter. + * + * Browsing groups a provider's services behind one card. Searching does NOT: + * someone who typed "outlook" has already told us they want the service, and + * re-collapsing the matches into the Microsoft card they were trying to look + * past hands back the same haystack. */ +export const presetCatalogItems = ( + entries: readonly PresetEntry[], + filter: PresetFilter, +): readonly PresetCatalogItem[] => { + const matching = filterPresetEntries(entries, filter); + const searching = (filter.query ?? "").trim().length > 0; + // Search results keep relevance-neutral catalog order: the query already + // ranked them, and reshuffling by "featured" fights what was typed. + return searching + ? matching.map((entry) => ({ type: "single", entry })) + : featuredFirst(groupPresetEntriesByFamily(matching)); +}; + +/** "All" plus one facet per protocol, each counting the CARDS it contributes + * for the active query — the number of results the chip actually reveals. */ +export const presetTypeFacets = ( + entries: readonly PresetEntry[], + query: string, +): readonly PresetTypeFacet[] => { + const labels = new Map(); + for (const entry of entries) { + if (!labels.has(entry.pluginKey)) labels.set(entry.pluginKey, entry.pluginLabel); + } + + const cardCount = (pluginKey: string | null): number => + presetCatalogItems(entries, { query, pluginKey }).length; + + return [ + { key: null, label: "All", count: cardCount(null) }, + ...[...labels].map(([key, label]) => ({ key, label, count: cardCount(key) })), + ]; +}; + +/** The services behind one provider card, for the drill-down view. */ +export const familyMemberEntries = ( + entries: readonly PresetEntry[], + family: string, +): readonly PresetEntry[] => entries.filter((entry) => entry.preset.family === family); diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index f1780b75cb..e40899ef28 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,29 +1,16 @@ -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 { Suspense, useMemo, useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import * as Exit from "effect/Exit"; import { PlusIcon } from "lucide-react"; -import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; -import { - useIntegrationPlugins, - type IntegrationPlugin, - type IntegrationPreset, -} from "@executor-js/sdk/client"; -import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; +import type { Integration } from "@executor-js/sdk/shared"; +import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; +import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; -import { Badge } from "../components/badge"; -import { Input } from "../components/input"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../components/dialog"; +import { ConnectIntegrationDialog } from "../components/connect-integration-dialog"; import { CardStack, CardStackContent, @@ -31,7 +18,6 @@ import { CardStackEntryActions, CardStackEntryContent, CardStackEntryDescription, - CardStackEntryMedia, CardStackEntryTitle, CardStackHeader, } from "../components/card-stack"; @@ -47,24 +33,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; - -const KIND_TO_PLUGIN_KEY: Record = { - openapi: "openapi", - mcp: "mcp", - graphql: "graphql", - googleDiscovery: "google", -}; - -const detectionRank: Record = { - high: 3, - medium: 2, - low: 1, -}; - -const bestDetection = ( - results: readonly IntegrationDetectionResult[], -): IntegrationDetectionResult | undefined => - [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; +import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; // --------------------------------------------------------------------------- // Page @@ -131,170 +100,11 @@ export function IntegrationsPage() { }) )} - + ); } -// --------------------------------------------------------------------------- -// Connect dialog — URL detection + manual plugin chooser + presets -// --------------------------------------------------------------------------- - -// Heuristic: the input either looks like a URL (auto-detect) or a free-text -// search query (filter the preset list). Anything with a scheme, slash, or -// host-with-TLD is treated as a URL; everything else is search. -const looksLikeUrl = (raw: string): boolean => { - const v = raw.trim(); - if (v.length === 0) return false; - if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; - if (v.includes("/")) return true; - if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; - return false; -}; - -function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { - const integrationPlugins = useIntegrationPlugins(); - const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); - const navigate = useNavigate(); - - const [query, setQuery] = useState(""); - const [detecting, setDetecting] = useState(false); - const [error, setError] = useState(null); - - const isUrl = looksLikeUrl(query); - const presetSearch = isUrl ? "" : query; - - const closeAndReset = useCallback(() => { - setQuery(""); - setError(null); - setDetecting(false); - props.onOpenChange(false); - }, [props]); - - const handleDetect = useCallback(async () => { - const trimmed = query.trim(); - if (!trimmed) return; - setDetecting(true); - setError(null); - // Detection is read-only — it inspects a URL and returns candidates without - // mutating the catalog, so it invalidates nothing. - const exit = await doDetect({ - payload: { url: trimmed }, - reactivityKeys: [], - }); - if (Exit.isFailure(exit)) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Detection failed. Try adding an integration manually."); - setDetecting(false); - return; - } - const results = exit.value; - if (results.length === 0) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - const detected = bestDetection(results); - if (!detected) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - trackEvent("integration_detect_submitted", { - success: true, - detected_kind: detected.kind, - confidence: detected.confidence, - }); - const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; - if (integrationPlugins.some((p) => p.key === pluginKey)) { - trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); - closeAndReset(); - void navigate({ - to: "/{-$orgSlug}/integrations/add/$pluginKey", - params: { pluginKey }, - search: { url: trimmed, namespace: detected.slug }, - }); - } else { - setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); - setDetecting(false); - } - }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); - - return ( - { - if (!open) closeAndReset(); - else props.onOpenChange(open); - }} - > - - - Connect an integration - - Search the preset library, or paste a URL to auto-detect. - - - -
-
-
- { - setQuery((e.target as HTMLInputElement).value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && isUrl) void handleDetect(); - }} - placeholder="Search or paste a URL…" - disabled={detecting} - className="flex-1" - /> - {isUrl && ( - - )} -
- {error &&

{error}

} -
- -
-

Or add manually

-
- {integrationPlugins.map((p) => ( - { - trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeAndReset(); - }} - className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" - > - {p.label} - - ))} -
-
- - -
-
-
- ); -} - // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- @@ -317,115 +127,6 @@ function EmptyIntegrations(props: { onConnect: () => void }) { ); } -// --------------------------------------------------------------------------- -// Preset grid (for inside the Connect dialog) -// --------------------------------------------------------------------------- - -type PresetEntry = { - preset: IntegrationPreset; - pluginKey: string; - pluginLabel: string; -}; - -function PresetGrid(props: { - plugins: readonly IntegrationPlugin[]; - onPick: () => void; - /** Controlled filter query forwarded from the dialog's unified - * search/URL input. Empty string disables filtering. */ - searchQuery?: string; -}) { - const allPresets = useMemo(() => { - const entries: PresetEntry[] = []; - for (const plugin of props.plugins) { - for (const preset of plugin.presets ?? []) { - entries.push({ - preset, - pluginKey: plugin.key, - pluginLabel: plugin.label, - }); - } - } - return entries; - }, [props.plugins]); - - const filtered = useMemo(() => { - const q = (props.searchQuery ?? "").trim().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]); - - if (allPresets.length === 0) return null; - - return ( -
-

Popular integrations

- - {/* Fixed height keeps the dialog stable as the user filters; the - * inner area scrolls when the list overflows and shows an empty - * state when no presets match. */} - - {filtered.length === 0 ? ( -
-

No matching presets

-

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

-
- ) : ( - filtered.map(({ preset, pluginKey, pluginLabel }) => { - const search: Record = { preset: preset.id }; - if (preset.url) search.url = preset.url; - return ( - - { - trackEvent("integration_add_started", { - plugin_key: pluginKey, - via: "preset", - preset_id: preset.id, - }); - props.onPick(); - }} - > - - {preset.icon ? ( - - ) : ( - - - - )} - - - {preset.name} - {preset.summary} - - - {pluginLabel} - - - - ); - }) - )} -
-
-
- ); -} - // --------------------------------------------------------------------------- // Integration grid — flat list of catalog integrations, click-through to detail // --------------------------------------------------------------------------- @@ -441,7 +142,7 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]); const renderEntry = (integration: Integration) => { - const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; + const pluginKey = pluginKeyForIntegrationKind(integration.kind); const plugin = pluginByKind.get(pluginKey); const SummaryComponent = plugin?.summary; const slug = String(integration.slug); From ec504825184ed4540a522af2a5d9f83d4f4dfe73 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 20:09:52 +0000 Subject: [PATCH 02/16] Cover the connect picker's browse, search, and filter contract Asserts the behavior the redesign turns on, in a browser against a real instance: a provider browses as one card with its services hidden, opening it reveals them, back re-collapses, searching returns the services rather than the card, a protocol facet excludes other protocols, and a picked service lands on its add flow with the preset applied. --- .../connect-integration-picker.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 e2e/scenarios/connect-integration-picker.test.ts diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts new file mode 100644 index 0000000000..249fb39743 --- /dev/null +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -0,0 +1,75 @@ +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 picker holds ~85 presets, and two providers contribute roughly half of +// them as bare service names. Browsing has to collapse those; searching has to +// uncollapse them again, or the search hands back the card it was looking past. +scenario( + "Connect picker · providers collapse while browsing and open up on search", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + const search = () => dialog.getByPlaceholder(/Search or paste a URL/); + const googleCard = () => dialog.getByRole("button", { name: /^Google\b.*services$/s }); + + await step("Open the connect picker", async () => { + await visit(page, "/integrations"); + await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); + }); + + await step("A multi-service provider browses as one card, not its services", async () => { + await googleCard().waitFor(); + expect(await googleCard().innerText()).toMatch(/\d+ services/); + // The services behind the card stay behind it. + expect(await dialog.getByRole("link", { name: /^Gmail\b/ }).count()).toBe(0); + }); + + await step("Opening the provider card reveals its services", async () => { + await googleCard().click(); + await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); + await dialog.getByRole("link", { name: /^Google Drive\b/ }).waitFor(); + }); + + await step("Going back returns to the browsable catalog", async () => { + await dialog.getByRole("button", { name: /All integrations/ }).click(); + await googleCard().waitFor(); + expect(await dialog.getByRole("link", { name: /^Gmail\b/ }).count()).toBe(0); + }); + + await step("Searching returns the services themselves, not the provider card", async () => { + await search().fill("outlook"); + await dialog.getByRole("link", { name: /^Outlook Mail\b/ }).waitFor(); + await dialog.getByRole("link", { name: /^Outlook Calendar\b/ }).waitFor(); + expect(await dialog.getByRole("button", { name: /^Microsoft\b.*services$/s }).count()).toBe( + 0, + ); + }); + + await step("A protocol filter narrows the catalog to that protocol", async () => { + await search().fill(""); + await dialog.getByRole("button", { name: /^MCP\s+\d+$/ }).click(); + await dialog.getByRole("link", { name: /^Context7\b/ }).waitFor(); + // Figma is OpenAPI-only, so the MCP facet must not offer it. + expect(await dialog.getByRole("link", { name: /^Figma\b/ }).count()).toBe(0); + }); + + await step("Picking a service opens its add flow with the preset applied", async () => { + await dialog.getByRole("button", { name: /^All\s+\d+$/ }).click(); + await search().fill("gmail"); + await dialog.getByRole("link", { name: /^Gmail\b/ }).click(); + await page.waitForURL(/\/integrations\/add\/openapi/); + await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); + expect(new URL(page.url()).searchParams.get("preset")).toBe("google-gmail"); + }); + }); + }), +); From 11ee2472c51d8ee2f841db313b102f073b8cf998 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 20:10:31 +0000 Subject: [PATCH 03/16] Add a changeset for the connect picker rework --- .changeset/connect-picker-browsable.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/connect-picker-browsable.md diff --git a/.changeset/connect-picker-browsable.md b/.changeset/connect-picker-browsable.md new file mode 100644 index 0000000000..473d696d4f --- /dev/null +++ b/.changeset/connect-picker-browsable.md @@ -0,0 +1,9 @@ +--- +"@executor-js/react": patch +--- + +**The connect picker is browsable instead of an 85-row scroll box** + +Connecting an integration meant scrolling a flat list of every preset every plugin ships — about 85 of them — through a 224px window in a 560px dialog. Two providers contribute roughly half of those rows as bare service names ("Users", "Directory", "Profile", "My Graph Operations"), which say nothing on their own, so the list read as noise and search was the only way through it. + +Providers with more than one service now browse as a single card that opens into its services, which turns 85 rows into 39 cards, and the curated `featured` presets lead, so the two providers standing in for half the library sit on the first screen rather than twelve rows down. Searching deliberately ungroups: typing "outlook" returns Outlook Mail, Calendar, and Contacts rather than the Microsoft card they were trying to see past. Protocol facets (All, OpenAPI, MCP, GraphQL) each count the cards they reveal for the active query. The dialog is wider, lays the catalog out in two columns, and moves the add-by-protocol links to the footer, out of the way of the thing people came for. From 5c76aba436ef33216bbad22dff9219f4a6d17774 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 21:06:39 +0000 Subject: [PATCH 04/16] Move the connect dialog back into the integrations page Keeps the picker's markup next to the page that owns it rather than in a component module of its own. Behavior is unchanged: the catalog logic stays in lib/preset-catalog.ts, and ConnectIntegrationDialog is file-local again. --- .../components/connect-integration-dialog.tsx | 348 ----------------- packages/react/src/pages/integrations.tsx | 358 +++++++++++++++++- 2 files changed, 350 insertions(+), 356 deletions(-) delete mode 100644 packages/react/src/components/connect-integration-dialog.tsx diff --git a/packages/react/src/components/connect-integration-dialog.tsx b/packages/react/src/components/connect-integration-dialog.tsx deleted file mode 100644 index 4b6038914e..0000000000 --- a/packages/react/src/components/connect-integration-dialog.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from "react"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { useAtomSet } from "@effect/atom-react"; -import * as Exit from "effect/Exit"; -import { ArrowLeftIcon, SearchIcon } from "lucide-react"; -import type { IntegrationDetectionResult } from "@executor-js/sdk/shared"; -import { useIntegrationPlugins } from "@executor-js/sdk/client"; - -import { detectIntegration } from "../api/atoms"; -import { trackEvent } from "../api/analytics"; -import { Button } from "./button"; -import { Badge } from "./badge"; -import { Input } from "./input"; -import { FilterTabs } from "./filter-tabs"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog"; -import { familyLabel } from "../lib/integration-grouping"; -import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; -import { - familyMemberEntries, - presetCatalogEntries, - presetCatalogItems, - presetTypeFacets, - type PresetEntry, -} from "../lib/preset-catalog"; - -const detectionRank: Record = { - high: 3, - medium: 2, - low: 1, -}; - -const bestDetection = ( - results: readonly IntegrationDetectionResult[], -): IntegrationDetectionResult | undefined => - [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; - -// Heuristic: the input either looks like a URL (auto-detect) or a free-text -// search query (filter the catalog). Anything with a scheme, slash, or -// host-with-TLD is treated as a URL; everything else is search. -const looksLikeUrl = (raw: string): boolean => { - const v = raw.trim(); - if (v.length === 0) return false; - if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; - if (v.includes("/")) return true; - if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; - return false; -}; - -/** The route a preset card links to: the plugin's add flow, pre-filled. */ -const presetLinkSearch = (entry: PresetEntry): Record => { - const search: Record = { preset: entry.preset.id }; - if (entry.preset.url) search.url = entry.preset.url; - return search; -}; - -const PresetIcon = (props: { src?: string; alt?: string; className?: string }) => - props.src ? ( - {props.alt - ) : ( - - - - ); - -// --------------------------------------------------------------------------- -// Connect dialog — search/detect, protocol facets, and a browsable catalog -// where multi-service providers collapse into one card you can open. -// --------------------------------------------------------------------------- - -export function ConnectIntegrationDialog(props: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const integrationPlugins = useIntegrationPlugins(); - const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); - const navigate = useNavigate(); - - const [query, setQuery] = useState(""); - const [pluginFilter, setPluginFilter] = useState("all"); - const [openFamily, setOpenFamily] = useState(null); - const [detecting, setDetecting] = useState(false); - const [error, setError] = useState(null); - - const isUrl = looksLikeUrl(query); - const presetSearch = isUrl ? "" : query; - - const entries = useMemo(() => presetCatalogEntries(integrationPlugins), [integrationPlugins]); - const facets = useMemo(() => presetTypeFacets(entries, presetSearch), [entries, presetSearch]); - - // Browsing groups providers; opening one drills into its services. Searching - // or switching protocol leaves the drill-down, so a query always searches the - // whole catalog rather than silently scoping to the open provider. - const items = useMemo(() => { - const filter = { - query: presetSearch, - pluginKey: pluginFilter === "all" ? null : pluginFilter, - }; - if (openFamily === null) return presetCatalogItems(entries, filter); - return familyMemberEntries(entries, openFamily) - .filter((entry) => filter.pluginKey === null || entry.pluginKey === filter.pluginKey) - .map((entry) => ({ type: "single", entry }) as const); - }, [entries, presetSearch, pluginFilter, openFamily]); - - const openFamilyLabel = openFamily === null ? null : familyLabel(openFamily); - - const resultsRef = useRef(null); - const scrollResultsToTop = () => resultsRef.current?.scrollTo({ top: 0 }); - - const closeAndReset = useCallback(() => { - setQuery(""); - setPluginFilter("all"); - setOpenFamily(null); - setError(null); - setDetecting(false); - props.onOpenChange(false); - }, [props]); - - const handleDetect = useCallback(async () => { - const trimmed = query.trim(); - if (!trimmed) return; - setDetecting(true); - setError(null); - // Detection is read-only — it inspects a URL and returns candidates without - // mutating the catalog, so it invalidates nothing. - const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); - if (Exit.isFailure(exit)) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Detection failed. Try adding an integration manually."); - setDetecting(false); - return; - } - const detected = exit.value.length === 0 ? undefined : bestDetection(exit.value); - if (!detected) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - trackEvent("integration_detect_submitted", { - success: true, - detected_kind: detected.kind, - confidence: detected.confidence, - }); - const pluginKey = pluginKeyForIntegrationKind(detected.kind); - if (integrationPlugins.some((p) => p.key === pluginKey)) { - trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); - closeAndReset(); - void navigate({ - to: "/{-$orgSlug}/integrations/add/$pluginKey", - params: { pluginKey }, - search: { url: trimmed, namespace: detected.slug }, - }); - } else { - setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); - setDetecting(false); - } - }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); - - return ( - { - if (!open) closeAndReset(); - else props.onOpenChange(open); - }} - > - - - Connect an integration - Search the library, or paste a URL to auto-detect. - - -
-
-
- - { - setQuery((e.target as HTMLInputElement).value); - setOpenFamily(null); - setError(null); - scrollResultsToTop(); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && isUrl) void handleDetect(); - }} - placeholder="Search or paste a URL…" - disabled={detecting} - className="pl-9" - /> -
- {isUrl && ( - - )} -
- {error &&

{error}

} -
- - ({ - label: facet.label, - value: facet.key ?? "all", - count: facet.count, - }))} - value={pluginFilter} - onChange={(value) => { - setPluginFilter(value); - setOpenFamily(null); - scrollResultsToTop(); - }} - /> - - {openFamilyLabel !== null && ( -
- - {openFamilyLabel} - - {items.length} {items.length === 1 ? "service" : "services"} - -
- )} - -
- {items.length === 0 ? ( -
-

No matching integrations

-

- Paste a URL above to auto-detect, or add one manually below. -

-
- ) : ( -
- {items.map((item) => - item.type === "family" ? ( - - ) : ( - { - trackEvent("integration_add_started", { - plugin_key: item.entry.pluginKey, - via: "preset", - preset_id: item.entry.preset.id, - }); - closeAndReset(); - }} - className="flex items-center gap-3 bg-background px-4 py-3 transition-colors hover:bg-muted" - > - -
-

{item.entry.preset.name}

-

- {item.entry.preset.summary} -

-
- - {item.entry.pluginLabel} - - - ), - )} - {items.length % 2 === 1 &&
} -
- )} -
- -
-

Not listed? Add manually:

- {integrationPlugins.map((p) => ( - { - trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeAndReset(); - }} - className="rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted" - > - {p.label} - - ))} -
- -
- ); -} diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index e40899ef28..ad6158fb85 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,16 +1,26 @@ -import { Suspense, useMemo, useState, type ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { Suspense, useCallback, useMemo, useRef, 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 { PlusIcon } from "lucide-react"; -import type { Integration } from "@executor-js/sdk/shared"; +import * as Exit from "effect/Exit"; +import { ArrowLeftIcon, PlusIcon, SearchIcon } from "lucide-react"; +import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; -import { integrationsOptimisticAtom } from "../api/atoms"; +import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; -import { ConnectIntegrationDialog } from "../components/connect-integration-dialog"; +import { Badge } from "../components/badge"; +import { Input } from "../components/input"; +import { FilterTabs } from "../components/filter-tabs"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/dialog"; import { CardStack, CardStackContent, @@ -26,7 +36,11 @@ import { integrationInferredUrl, integrationPresetIconUrl, } from "../components/integration-favicon"; -import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; +import { + familyLabel, + groupIntegrations, + type IntegrationFamilyGroup, +} from "../lib/integration-grouping"; import { IntegrationHealthSummary } from "../components/integration-health-summary"; import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; import { Skeleton } from "../components/skeleton"; @@ -34,6 +48,13 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; +import { + familyMemberEntries, + presetCatalogEntries, + presetCatalogItems, + presetTypeFacets, + type PresetEntry, +} from "../lib/preset-catalog"; // --------------------------------------------------------------------------- // Page @@ -105,6 +126,327 @@ export function IntegrationsPage() { ); } +const detectionRank: Record = { + high: 3, + medium: 2, + low: 1, +}; + +const bestDetection = ( + results: readonly IntegrationDetectionResult[], +): IntegrationDetectionResult | undefined => + [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; + +// Heuristic: the input either looks like a URL (auto-detect) or a free-text +// search query (filter the catalog). Anything with a scheme, slash, or +// host-with-TLD is treated as a URL; everything else is search. +const looksLikeUrl = (raw: string): boolean => { + const v = raw.trim(); + if (v.length === 0) return false; + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; + if (v.includes("/")) return true; + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; + return false; +}; + +/** The route a preset card links to: the plugin's add flow, pre-filled. */ +const presetLinkSearch = (entry: PresetEntry): Record => { + const search: Record = { preset: entry.preset.id }; + if (entry.preset.url) search.url = entry.preset.url; + return search; +}; + +const PresetIcon = (props: { src?: string; alt?: string; className?: string }) => + props.src ? ( + {props.alt + ) : ( + + + + ); + +// --------------------------------------------------------------------------- +// Connect dialog — search/detect, protocol facets, and a browsable catalog +// where multi-service providers collapse into one card you can open. +// --------------------------------------------------------------------------- + +function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { + const integrationPlugins = useIntegrationPlugins(); + const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); + const navigate = useNavigate(); + + const [query, setQuery] = useState(""); + const [pluginFilter, setPluginFilter] = useState("all"); + const [openFamily, setOpenFamily] = useState(null); + const [detecting, setDetecting] = useState(false); + const [error, setError] = useState(null); + + const isUrl = looksLikeUrl(query); + const presetSearch = isUrl ? "" : query; + + const entries = useMemo(() => presetCatalogEntries(integrationPlugins), [integrationPlugins]); + const facets = useMemo(() => presetTypeFacets(entries, presetSearch), [entries, presetSearch]); + + // Browsing groups providers; opening one drills into its services. Searching + // or switching protocol leaves the drill-down, so a query always searches the + // whole catalog rather than silently scoping to the open provider. + const items = useMemo(() => { + const filter = { + query: presetSearch, + pluginKey: pluginFilter === "all" ? null : pluginFilter, + }; + if (openFamily === null) return presetCatalogItems(entries, filter); + return familyMemberEntries(entries, openFamily) + .filter((entry) => filter.pluginKey === null || entry.pluginKey === filter.pluginKey) + .map((entry) => ({ type: "single", entry }) as const); + }, [entries, presetSearch, pluginFilter, openFamily]); + + const openFamilyLabel = openFamily === null ? null : familyLabel(openFamily); + + const resultsRef = useRef(null); + const scrollResultsToTop = () => resultsRef.current?.scrollTo({ top: 0 }); + + const closeAndReset = useCallback(() => { + setQuery(""); + setPluginFilter("all"); + setOpenFamily(null); + setError(null); + setDetecting(false); + props.onOpenChange(false); + }, [props]); + + const handleDetect = useCallback(async () => { + const trimmed = query.trim(); + if (!trimmed) return; + setDetecting(true); + setError(null); + // Detection is read-only — it inspects a URL and returns candidates without + // mutating the catalog, so it invalidates nothing. + const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); + if (Exit.isFailure(exit)) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Detection failed. Try adding an integration manually."); + setDetecting(false); + return; + } + const detected = exit.value.length === 0 ? undefined : bestDetection(exit.value); + if (!detected) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Could not detect an integration type from this URL. Try adding manually."); + setDetecting(false); + return; + } + trackEvent("integration_detect_submitted", { + success: true, + detected_kind: detected.kind, + confidence: detected.confidence, + }); + const pluginKey = pluginKeyForIntegrationKind(detected.kind); + if (integrationPlugins.some((p) => p.key === pluginKey)) { + trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); + closeAndReset(); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey }, + search: { url: trimmed, namespace: detected.slug }, + }); + } else { + setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); + setDetecting(false); + } + }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); + + return ( + { + if (!open) closeAndReset(); + else props.onOpenChange(open); + }} + > + + + Connect an integration + Search the library, or paste a URL to auto-detect. + + +
+
+
+ + { + setQuery((e.target as HTMLInputElement).value); + setOpenFamily(null); + setError(null); + scrollResultsToTop(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && isUrl) void handleDetect(); + }} + placeholder="Search or paste a URL…" + disabled={detecting} + className="pl-9" + /> +
+ {isUrl && ( + + )} +
+ {error &&

{error}

} +
+ + ({ + label: facet.label, + value: facet.key ?? "all", + count: facet.count, + }))} + value={pluginFilter} + onChange={(value) => { + setPluginFilter(value); + setOpenFamily(null); + scrollResultsToTop(); + }} + /> + + {openFamilyLabel !== null && ( +
+ + {openFamilyLabel} + + {items.length} {items.length === 1 ? "service" : "services"} + +
+ )} + +
+ {items.length === 0 ? ( +
+

No matching integrations

+

+ Paste a URL above to auto-detect, or add one manually below. +

+
+ ) : ( +
+ {items.map((item) => + item.type === "family" ? ( + + ) : ( + { + trackEvent("integration_add_started", { + plugin_key: item.entry.pluginKey, + via: "preset", + preset_id: item.entry.preset.id, + }); + closeAndReset(); + }} + className="flex items-center gap-3 bg-background px-4 py-3 transition-colors hover:bg-muted" + > + +
+

{item.entry.preset.name}

+

+ {item.entry.preset.summary} +

+
+ + {item.entry.pluginLabel} + + + ), + )} + {items.length % 2 === 1 &&
} +
+ )} +
+ +
+

Not listed? Add manually:

+ {integrationPlugins.map((p) => ( + { + trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); + closeAndReset(); + }} + className="rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted" + > + {p.label} + + ))} +
+ +
+ ); +} + // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- From c2924229fc4dc203855857ab83dcb538bf4e0b02 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 21:25:18 +0000 Subject: [PATCH 05/16] Tighten the connect picker changeset --- .changeset/connect-picker-browsable.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/connect-picker-browsable.md b/.changeset/connect-picker-browsable.md index 473d696d4f..8a489b59c4 100644 --- a/.changeset/connect-picker-browsable.md +++ b/.changeset/connect-picker-browsable.md @@ -4,6 +4,4 @@ **The connect picker is browsable instead of an 85-row scroll box** -Connecting an integration meant scrolling a flat list of every preset every plugin ships — about 85 of them — through a 224px window in a 560px dialog. Two providers contribute roughly half of those rows as bare service names ("Users", "Directory", "Profile", "My Graph Operations"), which say nothing on their own, so the list read as noise and search was the only way through it. - -Providers with more than one service now browse as a single card that opens into its services, which turns 85 rows into 39 cards, and the curated `featured` presets lead, so the two providers standing in for half the library sit on the first screen rather than twelve rows down. Searching deliberately ungroups: typing "outlook" returns Outlook Mail, Calendar, and Contacts rather than the Microsoft card they were trying to see past. Protocol facets (All, OpenAPI, MCP, GraphQL) each count the cards they reveal for the active query. The dialog is wider, lays the catalog out in two columns, and moves the add-by-protocol links to the footer, out of the way of the thing people came for. +Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. From 2fa2d6c5ff926f7dd42e63ec1a563967aa1ce6fa Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 21:44:30 +0000 Subject: [PATCH 06/16] Name the picker's family event after the connect dialog Analytics already calls this surface the connect dialog (integration_connect_dialog_opened); the new event introduced a second noun for the same thing. --- packages/react/src/api/analytics.tsx | 4 ++-- packages/react/src/pages/integrations.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 41bffcd198..567aa5a4b7 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -42,9 +42,9 @@ export interface AnalyticsEvents { via: "detect" | "manual" | "preset" | "command_palette"; preset_id?: string; }; - /** A multi-service provider card was opened in the connect picker. `family` + /** A multi-service provider card was opened in the connect dialog. `family` * is a curated catalog value (e.g. "google"), never user-entered text. */ - integration_picker_family_opened: { family: string }; + integration_connect_dialog_family_opened: { family: string }; integration_added: { plugin_key: string; integration_slug?: string }; integration_add_cancelled: { plugin_key: string }; integration_removed: { integration_slug: string; success: boolean }; diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index ad6158fb85..fc1f2ae1e3 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -361,7 +361,9 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b onClick={() => { setOpenFamily(item.family); scrollResultsToTop(); - trackEvent("integration_picker_family_opened", { family: item.family }); + trackEvent("integration_connect_dialog_family_opened", { + family: item.family, + }); }} className="h-auto justify-start gap-3 rounded-none bg-background px-4 py-3 text-left font-normal hover:bg-muted" > From 8bed8f7ab45e98eef07bc2b21c952aa99354fd94 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 22:35:43 +0000 Subject: [PATCH 07/16] Unmount the connect dialog on close The dialog stayed mounted, so a URL detection the user walked away from still landed in its state. Abandoning one that then failed left the error banner waiting in the next open, under an empty search box; abandoning one that then succeeded navigated the app to that URL's add flow, whatever the user was doing by then. Both are in the new scenario, which fails on the old shape. ConnectIntegrationDialog is now a wrapper that renders its view only while open, so the query, facet, open provider and in-flight detection die with it instead of being hand-reset. Unmounting cannot stop handleDetect's continuation, which navigates, so a cleanup marks the withdrawn answer unwanted and it returns early. --- .../connect-dialog-abandoned-detection.md | 7 ++ ...connect-dialog-abandoned-detection.test.ts | 97 +++++++++++++++++++ packages/react/src/pages/integrations.tsx | 55 +++++++---- 3 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 .changeset/connect-dialog-abandoned-detection.md create mode 100644 e2e/scenarios/connect-dialog-abandoned-detection.test.ts diff --git a/.changeset/connect-dialog-abandoned-detection.md b/.changeset/connect-dialog-abandoned-detection.md new file mode 100644 index 0000000000..0ba44c7662 --- /dev/null +++ b/.changeset/connect-dialog-abandoned-detection.md @@ -0,0 +1,7 @@ +--- +"@executor-js/react": patch +--- + +**A detection you walk away from no longer follows you** + +The connect dialog stayed mounted after closing, so an in-flight URL detection kept its grip on state the user had left behind. Abandoning a detection that later failed left its error banner waiting in the next open, under an empty search box; abandoning one that later succeeded moved the app to that URL's add flow, whatever the user was doing by then. The dialog now unmounts on close, so the search text, protocol facet, open provider card, and detection all die with it, and a withdrawn detection lands nowhere. diff --git a/e2e/scenarios/connect-dialog-abandoned-detection.test.ts b/e2e/scenarios/connect-dialog-abandoned-detection.test.ts new file mode 100644 index 0000000000..ad42e6ab10 --- /dev/null +++ b/e2e/scenarios/connect-dialog-abandoned-detection.test.ts @@ -0,0 +1,97 @@ +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"; + +const DETECT_ROUTE = "**/integrations/detect"; + +const DETECTED_OPENAPI = JSON.stringify([ + { + kind: "openapi", + confidence: "high", + endpoint: "https://example.com/openapi.json", + name: "Example", + slug: "example", + }, +]); + +// The connect dialog owns in-flight work: pasting a URL asks the server to +// detect what it is. Closing the dialog is the user withdrawing that question, +// so the answer has to land nowhere — not as an error banner waiting in the +// next open, and above all not as a navigation that moves the app under them. +scenario( + "Connect dialog · a detection the user walked away from lands nowhere", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + const connect = page.getByRole("button", { name: "Connect" }); + const search = () => dialog.getByPlaceholder(/Search or paste a URL/); + const catalog = () => dialog.getByRole("button", { name: /^Google\b.*services$/s }); + const detectError = dialog.getByText(/Detection failed|Could not detect/); + + /** Paste a URL, start detecting, and abandon the dialog mid-flight. + * Resolves the held request with `body` and waits for it to land. */ + const abandonDetection = async (status: number, body: string) => { + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route(DETECT_ROUTE, async (route) => { + await held; + await route.fulfill({ status, contentType: "application/json", body }); + }); + + await search().fill("https://example.com/openapi.json"); + await dialog.getByRole("button", { name: "Detect" }).click(); + await dialog.getByRole("button", { name: "Detecting..." }).waitFor(); + + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden" }); + + const answered = page.waitForResponse(DETECT_ROUTE); + release(); + await answered; + await page.unroute(DETECT_ROUTE); + }; + + await step("Open the connect picker", async () => { + await visit(page, "/integrations"); + await clickToReveal(connect, dialog); + await catalog().waitFor(); + }); + + await step("Abandon a detection, then let it fail", async () => { + await abandonDetection(500, JSON.stringify({ _tag: "InternalError" })); + }); + + await step("Reopening offers a clean dialog, not the abandoned failure", async () => { + await clickToReveal(connect, dialog); + await catalog().waitFor(); + expect(await detectError.count(), "the abandoned failure is not waiting here").toBe(0); + expect(await search().inputValue()).toBe(""); + }); + + await step("Abandon a second detection, then let it succeed", async () => { + await abandonDetection(200, DETECTED_OPENAPI); + }); + + await step("The successful answer does not steer the app to an add flow", async () => { + expect(page.url(), "a withdrawn detection must not navigate").not.toMatch( + /\/integrations\/add\//, + ); + // Reopening is the settle point: if the abandoned detection had steered + // the app, this page (and its Connect button) would already be gone. + await clickToReveal(connect, dialog); + await catalog().waitFor(); + expect(page.url()).not.toMatch(/\/integrations\/add\//); + }); + }); + }), +); diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index fc1f2ae1e3..494958d33b 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, 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"; @@ -175,7 +175,21 @@ const PresetIcon = (props: { src?: string; alt?: string; className?: string }) = // where multi-service providers collapse into one card you can open. // --------------------------------------------------------------------------- -function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { +interface ConnectIntegrationDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +/** The connect dialog is self-contained: the search text, the protocol facet, + * the open provider card, and the in-flight URL detection all live in + * `ConnectIntegrationDialogView`, so closing genuinely unmounts them rather + * than hand-resetting a list that grows every time the dialog gains a control. + * The page owns only whether it is open. */ +function ConnectIntegrationDialog(props: ConnectIntegrationDialogProps) { + return props.open ? : null; +} + +function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { const integrationPlugins = useIntegrationPlugins(); const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); const navigate = useNavigate(); @@ -211,14 +225,21 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b const resultsRef = useRef(null); const scrollResultsToTop = () => resultsRef.current?.scrollTo({ top: 0 }); - const closeAndReset = useCallback(() => { - setQuery(""); - setPluginFilter("all"); - setOpenFamily(null); - setError(null); - setDetecting(false); - props.onOpenChange(false); - }, [props]); + // Just ask the page to close. Reopening remounts this view (see + // `ConnectIntegrationDialog`), so there is nothing to hand-reset — the query, + // the facet, and the open provider die with this instance. + const closeDialog = useCallback(() => props.onOpenChange(false), [props]); + + // Unmounting cannot undo one thing: `handleDetect`'s continuation runs to + // completion whatever happens to this view, and it navigates. Closing the + // dialog withdraws the question, so the answer must land nowhere. + const detectionWanted = useRef(true); + useEffect( + () => () => { + detectionWanted.current = false; + }, + [], + ); const handleDetect = useCallback(async () => { const trimmed = query.trim(); @@ -228,6 +249,7 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b // Detection is read-only — it inspects a URL and returns candidates without // mutating the catalog, so it invalidates nothing. const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); + if (!detectionWanted.current) return; if (Exit.isFailure(exit)) { trackEvent("integration_detect_submitted", { success: false }); setError("Detection failed. Try adding an integration manually."); @@ -249,7 +271,7 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b const pluginKey = pluginKeyForIntegrationKind(detected.kind); if (integrationPlugins.some((p) => p.key === pluginKey)) { trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); - closeAndReset(); + closeDialog(); void navigate({ to: "/{-$orgSlug}/integrations/add/$pluginKey", params: { pluginKey }, @@ -259,14 +281,13 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); setDetecting(false); } - }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); + }, [query, doDetect, navigate, integrationPlugins, closeDialog]); return ( { - if (!open) closeAndReset(); - else props.onOpenChange(open); + if (!open) closeDialog(); }} > @@ -402,7 +423,7 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b via: "preset", preset_id: item.entry.preset.id, }); - closeAndReset(); + closeDialog(); }} className="flex items-center gap-3 bg-background px-4 py-3 transition-colors hover:bg-muted" > @@ -436,7 +457,7 @@ function ConnectIntegrationDialog(props: { open: boolean; onOpenChange: (open: b params={{ pluginKey: p.key }} onClick={() => { trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeAndReset(); + closeDialog(); }} className="rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted" > From 7df5670b72354b8fc0983bd304534145ba29f870 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 22:36:02 +0000 Subject: [PATCH 08/16] Apply the connect picker review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One rule for "browses as a provider card": the picker collapsed any family with two or more presets while the integrations grid gated on MULTI_SERVICE_FAMILIES, so a plugin tagging presets with an uncurated family would get a card the rest of the app won't group behind. Both now ask curatedFamily(). No behavior change today — google and microsoft are the only families shipped. The protocol facets stand down inside a provider, where they counted the whole catalog over a list that isn't it and read "All" as selected over 21 of 39 cards. The add-manually links drop the pill shape they shared with those facets, so the same three protocol names stop meaning two things. Also: PresetIcon's unused alt and dead className defaults, a redundant empty-array check bestDetection already makes, and a protocol-filter sentinel no plugin key can collide with. --- .../connect-integration-picker.test.ts | 7 ++- .../react/src/lib/integration-grouping.ts | 12 +++- packages/react/src/lib/preset-catalog.test.ts | 19 +++++++ packages/react/src/lib/preset-catalog.ts | 11 ++-- packages/react/src/pages/integrations.tsx | 57 ++++++++++--------- 5 files changed, 71 insertions(+), 35 deletions(-) diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts index 249fb39743..59550d0f96 100644 --- a/e2e/scenarios/connect-integration-picker.test.ts +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -20,6 +20,7 @@ scenario( const dialog = page.getByRole("dialog", { name: "Connect an integration" }); const search = () => dialog.getByPlaceholder(/Search or paste a URL/); const googleCard = () => dialog.getByRole("button", { name: /^Google\b.*services$/s }); + const allFacet = () => dialog.getByRole("button", { name: /^All\s+\d+$/ }); await step("Open the connect picker", async () => { await visit(page, "/integrations"); @@ -37,11 +38,15 @@ scenario( await googleCard().click(); await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); await dialog.getByRole("link", { name: /^Google Drive\b/ }).waitFor(); + // Inside a provider the protocol facets would advertise catalog-wide + // counts over a list that isn't the catalog, so they stand down. + expect(await allFacet().count()).toBe(0); }); await step("Going back returns to the browsable catalog", async () => { await dialog.getByRole("button", { name: /All integrations/ }).click(); await googleCard().waitFor(); + await allFacet().waitFor(); expect(await dialog.getByRole("link", { name: /^Gmail\b/ }).count()).toBe(0); }); @@ -63,7 +68,7 @@ scenario( }); await step("Picking a service opens its add flow with the preset applied", async () => { - await dialog.getByRole("button", { name: /^All\s+\d+$/ }).click(); + await allFacet().click(); await search().fill("gmail"); await dialog.getByRole("link", { name: /^Gmail\b/ }).click(); await page.waitForURL(/\/integrations\/add\/openapi/); diff --git a/packages/react/src/lib/integration-grouping.ts b/packages/react/src/lib/integration-grouping.ts index 1f0bd05d1c..0c5817b5c8 100644 --- a/packages/react/src/lib/integration-grouping.ts +++ b/packages/react/src/lib/integration-grouping.ts @@ -10,11 +10,17 @@ const FAMILY_LABELS: Record = { export const familyLabel = (family: string): string => FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1); -export const integrationFamily = (integration: Integration): string | null => { - const family = integration.family?.trim(); - return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; +/** The curated family a value names, or `null` when it isn't one we group. + * The connect picker and the integrations grid ask this of different shapes — + * a preset and a stored integration — so the rule itself lives in one place. */ +export const curatedFamily = (family: string | undefined): string | null => { + const trimmed = family?.trim(); + return trimmed && MULTI_SERVICE_FAMILIES.has(trimmed) ? trimmed : null; }; +export const integrationFamily = (integration: Integration): string | null => + curatedFamily(integration.family); + export interface IntegrationFamilyGroup { readonly type: "group"; readonly family: string; diff --git a/packages/react/src/lib/preset-catalog.test.ts b/packages/react/src/lib/preset-catalog.test.ts index 08f56747de..edf55549d6 100644 --- a/packages/react/src/lib/preset-catalog.test.ts +++ b/packages/react/src/lib/preset-catalog.test.ts @@ -71,6 +71,25 @@ describe("preset catalog", () => { expect(google?.type === "family" && google.members.length).toBe(3); }); + it("only collapses the curated families, not any provider that sets `family`", () => { + // `MULTI_SERVICE_FAMILIES` is the one rule for "browses as a provider card", + // shared with the connected-integrations grid. A plugin that tags presets + // with a family nobody curated lists them as themselves rather than + // inventing a card the rest of the app won't group behind. + const uncurated = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "acme-billing", name: "Acme Billing", summary: "Invoices.", family: "acme" }, + { id: "acme-crm", name: "Acme CRM", summary: "Contacts.", family: "acme" }, + ], + }, + ]); + + expect(titles(groupPresetEntriesByFamily(uncurated))).toEqual(["Acme Billing", "Acme CRM"]); + }); + it("keeps a family with a single service as an ordinary card", () => { const solo = presetCatalogEntries([ { diff --git a/packages/react/src/lib/preset-catalog.ts b/packages/react/src/lib/preset-catalog.ts index 164063c964..c6c1a4a771 100644 --- a/packages/react/src/lib/preset-catalog.ts +++ b/packages/react/src/lib/preset-catalog.ts @@ -1,6 +1,6 @@ import type { IntegrationPreset } from "@executor-js/sdk/client"; -import { familyLabel } from "./integration-grouping"; +import { curatedFamily, familyLabel } from "./integration-grouping"; // --------------------------------------------------------------------------- // The connect picker's browsable catalog. @@ -88,14 +88,15 @@ export const filterPresetEntries = ( }); }; -/** Collapse each provider with more than one service into a single card, in the - * position of its first member. A family of one browses better as itself. */ +/** Collapse each curated provider with more than one service into a single + * card, in the position of its first member. A family of one browses better as + * itself, and a family the app doesn't group elsewhere isn't grouped here. */ export const groupPresetEntriesByFamily = ( entries: readonly PresetEntry[], ): readonly PresetCatalogItem[] => { const counts = new Map(); for (const entry of entries) { - const family = entry.preset.family; + const family = curatedFamily(entry.preset.family); if (family) counts.set(family, (counts.get(family) ?? 0) + 1); } @@ -103,7 +104,7 @@ export const groupPresetEntriesByFamily = ( const indexByFamily = new Map(); for (const entry of entries) { - const family = entry.preset.family; + const family = curatedFamily(entry.preset.family); if (!family || (counts.get(family) ?? 0) < 2) { items.push({ type: "single", entry }); continue; diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 494958d33b..91118a21cd 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -156,16 +156,11 @@ const presetLinkSearch = (entry: PresetEntry): Record => { return search; }; -const PresetIcon = (props: { src?: string; alt?: string; className?: string }) => +const PresetIcon = (props: { src?: string; className: string }) => props.src ? ( - {props.alt + ) : ( - + ); @@ -175,6 +170,10 @@ const PresetIcon = (props: { src?: string; alt?: string; className?: string }) = // where multi-service providers collapse into one card you can open. // --------------------------------------------------------------------------- +/** `FilterTabs` needs a string per tab, and the "every protocol" tab is not a + * plugin — a key no plugin can hold keeps the two apart. */ +const ALL_PROTOCOLS = "__all__"; + interface ConnectIntegrationDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; @@ -195,7 +194,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { const navigate = useNavigate(); const [query, setQuery] = useState(""); - const [pluginFilter, setPluginFilter] = useState("all"); + const [pluginFilter, setPluginFilter] = useState(ALL_PROTOCOLS); const [openFamily, setOpenFamily] = useState(null); const [detecting, setDetecting] = useState(false); const [error, setError] = useState(null); @@ -212,7 +211,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { const items = useMemo(() => { const filter = { query: presetSearch, - pluginKey: pluginFilter === "all" ? null : pluginFilter, + pluginKey: pluginFilter === ALL_PROTOCOLS ? null : pluginFilter, }; if (openFamily === null) return presetCatalogItems(entries, filter); return familyMemberEntries(entries, openFamily) @@ -256,7 +255,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { setDetecting(false); return; } - const detected = exit.value.length === 0 ? undefined : bestDetection(exit.value); + const detected = bestDetection(exit.value); if (!detected) { trackEvent("integration_detect_submitted", { success: false }); setError("Could not detect an integration type from this URL. Try adding manually."); @@ -326,19 +325,23 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { {error &&

{error}

} - ({ - label: facet.label, - value: facet.key ?? "all", - count: facet.count, - }))} - value={pluginFilter} - onChange={(value) => { - setPluginFilter(value); - setOpenFamily(null); - scrollResultsToTop(); - }} - /> + {/* Inside a provider the facets would count the whole catalog over a + * list that isn't it, contradicting the "N services" line below. */} + {openFamily === null && ( + ({ + label: facet.label, + value: facet.key ?? ALL_PROTOCOLS, + count: facet.count, + }))} + value={pluginFilter} + onChange={(value) => { + setPluginFilter(value); + setOpenFamily(null); + scrollResultsToTop(); + }} + /> + )} {openFamilyLabel !== null && (
@@ -448,7 +451,9 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { )}
-
+ {/* The chips above filter; these navigate. Same three protocol names, + * so they must not wear the same pill. */} +

Not listed? Add manually:

{integrationPlugins.map((p) => ( {p.label} From 6687f91826b2799fd7075a0eca77cf7d21f22c52 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 22:40:44 +0000 Subject: [PATCH 09/16] Fold the dialog fix into the connect picker changeset One PR, one changelog entry. --- .changeset/connect-dialog-abandoned-detection.md | 7 ------- .changeset/connect-picker-browsable.md | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 .changeset/connect-dialog-abandoned-detection.md diff --git a/.changeset/connect-dialog-abandoned-detection.md b/.changeset/connect-dialog-abandoned-detection.md deleted file mode 100644 index 0ba44c7662..0000000000 --- a/.changeset/connect-dialog-abandoned-detection.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@executor-js/react": patch ---- - -**A detection you walk away from no longer follows you** - -The connect dialog stayed mounted after closing, so an in-flight URL detection kept its grip on state the user had left behind. Abandoning a detection that later failed left its error banner waiting in the next open, under an empty search box; abandoning one that later succeeded moved the app to that URL's add flow, whatever the user was doing by then. The dialog now unmounts on close, so the search text, protocol facet, open provider card, and detection all die with it, and a withdrawn detection lands nowhere. diff --git a/.changeset/connect-picker-browsable.md b/.changeset/connect-picker-browsable.md index 8a489b59c4..60dd86ec57 100644 --- a/.changeset/connect-picker-browsable.md +++ b/.changeset/connect-picker-browsable.md @@ -4,4 +4,4 @@ **The connect picker is browsable instead of an 85-row scroll box** -Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. +Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. Closing it now unmounts it, so a detection you walked away from no longer leaves its error waiting in the next open — or, once it finally answers, navigates you to that URL's add flow. From da8f65ca78acfd21d4e237df4fe2d931d673d756 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 22:45:09 +0000 Subject: [PATCH 10/16] Give the manual add path its weight back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the links plain text to tell them apart from the protocol facets demoted a common way in to a footnote. They are three full buttons in a bordered footer now, each led by a plus and the verb — "Add MCP" cannot be mistaken for the "MCP 13" chip above, and it no longer reads as fine print. --- packages/react/src/pages/integrations.tsx | 35 ++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 91118a21cd..00833e3a55 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -451,23 +451,26 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { )}
- {/* The chips above filter; these navigate. Same three protocol names, - * so they must not wear the same pill. */} -
-

Not listed? Add manually:

+ {/* Pointing your own spec, server, or endpoint at Executor is a first- + * class way in, not a footnote to the library — so this reads as three + * actions. The verb is what keeps "Add MCP" from being mistaken for + * the "MCP 13" facet above. */} +
+

Not in the library?

{integrationPlugins.map((p) => ( - { - trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeDialog(); - }} - className="text-xs font-medium underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground" - > - {p.label} - + ))}
From 43fa41cfc077755654a3eeb66f594fdad7079fd1 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 22:54:08 +0000 Subject: [PATCH 11/16] Drop the rule above the manual add buttons The results box already ends in a border and the buttons draw their own, so the divider was a third line in forty pixels. --- packages/react/src/pages/integrations.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 00833e3a55..1bbb6a643b 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -455,7 +455,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { * class way in, not a footnote to the library — so this reads as three * actions. The verb is what keeps "Add MCP" from being mistaken for * the "MCP 13" facet above. */} -
+

Not in the library?

{integrationPlugins.map((p) => (
)} + {/* A fixed height, not flex-1: sizing to the results made the dialog + * shrink as you filtered, walking the facet chips out from under the + * cursor that was clicking them. */}
{items.length === 0 ? (
From 001d79833b4a63135f2a4d2224f8a0c64bb046ae Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 23:07:17 +0000 Subject: [PATCH 13/16] Stop the picker spilling at narrow widths The results grid draws its separators as gaps over a border-coloured background, so the last row had nothing under it and a short list ended in mid-air. A pixel of bottom padding exposes the same line. On a phone the protocol facets wrapped the last one onto its own row and the three add buttons wrapped onto a second, both pushing the dialog around. The facets scroll sideways now instead of wrapping, and below `sm` the add buttons collapse into one "Add manually" menu holding the same links. The scenario checks both at 390px: every facet chip shares a row, and the desktop buttons are gone in favour of the menu. --- .../connect-integration-picker.test.ts | 18 +++++ packages/react/src/components/filter-tabs.tsx | 6 +- packages/react/src/pages/integrations.tsx | 69 ++++++++++++++----- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts index 59550d0f96..91e3a51923 100644 --- a/e2e/scenarios/connect-integration-picker.test.ts +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -67,7 +67,25 @@ scenario( expect(await dialog.getByRole("link", { name: /^Figma\b/ }).count()).toBe(0); }); + await step("On a phone the filters and the add path stay on one row", async () => { + await page.setViewportSize({ width: 390, height: 844 }); + const facetTops = await dialog + .getByRole("button", { name: /^(All|OpenAPI|MCP|GraphQL)\s+\d+$/ }) + .evaluateAll((chips) => chips.map((chip) => chip.getBoundingClientRect().top)); + expect(facetTops.length).toBeGreaterThan(1); + expect(new Set(facetTops).size, "the facets scroll sideways, they do not wrap").toBe(1); + + // Three protocol buttons would wrap into a second row down here, so + // they collapse into one menu that opens the same links. + await dialog.getByRole("button", { name: "Add manually" }).waitFor(); + expect(await dialog.getByRole("link", { name: "Add OpenAPI" }).isVisible()).toBe(false); + await dialog.getByRole("button", { name: "Add manually" }).click(); + await page.getByRole("menuitem", { name: "Add GraphQL" }).waitFor(); + await page.keyboard.press("Escape"); + }); + await step("Picking a service opens its add flow with the preset applied", async () => { + await page.setViewportSize({ width: 1280, height: 800 }); await allFacet().click(); await search().fill("gmail"); await dialog.getByRole("link", { name: /^Gmail\b/ }).click(); diff --git a/packages/react/src/components/filter-tabs.tsx b/packages/react/src/components/filter-tabs.tsx index b6f9114e0a..b539e13c98 100644 --- a/packages/react/src/components/filter-tabs.tsx +++ b/packages/react/src/components/filter-tabs.tsx @@ -14,15 +14,19 @@ interface FilterTabsProps { tabs: FilterTab[]; value: T; onChange: (value: T) => void; + /** For callers that need the row to behave differently when it runs out of + * width — e.g. scroll instead of wrap in a narrow dialog. */ + className?: string; } export function FilterTabs({ tabs, value, onChange, + className, }: FilterTabsProps) { return ( -
+
{tabs.map((tab) => { const isActive = value === tab.value; return ( diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 6f7fc43e2c..3155c0178a 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -14,6 +14,12 @@ import { PageContainer, PageHeader } from "../components/page"; import { Badge } from "../components/badge"; import { Input } from "../components/input"; import { FilterTabs } from "../components/filter-tabs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../components/dropdown-menu"; import { Dialog, DialogContent, @@ -240,6 +246,22 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { [], ); + /** One "add this protocol by hand" link, worn as a button on a wide dialog + * and as a menu item on a narrow one. */ + const manualAddLink = (plugin: IntegrationPlugin) => ( + { + trackEvent("integration_add_started", { plugin_key: plugin.key, via: "manual" }); + closeDialog(); + }} + > + + Add {plugin.label} + + ); + const handleDetect = useCallback(async () => { const trimmed = query.trim(); if (!trimmed) return; @@ -329,6 +351,9 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { * list that isn't it, contradicting the "N services" line below. */} {openFamily === null && ( ({ label: facet.label, value: facet.key ?? ALL_PROTOCOLS, @@ -379,7 +404,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) {

) : ( -
+
{items.map((item) => item.type === "family" ? ( + ))} +
+ + + - ))} + Add manually + + + + {integrationPlugins.map((plugin) => ( + + {manualAddLink(plugin)} + + ))} + +
From fa72f98546b231f0895ba0bdf2001419bcce0bf7 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Thu, 20 Aug 2026 23:28:07 +0000 Subject: [PATCH 14/16] Size the picker's controls for a thumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured at 390px: the add menu's items and the protocol chips were 32px tall, the search field 36, and every dialog's close button a bare 16x16 icon — that last one misses even the 24px WCAG 2.2 floor, let alone the 44px that WCAG 2.5.5, Apple, and Material all ask of a touch target. Phones now get 44px on the chips, the field, the manual-add trigger, and its menu items; wider viewports keep the tighter mouse-sized versions. The close button gets a 44px hit area at every width, grown with padding and pulled back by an equal negative margin so the glyph does not move. The scenario asserts no control in the dialog or its menu measures under 44px on a phone. --- .../connect-integration-picker.test.ts | 24 +++++++++++++++++++ packages/react/src/components/dialog.tsx | 2 +- packages/react/src/components/filter-tabs.tsx | 4 +++- packages/react/src/pages/integrations.tsx | 6 ++--- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts index 91e3a51923..2fa3bf4191 100644 --- a/e2e/scenarios/connect-integration-picker.test.ts +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -2,6 +2,8 @@ import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; +import type { Locator, Page } from "playwright"; + import { Browser, Target } from "../src/services"; import { clickToReveal, visit } from "../src/surfaces/browser"; @@ -81,7 +83,29 @@ scenario( expect(await dialog.getByRole("link", { name: "Add OpenAPI" }).isVisible()).toBe(false); await dialog.getByRole("button", { name: "Add manually" }).click(); await page.getByRole("menuitem", { name: "Add GraphQL" }).waitFor(); + + // Touch guidelines (WCAG 2.5.5, Apple, Material) put a thumb target at + // 44px; the defaults here land at 32 and the dialog's close at 16. + const undersized = (scope: Locator | Page, selector: string) => + scope.locator(selector).evaluateAll((els) => + els + .map((el) => ({ + label: (el.textContent ?? "").trim().slice(0, 24), + box: el.getBoundingClientRect(), + })) + .filter((t) => t.box.width > 0 && (t.box.height < 44 || t.box.width < 44)) + .map( + (t) => + `${t.label || "(icon)"} ${Math.round(t.box.width)}x${Math.round(t.box.height)}`, + ), + ); + expect(await undersized(page, "[role=menuitem]"), "menu items are thumb-sized").toEqual([]); await page.keyboard.press("Escape"); + await page.getByRole("menuitem", { name: "Add GraphQL" }).waitFor({ state: "detached" }); + expect( + await undersized(dialog, "a[href], button, input"), + "every control in the picker is thumb-sized", + ).toEqual([]); }); await step("Picking a service opens its add flow with the preset applied", async () => { diff --git a/packages/react/src/components/dialog.tsx b/packages/react/src/components/dialog.tsx index 51020ea2d7..d557da3189 100644 --- a/packages/react/src/components/dialog.tsx +++ b/packages/react/src/components/dialog.tsx @@ -83,7 +83,7 @@ function DialogContent({ {showCloseButton && ( Close diff --git a/packages/react/src/components/filter-tabs.tsx b/packages/react/src/components/filter-tabs.tsx index b539e13c98..3bf76f2784 100644 --- a/packages/react/src/components/filter-tabs.tsx +++ b/packages/react/src/components/filter-tabs.tsx @@ -36,7 +36,9 @@ export function FilterTabs({ key={tab.value} onClick={() => onChange(tab.value)} className={cn( - "inline-flex items-center justify-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium shadow-none transition-transform duration-100 active:scale-[0.98]", + // 32px is a fine mouse target and a poor thumb one, so phones get the + // 44px the touch guidelines ask for. + "inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium shadow-none transition-transform duration-100 active:scale-[0.98] sm:min-h-0", isActive ? "border-border bg-background text-foreground" : "border-transparent bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground", diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 3155c0178a..31ebc501c3 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -335,7 +335,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { }} placeholder="Search or paste a URL…" disabled={detecting} - className="pl-9" + className="h-11 pl-9 sm:h-9" /> {isUrl && ( @@ -495,14 +495,14 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { - {integrationPlugins.map((plugin) => ( - + {manualAddLink(plugin)} ))} From dd7bc3bc625041985e44f6fd0334bd5fed32752d Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Fri, 21 Aug 2026 00:14:14 +0000 Subject: [PATCH 15/16] Move the family drill-down into the preset catalog --- packages/react/src/lib/preset-catalog.test.ts | 42 +++++++++++++++++-- packages/react/src/lib/preset-catalog.ts | 17 +++++++- packages/react/src/pages/integrations.tsx | 14 +++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/react/src/lib/preset-catalog.test.ts b/packages/react/src/lib/preset-catalog.test.ts index edf55549d6..ca019776e9 100644 --- a/packages/react/src/lib/preset-catalog.test.ts +++ b/packages/react/src/lib/preset-catalog.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { - familyMemberEntries, + familyDrillDownItems, presetCatalogItems, filterPresetEntries, groupPresetEntriesByFamily, @@ -176,11 +176,47 @@ describe("preset catalog", () => { }); it("drills into a family and lists only that provider's services", () => { - expect(familyMemberEntries(entries, "google").map((entry) => entry.preset.name)).toEqual([ + // Every member browses as itself: the card you opened is the one thing the + // drill-down must not show you again. + expect(titles(familyDrillDownItems(entries, "google", null))).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + expect(familyDrillDownItems(entries, "nope", null)).toEqual([]); + }); + + it("keeps the protocol filter while drilled in, even where a provider spans two", () => { + // No shipped family mixes protocols yet, but the facet chips stay live + // inside the drill-down, so a provider that gained an MCP service must + // still narrow to the chip the user is holding. + const mixed = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "google-gmail", name: "Gmail", summary: "Mail.", family: "google" }, + { id: "google-drive", name: "Google Drive", summary: "Files.", family: "google" }, + ], + }, + { + key: "mcp", + label: "MCP", + presets: [ + { id: "google-chat-mcp", name: "Google Chat", summary: "Spaces.", family: "google" }, + ], + }, + ]); + + expect(titles(familyDrillDownItems(mixed, "google", "openapi"))).toEqual([ + "Gmail", + "Google Drive", + ]); + expect(titles(familyDrillDownItems(mixed, "google", "mcp"))).toEqual(["Google Chat"]); + expect(titles(familyDrillDownItems(mixed, "google", null))).toEqual([ "Gmail", "Google Drive", "Google Chat", ]); - expect(familyMemberEntries(entries, "nope")).toEqual([]); }); }); diff --git a/packages/react/src/lib/preset-catalog.ts b/packages/react/src/lib/preset-catalog.ts index c6c1a4a771..a3d634b4e1 100644 --- a/packages/react/src/lib/preset-catalog.ts +++ b/packages/react/src/lib/preset-catalog.ts @@ -176,7 +176,22 @@ export const presetTypeFacets = ( }; /** The services behind one provider card, for the drill-down view. */ -export const familyMemberEntries = ( +const familyMemberEntries = ( entries: readonly PresetEntry[], family: string, ): readonly PresetEntry[] => entries.filter((entry) => entry.preset.family === family); + +/** What the picker shows once a provider card is open: its services, each as + * itself, narrowed by the protocol chip still on screen. + * + * There is no query here on purpose. Typing exits the drill-down, because a + * search scoped to the open provider would quietly hide the rest of the + * catalog from someone who asked it a question. */ +export const familyDrillDownItems = ( + entries: readonly PresetEntry[], + family: string, + pluginKey: string | null, +): readonly PresetCatalogItem[] => + familyMemberEntries(entries, family) + .filter((entry) => pluginKey === null || entry.pluginKey === pluginKey) + .map((entry) => ({ type: "single", entry })); diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 31ebc501c3..3172dade91 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -55,7 +55,7 @@ import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; import { - familyMemberEntries, + familyDrillDownItems, presetCatalogEntries, presetCatalogItems, presetTypeFacets, @@ -215,14 +215,10 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { // or switching protocol leaves the drill-down, so a query always searches the // whole catalog rather than silently scoping to the open provider. const items = useMemo(() => { - const filter = { - query: presetSearch, - pluginKey: pluginFilter === ALL_PROTOCOLS ? null : pluginFilter, - }; - if (openFamily === null) return presetCatalogItems(entries, filter); - return familyMemberEntries(entries, openFamily) - .filter((entry) => filter.pluginKey === null || entry.pluginKey === filter.pluginKey) - .map((entry) => ({ type: "single", entry }) as const); + const pluginKey = pluginFilter === ALL_PROTOCOLS ? null : pluginFilter; + return openFamily === null + ? presetCatalogItems(entries, { query: presetSearch, pluginKey }) + : familyDrillDownItems(entries, openFamily, pluginKey); }, [entries, presetSearch, pluginFilter, openFamily]); const openFamilyLabel = openFamily === null ? null : familyLabel(openFamily); From 6284f2fad6f5c602264e2cfc81123ef78271d346 Mon Sep 17 00:00:00 2001 From: Nick Wylnko Date: Fri, 21 Aug 2026 19:48:44 +0000 Subject: [PATCH 16/16] Say "add" once above the manual add buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three buttons each read "+ Add " under a lead-in already asking "Not in the library?" — one idea stated four times. The lead-in now carries the verb ("Not in the library? Add your own") and each button carries only the format, which still keeps a bare "MCP" from reading as the "MCP 13" facet above it. The plus goes too: these open a form rather than creating anything inline, so it was decoration. Co-Authored-By: Claude Opus 5 --- .changeset/connect-picker-browsable.md | 2 +- .../connect-integration-picker.test.ts | 31 +++++++++++++++++-- packages/react/src/pages/integrations.tsx | 17 +++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.changeset/connect-picker-browsable.md b/.changeset/connect-picker-browsable.md index 60dd86ec57..f095201105 100644 --- a/.changeset/connect-picker-browsable.md +++ b/.changeset/connect-picker-browsable.md @@ -4,4 +4,4 @@ **The connect picker is browsable instead of an 85-row scroll box** -Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. Closing it now unmounts it, so a detection you walked away from no longer leaves its error waiting in the next open — or, once it finally answers, navigates you to that URL's add flow. +Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. Adding your own spec by hand sits under the catalog as "Not in the library? Add your own" with one button per format, rather than repeating "Add" and a plus icon across all three. Closing it now unmounts it, so a detection you walked away from no longer leaves its error waiting in the next open — or, once it finally answers, navigates you to that URL's add flow. diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts index 2fa3bf4191..ad34f7fec8 100644 --- a/e2e/scenarios/connect-integration-picker.test.ts +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -69,6 +69,27 @@ scenario( expect(await dialog.getByRole("link", { name: /^Figma\b/ }).count()).toBe(0); }); + // "Add OpenAPI / Add MCP / Add GraphQL" spent a verb and a plus icon on + // each of three buttons for one idea. The lead-in carries the verb once + // and the buttons carry only the format they add. The plus is gone too: + // these open a form, they do not create anything inline. + await step("The manual add path spells out the verb once", async () => { + await dialog.getByText(/Not in the library\? Add your own/).waitFor(); + for (const format of ["OpenAPI", "MCP", "GraphQL"]) { + await dialog.getByRole("link", { name: format, exact: true }).waitFor(); + } + expect( + await dialog.getByRole("link", { name: /^Add (OpenAPI|MCP|GraphQL)$/ }).count(), + "the verb is not repeated per button", + ).toBe(0); + expect( + await dialog + .getByRole("link", { name: /^(OpenAPI|MCP|GraphQL)$/ }) + .evaluateAll((links) => links.filter((link) => link.querySelector("svg")).length), + "the manual add links carry no icon", + ).toBe(0); + }); + await step("On a phone the filters and the add path stay on one row", async () => { await page.setViewportSize({ width: 390, height: 844 }); const facetTops = await dialog @@ -80,9 +101,11 @@ scenario( // Three protocol buttons would wrap into a second row down here, so // they collapse into one menu that opens the same links. await dialog.getByRole("button", { name: "Add manually" }).waitFor(); - expect(await dialog.getByRole("link", { name: "Add OpenAPI" }).isVisible()).toBe(false); + expect(await dialog.getByRole("link", { name: "OpenAPI", exact: true }).isVisible()).toBe( + false, + ); await dialog.getByRole("button", { name: "Add manually" }).click(); - await page.getByRole("menuitem", { name: "Add GraphQL" }).waitFor(); + await page.getByRole("menuitem", { name: "GraphQL", exact: true }).waitFor(); // Touch guidelines (WCAG 2.5.5, Apple, Material) put a thumb target at // 44px; the defaults here land at 32 and the dialog's close at 16. @@ -101,7 +124,9 @@ scenario( ); expect(await undersized(page, "[role=menuitem]"), "menu items are thumb-sized").toEqual([]); await page.keyboard.press("Escape"); - await page.getByRole("menuitem", { name: "Add GraphQL" }).waitFor({ state: "detached" }); + await page + .getByRole("menuitem", { name: "GraphQL", exact: true }) + .waitFor({ state: "detached" }); expect( await undersized(dialog, "a[href], button, input"), "every control in the picker is thumb-sized", diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 3172dade91..38da4e5a6e 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -253,8 +253,7 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { closeDialog(); }} > - - Add {plugin.label} + {plugin.label} ); @@ -477,11 +476,16 @@ function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { {/* Pointing your own spec, server, or endpoint at Executor is a first- * class way in, not a footnote to the library — so this reads as real - * actions. The verb is what keeps "Add MCP" from being mistaken for - * the "MCP 13" facet above. Narrow enough and one row of them would - * wrap into two, so there they become a single menu instead. */} + * actions. The lead-in carries the verb for all three, which is what + * keeps a bare "MCP" from reading as the "MCP 13" facet above; a verb + * and a plus on each button restated one idea three times. These link + * to a form rather than creating anything inline, so no plus earns its + * place here. Narrow enough and one row of them would wrap into two, + * so there the whole sentence collapses into a single menu instead. */}
-

Not in the library?

+

+ Not in the library? Add your own +

{integrationPlugins.map((plugin) => (