Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/integration-icon-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"executor": patch
"@executor-js/plugin-mcp": patch
---

Show real icons on the integration browse page. The Codex plugin preset cards
rendered a bare-letter avatar: the page fed the preset's authenticated
`executor:` icon path into a raw `<img>`, which can never load it, and never
consulted the preset's public fallback image. Icon rendering is now unified on
one component — explicit icon, then its fallback image, then the favicon
derived from the integration's URL, then a neutral mark — used by the browse
cards, the command palette, and the Codex plugin add screen alike.
24 changes: 23 additions & 1 deletion e2e/local/codex-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";

import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { Browser, Cli, RunDir, Target } from "../src/services";
import { withLocalServer } from "./local-server";

const api = composePluginApi([mcpHttpPlugin()] as const);
Expand Down Expand Up @@ -109,7 +109,10 @@ scenario(
{ timeout: 300_000 },
Effect.gen(function* () {
const cli = yield* Cli;
const browser = yield* Browser;
const runDir = yield* RunDir;
const target = yield* Target;
const identity = yield* target.newIdentity();
const codexHome = makeCodexHome();

yield* withLocalServer(
Expand Down Expand Up @@ -142,6 +145,25 @@ scenario(
CODEX_HOME: codexHome,
});
}

yield* browser.session(identity, async ({ page, step }) => {
await step("Find Computer Use on the integration browse page", async () => {
await page.goto(server.url, { waitUntil: "domcontentloaded" });
await page.getByRole("link", { name: "Add integration" }).click();
await page
.getByRole("textbox", { name: "Search integrations, or paste a URL" })
.fill("Computer Use");

const card = page.getByTestId("preset-mcp-codex-computer-use");
await card.getByText("Computer Use MCP").waitFor({ timeout: 30_000 });
await card
.locator(
'img[src="https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png"]',
)
.waitFor({ timeout: 30_000 });
});
});

// Curated entries carry the app-server bridge recipe: `codex
// app-server`, the server name the bridge calls tools on, and the
// preset it came from — that last one is what lets a macOS refusal
Expand Down
10 changes: 6 additions & 4 deletions packages/plugins/mcp/src/react/CodexPluginAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";

import { Button } from "@executor-js/react/components/button";
import { FloatActions } from "@executor-js/react/components/float-actions";
import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
Expand Down Expand Up @@ -150,10 +151,11 @@ export default function CodexPluginAdd(props: {
{/* The plugin's own icon comes from the local Codex install; without
one the card still identifies its provider rather than showing a
gap, which matters most on the machines that have no install. */}
<img
src={plugin.icon ?? plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"}
alt=""
className="size-16 rounded-2xl"
<IntegrationFavicon
icon={plugin.icon}
fallbackSrc={plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"}
size={64}
className="rounded-2xl"
/>
<div className="min-w-0">
<div className="flex items-baseline gap-2">
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/mcp/src/sdk/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type McpPreset = McpRemotePreset | McpStdioPreset;
// use", …). `command` is deliberately empty: the real spawn recipe is
// machine-specific and comes from the server-side scanner
// (`codex-plugins.ts`); picking one of these opens the focused Codex add
// screen. The icon uses the `executor:` scheme (see preset-icon.tsx): the
// screen. The icon uses the `executor:` scheme (see integration-favicon.tsx): the
// plugin's own icon is a machine-local file, so it is served by the local API
// and resolved with the auth header — a static URL cannot reach it.
const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({
Expand Down
15 changes: 4 additions & 11 deletions packages/react/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { PlusIcon } from "lucide-react";
import { trackEvent } from "../api/analytics";
import type { Integration } from "@executor-js/sdk/shared";
import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon";
import { PresetIcon } from "./preset-icon";
import { integrationsOptimisticAtom } from "../api/atoms";
import { useIntegrationPlugins } from "@executor-js/sdk/client";
import {
Expand Down Expand Up @@ -196,16 +195,10 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool
value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
>
<PresetIcon
{...(e.presetIcon ? { icon: e.presetIcon } : {})}
{...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})}
className="size-4 shrink-0 object-contain"
fallback={
<span
aria-hidden
className="size-4 shrink-0 rounded-sm bg-muted-foreground/20"
/>
}
<IntegrationFavicon
icon={e.presetIcon}
fallbackSrc={e.presetFallbackIcon}
size={16}
/>
<span className="flex-1 truncate">{e.presetName}</span>
<CommandShortcut>{e.pluginLabel}</CommandShortcut>
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/components/integration-favicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ describe("IntegrationFavicon", () => {
expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull();
});

it("tries the explicit fallback before the integration-derived favicon", () => {
const icon = "executor:/mcp/codex-plugins/computer-use/icon";
const fallbackSrc = "https://integrations.sh/logo/openai.com";
const url = "https://example.com/mcp";

expect(integrationFaviconSrc({ icon, fallbackSrc, url, size: 20 })).toBe(icon);
expect(integrationFaviconSrc({ icon, fallbackSrc, url, size: 20, failedSrcs: [icon] })).toBe(
fallbackSrc,
);
expect(
integrationFaviconSrc({
icon,
fallbackSrc,
url,
size: 20,
failedSrcs: [icon, fallbackSrc],
}),
).toBe("https://integrations.sh/logo/example.com?sz=40");
});

it("uses the Executor favicon for the built-in executor integration", () => {
expect(integrationLocalIconUrl("executor")).toBe("/favicon-32.png");
expect(integrationLocalIconUrl("openapi")).toBeNull();
Expand Down
70 changes: 61 additions & 9 deletions packages/react/src/components/integration-favicon.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,47 @@
import { BoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import type { IntegrationPlugin } from "@executor-js/sdk/client";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { getDomain } from "tldts";

import { EXECUTOR_ICON_SCHEME, resolveExecutorIcon } from "./preset-icon";
import {
getExecutorApiBaseUrl,
getExecutorServerAuthorizationHeader,
} from "../api/server-connection";
import { cn } from "../lib/utils";

// ---------------------------------------------------------------------------

const EXECUTOR_ICON_SCHEME = "executor:";

const IconResponse = Schema.Struct({ icon: Schema.NullOr(Schema.String) });
const decodeIconResponse = Schema.decodeUnknownOption(IconResponse);

const resolvedExecutorIcons = new Map<string, Promise<string | null>>();

/** Resolves an authenticated local icon path once per browser session. */
const resolveExecutorIcon = (path: string): Promise<string | null> => {
const cached = resolvedExecutorIcons.get(path);
if (cached) return cached;
const authorization = getExecutorServerAuthorizationHeader();
const request = Effect.runPromise(
Effect.tryPromise(async () => {
const response = await fetch(`${getExecutorApiBaseUrl()}${path}`, {
headers: authorization === null ? {} : { authorization },
});
if (!response.ok) return null;
const body: unknown = await response.json();
return Option.match(decodeIconResponse(body), {
onNone: () => null,
onSome: ({ icon }) => icon,
});
}).pipe(Effect.orElseSucceed(() => null)),
);
resolvedExecutorIcons.set(path, request);
return request;
};

// ---------------------------------------------------------------------------
// IntegrationFavicon — renders a small favicon derived from an integration URL.
Expand Down Expand Up @@ -128,13 +166,15 @@ export function integrationPresetIconUrl(
}

// Resolution cascade for the rendered favicon: first non-null, non-failed of an
// explicit preset icon, the bundled local icon for a known integration id, then
// the integrations.sh logo proxy derived from the integration URL (which owns
// its own upstream fallbacks). The built-in executor integration has no preset
// icon and no URL, so it resolves ONLY through the integrationId branch: callers
// that drop integrationId fall through to the neutral BoxIcon placeholder.
// explicit preset icon, its explicit fallback image, the bundled local icon for
// a known integration id, then the integrations.sh logo proxy derived from the
// integration URL (which owns its own upstream fallbacks). The built-in executor
// integration has no preset icon and no URL, so it resolves ONLY through the
// integrationId branch: callers that drop integrationId fall through to the
// neutral BoxIcon placeholder.
export function integrationFaviconSrc(args: {
icon?: string | null;
fallbackSrc?: string | null;
integrationId?: string;
url?: string;
size: number;
Expand All @@ -144,6 +184,7 @@ export function integrationFaviconSrc(args: {
return (
[
args.icon ?? null,
args.fallbackSrc ?? null,
integrationLocalIconUrl(args.integrationId),
integrationFaviconUrl(args.url, args.size),
].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null
Expand All @@ -152,21 +193,32 @@ export function integrationFaviconSrc(args: {

export function IntegrationFavicon({
icon,
fallbackSrc,
integrationId,
url,
size = 16,
className,
}: {
icon?: string | null;
fallbackSrc?: string | null;
integrationId?: string;
url?: string;
size?: number;
className?: string;
}) {
const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]);
// `executor:`-scheme icons (served by the local API behind the bearer gate,
// e.g. a Codex plugin's own icon) resolve asynchronously to a data URI; a
// null resolution marks the candidate failed so the cascade continues.
const [executorIcons, setExecutorIcons] = useState<Readonly<Record<string, string>>>({});
const cascadeSrc = integrationFaviconSrc({ icon, integrationId, url, size, failedSrcs });
const cascadeSrc = integrationFaviconSrc({
icon,
fallbackSrc,
integrationId,
url,
size,
failedSrcs,
});
const isExecutorSrc = cascadeSrc?.startsWith(EXECUTOR_ICON_SCHEME) ?? false;

useEffect(() => {
Expand Down Expand Up @@ -194,7 +246,7 @@ export function IntegrationFavicon({
return (
<BoxIcon
aria-hidden
className="shrink-0 text-muted-foreground"
className={cn("shrink-0 text-muted-foreground", className)}
style={{ width: size, height: size }}
/>
);
Expand All @@ -214,7 +266,7 @@ export function IntegrationFavicon({
current.includes(failedCandidate) ? current : [...current, failedCandidate],
)
}
className="shrink-0 rounded-sm"
className={cn("shrink-0 rounded-sm object-contain", className)}
style={{ width: size, height: size }}
/>
);
Expand Down
84 changes: 0 additions & 84 deletions packages/react/src/components/preset-icon.tsx

This file was deleted.

Loading
Loading