History

Unify integration icons on one component

Rhys Sullivan committed 2 months ago

Commit 4bd5fa11 parent b81476d8 files · +151 −171Browse files

Unified · Split

8 files
  1. e2e/local/codex-plugins.test.ts +23 −1
  2. packages/plugins/mcp/src/react/CodexPluginAdd.tsx +6 −4
  3. packages/plugins/mcp/src/sdk/presets.ts +1 −1
  4. packages/react/src/components/command-palette.tsx +4 −11
  5. packages/react/src/components/integration-favicon.test.tsx +20 −0
  6. packages/react/src/components/integration-favicon.tsx +61 −9
  7. packages/react/src/components/preset-icon.tsx +0 −84
  8. packages/react/src/pages/integration-browse.tsx +36 −61
e2e/local/codex-plugins.test.ts+23−1
@@ -28,7 +28,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 27 unmodified lines
2828 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
2929
3030 import { scenario } from "../src/scenario";
31−import { Cli, RunDir } from "../src/services";
31+import { Browser, Cli, RunDir, Target } from "../src/services";
3232 import { withLocalServer } from "./local-server";
3333
3434 const api = composePluginApi([mcpHttpPlugin()] as const);
@@ -109,7 +109,10 @@ scenario( · 74 unmodified lines
109109 { timeout: 300_000 },
110110 Effect.gen(function* () {
111111 const cli = yield* Cli;
112+ const browser = yield* Browser;
112113 const runDir = yield* RunDir;
114+ const target = yield* Target;
115+ const identity = yield* target.newIdentity();
113116 const codexHome = makeCodexHome();
114117
115118 yield* withLocalServer(
@@ -142,6 +145,25 @@ scenario( · 26 unmodified lines
142145 CODEX_HOME: codexHome,
143146 });
144147 }
148+
149+ yield* browser.session(identity, async ({ page, step }) => {
150+ await step("Find Computer Use on the integration browse page", async () => {
151+ await page.goto(server.url, { waitUntil: "domcontentloaded" });
152+ await page.getByRole("link", { name: "Add integration" }).click();
153+ await page
154+ .getByRole("textbox", { name: "Search integrations, or paste a URL" })
155+ .fill("Computer Use");
156+
157+ const card = page.getByTestId("preset-mcp-codex-computer-use");
158+ await card.getByText("Computer Use MCP").waitFor({ timeout: 30_000 });
159+ await card
160+ .locator(
161+ 'img[src="https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png"]',
162+ )
163+ .waitFor({ timeout: 30_000 });
164+ });
165+ });
166+
145167 // Curated entries carry the app-server bridge recipe: `codex
146168 // app-server`, the server name the bridge calls tools on, and the
147169 // preset it came from — that last one is what lets a macOS refusal
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+6−4
@@ -5,6 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines
55
66 import { Button } from "@executor-js/react/components/button";
77 import { FloatActions } from "@executor-js/react/components/float-actions";
8+import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
89 import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
910 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
1011 import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
@@ -150,10 +151,11 @@ export default function CodexPluginAdd(props: { · 139 unmodified lines
150151 {/* The plugin's own icon comes from the local Codex install; without
151152 one the card still identifies its provider rather than showing a
152153 gap, which matters most on the machines that have no install. */}
153− <img
154− src={plugin.icon ?? plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"}
155− alt=""
156− className="size-16 rounded-2xl"
154+ <IntegrationFavicon
155+ icon={plugin.icon}
156+ fallbackSrc={plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"}
157+ size={64}
158+ className="rounded-2xl"
157159 />
158160 <div className="min-w-0">
159161 <div className="flex items-baseline gap-2">
packages/plugins/mcp/src/sdk/presets.ts+1−1
@@ -37,7 +37,7 @@ export type McpPreset = McpRemotePreset | McpStdioPreset; · 36 unmodified lines
3737 // use", …). `command` is deliberately empty: the real spawn recipe is
3838 // machine-specific and comes from the server-side scanner
3939 // (`codex-plugins.ts`); picking one of these opens the focused Codex add
40−// screen. The icon uses the `executor:` scheme (see preset-icon.tsx): the
40+// screen. The icon uses the `executor:` scheme (see integration-favicon.tsx): the
4141 // plugin's own icon is a machine-local file, so it is served by the local API
4242 // and resolved with the auth header — a static URL cannot reach it.
4343 const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({
packages/react/src/components/command-palette.tsx+4−11
@@ -6,7 +6,6 @@ import { PlusIcon } from "lucide-react"; · 5 unmodified lines
66 import { trackEvent } from "../api/analytics";
77 import type { Integration } from "@executor-js/sdk/shared";
88 import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon";
9−import { PresetIcon } from "./preset-icon";
109 import { integrationsOptimisticAtom } from "../api/atoms";
1110 import { useIntegrationPlugins } from "@executor-js/sdk/client";
1211 import {
@@ -196,16 +195,10 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 183 unmodified lines
196195 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
197196 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
198197 >
199− <PresetIcon
200− {...(e.presetIcon ? { icon: e.presetIcon } : {})}
201− {...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})}
202− className="size-4 shrink-0 object-contain"
203− fallback={
204− <span
205− aria-hidden
206− className="size-4 shrink-0 rounded-sm bg-muted-foreground/20"
207− />
208− }
198+ <IntegrationFavicon
199+ icon={e.presetIcon}
200+ fallbackSrc={e.presetFallbackIcon}
201+ size={16}
209202 />
210203 <span className="flex-1 truncate">{e.presetName}</span>
211204 <CommandShortcut>{e.pluginLabel}</CommandShortcut>
packages/react/src/components/integration-favicon.test.tsx+20−0
@@ -33,6 +33,26 @@ describe("IntegrationFavicon", () => { · 32 unmodified lines
3333 expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull();
3434 });
3535
36+ it("tries the explicit fallback before the integration-derived favicon", () => {
37+ const icon = "executor:/mcp/codex-plugins/computer-use/icon";
38+ const fallbackSrc = "https://integrations.sh/logo/openai.com";
39+ const url = "https://example.com/mcp";
40+
41+ expect(integrationFaviconSrc({ icon, fallbackSrc, url, size: 20 })).toBe(icon);
42+ expect(integrationFaviconSrc({ icon, fallbackSrc, url, size: 20, failedSrcs: [icon] })).toBe(
43+ fallbackSrc,
44+ );
45+ expect(
46+ integrationFaviconSrc({
47+ icon,
48+ fallbackSrc,
49+ url,
50+ size: 20,
51+ failedSrcs: [icon, fallbackSrc],
52+ }),
53+ ).toBe("https://integrations.sh/logo/example.com?sz=40");
54+ });
55+
3656 it("uses the Executor favicon for the built-in executor integration", () => {
3757 expect(integrationLocalIconUrl("executor")).toBe("/favicon-32.png");
3858 expect(integrationLocalIconUrl("openapi")).toBeNull();
packages/react/src/components/integration-favicon.tsx+61−9
@@ -1,9 +1,47 @@
11 import { BoxIcon } from "lucide-react";
22 import { useEffect, useState } from "react";
33 import type { IntegrationPlugin } from "@executor-js/sdk/client";
4+import * as Effect from "effect/Effect";
5+import * as Option from "effect/Option";
6+import * as Schema from "effect/Schema";
47 import { getDomain } from "tldts";
58
6−import { EXECUTOR_ICON_SCHEME, resolveExecutorIcon } from "./preset-icon";
9+import {
10+ getExecutorApiBaseUrl,
11+ getExecutorServerAuthorizationHeader,
12+} from "../api/server-connection";
13+import { cn } from "../lib/utils";
14+
15+// ---------------------------------------------------------------------------
16+
17+const EXECUTOR_ICON_SCHEME = "executor:";
18+
19+const IconResponse = Schema.Struct({ icon: Schema.NullOr(Schema.String) });
20+const decodeIconResponse = Schema.decodeUnknownOption(IconResponse);
21+
22+const resolvedExecutorIcons = new Map<string, Promise<string | null>>();
23+
24+/** Resolves an authenticated local icon path once per browser session. */
25+const resolveExecutorIcon = (path: string): Promise<string | null> => {
26+ const cached = resolvedExecutorIcons.get(path);
27+ if (cached) return cached;
28+ const authorization = getExecutorServerAuthorizationHeader();
29+ const request = Effect.runPromise(
30+ Effect.tryPromise(async () => {
31+ const response = await fetch(`${getExecutorApiBaseUrl()}${path}`, {
32+ headers: authorization === null ? {} : { authorization },
33+ });
34+ if (!response.ok) return null;
35+ const body: unknown = await response.json();
36+ return Option.match(decodeIconResponse(body), {
37+ onNone: () => null,
38+ onSome: ({ icon }) => icon,
39+ });
40+ }).pipe(Effect.orElseSucceed(() => null)),
41+ );
42+ resolvedExecutorIcons.set(path, request);
43+ return request;
44+};
745
846 // ---------------------------------------------------------------------------
947 // IntegrationFavicon — renders a small favicon derived from an integration URL.
@@ -128,13 +166,15 @@ export function integrationPresetIconUrl( · 118 unmodified lines
128166 }
129167
130168 // Resolution cascade for the rendered favicon: first non-null, non-failed of an
131−// explicit preset icon, the bundled local icon for a known integration id, then
132−// the integrations.sh logo proxy derived from the integration URL (which owns
133−// its own upstream fallbacks). The built-in executor integration has no preset
134−// icon and no URL, so it resolves ONLY through the integrationId branch: callers
135−// that drop integrationId fall through to the neutral BoxIcon placeholder.
169+// explicit preset icon, its explicit fallback image, the bundled local icon for
170+// a known integration id, then the integrations.sh logo proxy derived from the
171+// integration URL (which owns its own upstream fallbacks). The built-in executor
172+// integration has no preset icon and no URL, so it resolves ONLY through the
173+// integrationId branch: callers that drop integrationId fall through to the
174+// neutral BoxIcon placeholder.
136175 export function integrationFaviconSrc(args: {
137176 icon?: string | null;
177+ fallbackSrc?: string | null;
138178 integrationId?: string;
139179 url?: string;
140180 size: number;
@@ -144,6 +184,7 @@ export function integrationFaviconSrc(args: { · 3 unmodified lines
144184 return (
145185 [
146186 args.icon ?? null,
187+ args.fallbackSrc ?? null,
147188 integrationLocalIconUrl(args.integrationId),
148189 integrationFaviconUrl(args.url, args.size),
149190 ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null
@@ -152,21 +193,32 @@ export function integrationFaviconSrc(args: { · 2 unmodified lines
152193
153194 export function IntegrationFavicon({
154195 icon,
196+ fallbackSrc,
155197 integrationId,
156198 url,
157199 size = 16,
200+ className,
158201 }: {
159202 icon?: string | null;
203+ fallbackSrc?: string | null;
160204 integrationId?: string;
161205 url?: string;
162206 size?: number;
207+ className?: string;
163208 }) {
164209 const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]);
165210 // `executor:`-scheme icons (served by the local API behind the bearer gate,
166211 // e.g. a Codex plugin's own icon) resolve asynchronously to a data URI; a
167212 // null resolution marks the candidate failed so the cascade continues.
168213 const [executorIcons, setExecutorIcons] = useState<Readonly<Record<string, string>>>({});
169− const cascadeSrc = integrationFaviconSrc({ icon, integrationId, url, size, failedSrcs });
214+ const cascadeSrc = integrationFaviconSrc({
215+ icon,
216+ fallbackSrc,
217+ integrationId,
218+ url,
219+ size,
220+ failedSrcs,
221+ });
170222 const isExecutorSrc = cascadeSrc?.startsWith(EXECUTOR_ICON_SCHEME) ?? false;
171223
172224 useEffect(() => {
@@ -194,7 +246,7 @@ export function IntegrationFavicon({ · 21 unmodified lines
194246 return (
195247 <BoxIcon
196248 aria-hidden
197− className="shrink-0 text-muted-foreground"
249+ className={cn("shrink-0 text-muted-foreground", className)}
198250 style={{ width: size, height: size }}
199251 />
200252 );
@@ -214,7 +266,7 @@ export function IntegrationFavicon({ · 13 unmodified lines
214266 current.includes(failedCandidate) ? current : [...current, failedCandidate],
215267 )
216268 }
217− className="shrink-0 rounded-sm"
269+ className={cn("shrink-0 rounded-sm object-contain", className)}
218270 style={{ width: size, height: size }}
219271 />
220272 );
packages/react/src/components/preset-icon.tsx+0−84
@@ -1,84 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Preset icon — a preset's `icon` is usually a plain image URL. A preset whose
3−// icon can only come from the local server (e.g. a Codex plugin's own icon,
4−// read off this machine at runtime) uses the `executor:` scheme instead: the
5−// path after the scheme is fetched from the executor API with the server auth
6−// header and must answer `{ icon: string | null }` (a data URI). The
7−// indirection exists because local auth is deliberately bearer-header-only —
8−// an <img> cannot authenticate itself.
9−// ---------------------------------------------------------------------------
10−
11−import { useEffect, useState } from "react";
12−import * as Effect from "effect/Effect";
13−import * as Option from "effect/Option";
14−import * as Schema from "effect/Schema";
15−
16−import {
17− getExecutorApiBaseUrl,
18− getExecutorServerAuthorizationHeader,
19−} from "../api/server-connection";
20−
21−export const EXECUTOR_ICON_SCHEME = "executor:";
22−
23−const IconResponse = Schema.Struct({ icon: Schema.NullOr(Schema.String) });
24−const decodeIconResponse = Schema.decodeUnknownOption(IconResponse);
25−
26−const resolved = new Map<string, Promise<string | null>>();
27−
28−/** Resolve an `executor:`-scheme icon path to its data URI (or null). Shared
29− * by every surface that renders preset icons, including IntegrationFavicon's
30− * cascade. Results are memoized per path for the session; any failure is
31− * "no icon", never an error. */
32−export const resolveExecutorIcon = (path: string): Promise<string | null> => {
33− const cached = resolved.get(path);
34− if (cached) return cached;
35− const authorization = getExecutorServerAuthorizationHeader();
36− const request = Effect.runPromise(
37− Effect.tryPromise(async () => {
38− const response = await fetch(`${getExecutorApiBaseUrl()}${path}`, {
39− headers: authorization === null ? {} : { authorization },
40− });
41− if (!response.ok) return null;
42− const body: unknown = await response.json();
43− return Option.match(decodeIconResponse(body), {
44− onNone: () => null,
45− onSome: ({ icon }) => icon,
46− });
47− }).pipe(Effect.orElseSucceed(() => null)),
48− );
49− resolved.set(path, request);
50− return request;
51−};
52−
53−/** Renders a preset icon, resolving `executor:` scheme icons through the
54− * authenticated API.
55− *
56− * `fallbackSrc` is a plain image URL to use when the machine-local icon is
57− * unavailable — a Codex plugin card still shows its provider's mark on a
58− * machine where Codex is not installed, which is exactly when the card is
59− * most in need of explaining itself. `fallback` is the last resort, for when
60− * there is no image of any kind. */
61−export function PresetIcon(props: {
62− readonly icon?: string;
63− readonly fallbackSrc?: string;
64− readonly className?: string;
65− readonly fallback?: React.ReactNode;
66−}) {
67− const isExecutorIcon = props.icon?.startsWith(EXECUTOR_ICON_SCHEME) ?? false;
68− const [fetched, setFetched] = useState<string | null>(null);
69−
70− useEffect(() => {
71− if (!isExecutorIcon || props.icon === undefined) return;
72− let live = true;
73− void resolveExecutorIcon(props.icon.slice(EXECUTOR_ICON_SCHEME.length)).then((icon) => {
74− if (live) setFetched(icon);
75− });
76− return () => {
77− live = false;
78− };
79− }, [isExecutorIcon, props.icon]);
80−
81− const src = (isExecutorIcon ? fetched : (props.icon ?? null)) ?? props.fallbackSrc ?? null;
82− if (src === null) return <>{props.fallback ?? null}</>;
83− return <img src={src} alt="" className={props.className} loading="lazy" />;
84−}
packages/react/src/pages/integration-browse.tsx+36−61
@@ -21,7 +21,7 @@ import { Button } from "../components/button"; · 20 unmodified lines
2121 import { Input } from "../components/input";
2222 import { PageHeader } from "../components/page";
2323 import {
24− integrationFaviconUrl,
24+ IntegrationFavicon,
2525 integrationInferredUrl,
2626 integrationPresetIconUrl,
2727 } from "../components/integration-favicon";
@@ -29,7 +29,6 @@ import { Skeleton } from "../components/skeleton"; · 1 unmodified line
2929 import { useExecutorDocumentTitle } from "../lib/document-title";
3030 import {
3131 availableCatalogKinds,
32− catalogLogoUrl,
3332 filterCatalogEntries,
3433 resolveConnectTarget,
3534 useCatalogBrowse,
@@ -168,7 +167,10 @@ interface Row { · 132 unmodified lines
168167 * distinguishable. Omitted when the title is already the domain. */
169168 readonly domain?: string;
170169 readonly description?: string;
171− readonly iconUrl?: string;
170+ readonly icon?: string;
171+ readonly fallbackSrc?: string;
172+ readonly integrationId?: string;
173+ readonly url?: string;
172174 readonly onSelect: () => void;
173175 readonly added: boolean;
174176 /** Namespace of the installed integration this row matched — the View
@@ -214,36 +216,6 @@ function LoadMoreSentinel(props: { readonly onVisible: () => void }) { · 39 unmodified lines
214216 // Row
215217 // ---------------------------------------------------------------------------
216218
217−/** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of
218− * brand marks looks like a bug, so failures fall back to a neutral mark. */
219−function RowIcon(props: { readonly src?: string; readonly alt: string }) {
220− const [failed, setFailed] = useState(false);
221− if (!props.src || failed) {
222− return (
223− <span
224− aria-hidden
225− className="flex size-5 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground"
226− >
227− {props.alt.charAt(0).toUpperCase()}
228− </span>
229− );
230− }
231− return (
232− <img
233− src={props.src}
234− alt=""
235− width={20}
236− height={20}
237− // NOT lazy. The console scrolls inside a nested container, and Chrome's
238− // lazy loading never brings these into view there — every icon sits
239− // pending forever while an eager load of the same URL succeeds instantly.
240− // A 20px favicon is not worth deferring anyway.
241− onError={() => setFailed(true)}
242− className="size-5 object-contain"
243− />
244− );
245−}
246−
247219 function ResultCard(props: { readonly row: Row }) {
248220 const { row } = props;
249221 return (
@@ -253,7 +225,13 @@ function ResultCard(props: { readonly row: Row }) { · 3 unmodified lines
253225 >
254226 <div className="flex items-start justify-between gap-2">
255227 <span className="flex size-8 shrink-0 items-center justify-center">
256− <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} />
228+ <IntegrationFavicon
229+ icon={row.icon}
230+ fallbackSrc={row.fallbackSrc}
231+ integrationId={row.integrationId}
232+ url={row.url}
233+ size={20}
234+ />
257235 </span>
258236 {row.added ? (
259237 // The add happened HERE — the card is the receipt. View jumps to
@@ -725,7 +703,8 @@ export function IntegrationBrowsePage() { · 465 unmodified lines
725703 title,
726704 kindKey,
727705 ...(entry.preset.summary ? { description: entry.preset.summary } : {}),
728− ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),
706+ ...(entry.preset.icon ? { icon: entry.preset.icon } : {}),
707+ ...(entry.preset.fallbackIcon ? { fallbackSrc: entry.preset.fallbackIcon } : {}),
729708 onSelect: () => pickPreset(entry),
730709 // The rendered title is also what the add flow derives the namespace
731710 // from, so the card recognises an add made through it even when the
@@ -774,8 +753,8 @@ export function IntegrationBrowsePage() { · 42 unmodified lines
774753 kindKey: surface.kind,
775754 domain: entry.domain,
776755 ...(description ? { description } : {}),
777− iconUrl:
778− (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10),
756+ ...(known && "icon" in known && known.icon ? { icon: known.icon } : {}),
757+ url: `https://${entry.domain}`,
779758 onSelect: () =>
780759 void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined),
781760 added:
@@ -854,38 +833,34 @@ export function IntegrationBrowsePage() { · 72 unmodified lines
854833 String(row.slug).includes(text)),
855834 )
856835 .map((row): Row => {
857− // The SAME icon cascade the sidebar runs — preset icon by exact
858− // identity, else the logo proxy from the integration's own URL. Two
859− // resolvers for one integration is how the sidebar and the picker
860− // ended up disagreeing about what DeepWiki looks like.
861− const iconUrl =
862− integrationPresetIconUrl(
863− {
864− id: String(row.slug),
865− kind: row.kind,
866− name: row.name,
867− ...(row.displayUrl ? { url: row.displayUrl } : {}),
868− },
869− integrationPlugins,
870− ) ??
871− integrationFaviconUrl(
872− row.displayUrl ??
873− integrationInferredUrl({ id: String(row.slug), name: row.name }) ??
874− undefined,
875− 10,
876− );
836+ const integrationId = String(row.slug);
837+ const url =
838+ row.displayUrl ??
839+ integrationInferredUrl({ id: integrationId, name: row.name }) ??
840+ undefined;
877841 return {
878− key: `installed-${String(row.slug)}`,
879− testId: `installed-${String(row.slug)}`,
842+ key: `installed-${integrationId}`,
843+ testId: `installed-${integrationId}`,
880844 title: row.name,
881845 kindKey: row.kind,
882846 ...(row.description && row.description !== row.name
883847 ? { description: tidyDescription(row.description) }
884848 : {}),
885− ...(iconUrl ? { iconUrl } : {}),
849+ icon:
850+ integrationPresetIconUrl(
851+ {
852+ id: integrationId,
853+ kind: row.kind,
854+ name: row.name,
855+ ...(row.displayUrl ? { url: row.displayUrl } : {}),
856+ },
857+ integrationPlugins,
858+ ) ?? undefined,
859+ integrationId,
860+ url,
886861 onSelect: () => {},
887862 added: true,
888− viewSlug: String(row.slug),
863+ viewSlug: integrationId,
889864 busy: false,
890865 };
891866 });