Unified · Split
| @@ -28,7 +28,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 27 unmodified lines | |||
| 28 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 28 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 29 | 29 | ||
| 30 | import { scenario } from "../src/scenario"; | 30 | import { scenario } from "../src/scenario"; |
| 31 | import { Cli, RunDir } from "../src/services"; | 31 | import { Browser, Cli, RunDir, Target } from "../src/services"; |
| 32 | import { withLocalServer } from "./local-server"; | 32 | import { withLocalServer } from "./local-server"; |
| 33 | 33 | ||
| 34 | const api = composePluginApi([mcpHttpPlugin()] as const); | 34 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| @@ -109,7 +109,10 @@ scenario( · 74 unmodified lines | |||
| 109 | { timeout: 300_000 }, | 109 | { timeout: 300_000 }, |
| 110 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 111 | const cli = yield* Cli; | 111 | const cli = yield* Cli; |
| 112 | const browser = yield* Browser; | ||
| 112 | const runDir = yield* RunDir; | 113 | const runDir = yield* RunDir; |
| 114 | const target = yield* Target; | ||
| 115 | const identity = yield* target.newIdentity(); | ||
| 113 | const codexHome = makeCodexHome(); | 116 | const codexHome = makeCodexHome(); |
| 114 | 117 | ||
| 115 | yield* withLocalServer( | 118 | yield* withLocalServer( |
| @@ -142,6 +145,25 @@ scenario( · 26 unmodified lines | |||
| 142 | CODEX_HOME: codexHome, | 145 | CODEX_HOME: codexHome, |
| 143 | }); | 146 | }); |
| 144 | } | 147 | } |
| 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 | |||
| 145 | // Curated entries carry the app-server bridge recipe: `codex | 167 | // Curated entries carry the app-server bridge recipe: `codex |
| 146 | // app-server`, the server name the bridge calls tools on, and the | 168 | // app-server`, the server name the bridge calls tools on, and the |
| 147 | // preset it came from — that last one is what lets a macOS refusal | 169 | // preset it came from — that last one is what lets a macOS refusal |
| @@ -5,6 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | 6 | import { Button } from "@executor-js/react/components/button"; |
| 7 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 7 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 8 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 8 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; | 9 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; |
| 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 10 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 10 | import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; | 11 | import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; |
| @@ -150,10 +151,11 @@ export default function CodexPluginAdd(props: { · 139 unmodified lines | |||
| 150 | {/* The plugin's own icon comes from the local Codex install; without | 151 | {/* The plugin's own icon comes from the local Codex install; without |
| 151 | one the card still identifies its provider rather than showing a | 152 | one the card still identifies its provider rather than showing a |
| 152 | gap, which matters most on the machines that have no install. */} | 153 | gap, which matters most on the machines that have no install. */} |
| 153 | <img | 154 | <IntegrationFavicon |
| 154 | src={plugin.icon ?? plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"} | 155 | icon={plugin.icon} |
| 155 | alt="" | 156 | fallbackSrc={plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"} |
| 156 | className="size-16 rounded-2xl" | 157 | size={64} |
| 158 | className="rounded-2xl" | ||
| 157 | /> | 159 | /> |
| 158 | <div className="min-w-0"> | 160 | <div className="min-w-0"> |
| 159 | <div className="flex items-baseline gap-2"> | 161 | <div className="flex items-baseline gap-2"> |
| @@ -37,7 +37,7 @@ export type McpPreset = McpRemotePreset | McpStdioPreset; · 36 unmodified lines | |||
| 37 | // use", …). `command` is deliberately empty: the real spawn recipe is | 37 | // use", …). `command` is deliberately empty: the real spawn recipe is |
| 38 | // machine-specific and comes from the server-side scanner | 38 | // machine-specific and comes from the server-side scanner |
| 39 | // (`codex-plugins.ts`); picking one of these opens the focused Codex add | 39 | // (`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 |
| 41 | // plugin's own icon is a machine-local file, so it is served by the local API | 41 | // plugin's own icon is a machine-local file, so it is served by the local API |
| 42 | // and resolved with the auth header — a static URL cannot reach it. | 42 | // and resolved with the auth header — a static URL cannot reach it. |
| 43 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ | 43 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ |
| @@ -6,7 +6,6 @@ import { PlusIcon } from "lucide-react"; · 5 unmodified lines | |||
| 6 | import { trackEvent } from "../api/analytics"; | 6 | import { trackEvent } from "../api/analytics"; |
| 7 | import type { Integration } from "@executor-js/sdk/shared"; | 7 | import type { Integration } from "@executor-js/sdk/shared"; |
| 8 | import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon"; | 8 | import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon"; |
| 9 | import { PresetIcon } from "./preset-icon"; | ||
| 10 | import { integrationsOptimisticAtom } from "../api/atoms"; | 9 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 10 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 12 | import { | 11 | import { |
| @@ -196,16 +195,10 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 183 unmodified lines | |||
| 196 | value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`} | 195 | value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`} |
| 197 | onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)} | 196 | onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)} |
| 198 | > | 197 | > |
| 199 | <PresetIcon | 198 | <IntegrationFavicon |
| 200 | {...(e.presetIcon ? { icon: e.presetIcon } : {})} | 199 | icon={e.presetIcon} |
| 201 | {...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})} | 200 | fallbackSrc={e.presetFallbackIcon} |
| 202 | className="size-4 shrink-0 object-contain" | 201 | size={16} |
| 203 | fallback={ | ||
| 204 | <span | ||
| 205 | aria-hidden | ||
| 206 | className="size-4 shrink-0 rounded-sm bg-muted-foreground/20" | ||
| 207 | /> | ||
| 208 | } | ||
| 209 | /> | 202 | /> |
| 210 | <span className="flex-1 truncate">{e.presetName}</span> | 203 | <span className="flex-1 truncate">{e.presetName}</span> |
| 211 | <CommandShortcut>{e.pluginLabel}</CommandShortcut> | 204 | <CommandShortcut>{e.pluginLabel}</CommandShortcut> |
| @@ -33,6 +33,26 @@ describe("IntegrationFavicon", () => { · 32 unmodified lines | |||
| 33 | expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull(); | 33 | expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull(); |
| 34 | }); | 34 | }); |
| 35 | 35 | ||
| 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 | |||
| 36 | it("uses the Executor favicon for the built-in executor integration", () => { | 56 | it("uses the Executor favicon for the built-in executor integration", () => { |
| 37 | expect(integrationLocalIconUrl("executor")).toBe("/favicon-32.png"); | 57 | expect(integrationLocalIconUrl("executor")).toBe("/favicon-32.png"); |
| 38 | expect(integrationLocalIconUrl("openapi")).toBeNull(); | 58 | expect(integrationLocalIconUrl("openapi")).toBeNull(); |
| @@ -1,9 +1,47 @@ | |||
| 1 | import { BoxIcon } from "lucide-react"; | 1 | import { BoxIcon } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; | 2 | import { useEffect, useState } from "react"; |
| 3 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 3 | 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"; | ||
| 4 | import { getDomain } from "tldts"; | 7 | import { getDomain } from "tldts"; |
| 5 | 8 | ||
| 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 | }; | ||
| 7 | 45 | ||
| 8 | // --------------------------------------------------------------------------- | 46 | // --------------------------------------------------------------------------- |
| 9 | // IntegrationFavicon — renders a small favicon derived from an integration URL. | 47 | // IntegrationFavicon — renders a small favicon derived from an integration URL. |
| @@ -128,13 +166,15 @@ export function integrationPresetIconUrl( · 118 unmodified lines | |||
| 128 | } | 166 | } |
| 129 | 167 | ||
| 130 | // Resolution cascade for the rendered favicon: first non-null, non-failed of an | 168 | // 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 | 169 | // explicit preset icon, its explicit fallback image, the bundled local icon for |
| 132 | // the integrations.sh logo proxy derived from the integration URL (which owns | 170 | // a known integration id, then the integrations.sh logo proxy derived from the |
| 133 | // its own upstream fallbacks). The built-in executor integration has no preset | 171 | // integration URL (which owns its own upstream fallbacks). The built-in executor |
| 134 | // icon and no URL, so it resolves ONLY through the integrationId branch: callers | 172 | // integration has no preset icon and no URL, so it resolves ONLY through the |
| 135 | // that drop integrationId fall through to the neutral BoxIcon placeholder. | 173 | // integrationId branch: callers that drop integrationId fall through to the |
| 174 | // neutral BoxIcon placeholder. | ||
| 136 | export function integrationFaviconSrc(args: { | 175 | export function integrationFaviconSrc(args: { |
| 137 | icon?: string | null; | 176 | icon?: string | null; |
| 177 | fallbackSrc?: string | null; | ||
| 138 | integrationId?: string; | 178 | integrationId?: string; |
| 139 | url?: string; | 179 | url?: string; |
| 140 | size: number; | 180 | size: number; |
| @@ -144,6 +184,7 @@ export function integrationFaviconSrc(args: { · 3 unmodified lines | |||
| 144 | return ( | 184 | return ( |
| 145 | [ | 185 | [ |
| 146 | args.icon ?? null, | 186 | args.icon ?? null, |
| 187 | args.fallbackSrc ?? null, | ||
| 147 | integrationLocalIconUrl(args.integrationId), | 188 | integrationLocalIconUrl(args.integrationId), |
| 148 | integrationFaviconUrl(args.url, args.size), | 189 | integrationFaviconUrl(args.url, args.size), |
| 149 | ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null | 190 | ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null |
| @@ -152,21 +193,32 @@ export function integrationFaviconSrc(args: { · 2 unmodified lines | |||
| 152 | 193 | ||
| 153 | export function IntegrationFavicon({ | 194 | export function IntegrationFavicon({ |
| 154 | icon, | 195 | icon, |
| 196 | fallbackSrc, | ||
| 155 | integrationId, | 197 | integrationId, |
| 156 | url, | 198 | url, |
| 157 | size = 16, | 199 | size = 16, |
| 200 | className, | ||
| 158 | }: { | 201 | }: { |
| 159 | icon?: string | null; | 202 | icon?: string | null; |
| 203 | fallbackSrc?: string | null; | ||
| 160 | integrationId?: string; | 204 | integrationId?: string; |
| 161 | url?: string; | 205 | url?: string; |
| 162 | size?: number; | 206 | size?: number; |
| 207 | className?: string; | ||
| 163 | }) { | 208 | }) { |
| 164 | const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]); | 209 | const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]); |
| 165 | // `executor:`-scheme icons (served by the local API behind the bearer gate, | 210 | // `executor:`-scheme icons (served by the local API behind the bearer gate, |
| 166 | // e.g. a Codex plugin's own icon) resolve asynchronously to a data URI; a | 211 | // e.g. a Codex plugin's own icon) resolve asynchronously to a data URI; a |
| 167 | // null resolution marks the candidate failed so the cascade continues. | 212 | // null resolution marks the candidate failed so the cascade continues. |
| 168 | const [executorIcons, setExecutorIcons] = useState<Readonly<Record<string, string>>>({}); | 213 | 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 | }); | ||
| 170 | const isExecutorSrc = cascadeSrc?.startsWith(EXECUTOR_ICON_SCHEME) ?? false; | 222 | const isExecutorSrc = cascadeSrc?.startsWith(EXECUTOR_ICON_SCHEME) ?? false; |
| 171 | 223 | ||
| 172 | useEffect(() => { | 224 | useEffect(() => { |
| @@ -194,7 +246,7 @@ export function IntegrationFavicon({ · 21 unmodified lines | |||
| 194 | return ( | 246 | return ( |
| 195 | <BoxIcon | 247 | <BoxIcon |
| 196 | aria-hidden | 248 | aria-hidden |
| 197 | className="shrink-0 text-muted-foreground" | 249 | className={cn("shrink-0 text-muted-foreground", className)} |
| 198 | style={{ width: size, height: size }} | 250 | style={{ width: size, height: size }} |
| 199 | /> | 251 | /> |
| 200 | ); | 252 | ); |
| @@ -214,7 +266,7 @@ export function IntegrationFavicon({ · 13 unmodified lines | |||
| 214 | current.includes(failedCandidate) ? current : [...current, failedCandidate], | 266 | current.includes(failedCandidate) ? current : [...current, failedCandidate], |
| 215 | ) | 267 | ) |
| 216 | } | 268 | } |
| 217 | className="shrink-0 rounded-sm" | 269 | className={cn("shrink-0 rounded-sm object-contain", className)} |
| 218 | style={{ width: size, height: size }} | 270 | style={{ width: size, height: size }} |
| 219 | /> | 271 | /> |
| 220 | ); | 272 | ); |
| @@ -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 | } | ||
| @@ -21,7 +21,7 @@ import { Button } from "../components/button"; · 20 unmodified lines | |||
| 21 | import { Input } from "../components/input"; | 21 | import { Input } from "../components/input"; |
| 22 | import { PageHeader } from "../components/page"; | 22 | import { PageHeader } from "../components/page"; |
| 23 | import { | 23 | import { |
| 24 | integrationFaviconUrl, | 24 | IntegrationFavicon, |
| 25 | integrationInferredUrl, | 25 | integrationInferredUrl, |
| 26 | integrationPresetIconUrl, | 26 | integrationPresetIconUrl, |
| 27 | } from "../components/integration-favicon"; | 27 | } from "../components/integration-favicon"; |
| @@ -29,7 +29,6 @@ import { Skeleton } from "../components/skeleton"; · 1 unmodified line | |||
| 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 30 | import { | 30 | import { |
| 31 | availableCatalogKinds, | 31 | availableCatalogKinds, |
| 32 | catalogLogoUrl, | ||
| 33 | filterCatalogEntries, | 32 | filterCatalogEntries, |
| 34 | resolveConnectTarget, | 33 | resolveConnectTarget, |
| 35 | useCatalogBrowse, | 34 | useCatalogBrowse, |
| @@ -168,7 +167,10 @@ interface Row { · 132 unmodified lines | |||
| 168 | * distinguishable. Omitted when the title is already the domain. */ | 167 | * distinguishable. Omitted when the title is already the domain. */ |
| 169 | readonly domain?: string; | 168 | readonly domain?: string; |
| 170 | readonly description?: string; | 169 | readonly description?: string; |
| 171 | readonly iconUrl?: string; | 170 | readonly icon?: string; |
| 171 | readonly fallbackSrc?: string; | ||
| 172 | readonly integrationId?: string; | ||
| 173 | readonly url?: string; | ||
| 172 | readonly onSelect: () => void; | 174 | readonly onSelect: () => void; |
| 173 | readonly added: boolean; | 175 | readonly added: boolean; |
| 174 | /** Namespace of the installed integration this row matched — the View | 176 | /** Namespace of the installed integration this row matched — the View |
| @@ -214,36 +216,6 @@ function LoadMoreSentinel(props: { readonly onVisible: () => void }) { · 39 unmodified lines | |||
| 214 | // Row | 216 | // Row |
| 215 | // --------------------------------------------------------------------------- | 217 | // --------------------------------------------------------------------------- |
| 216 | 218 | ||
| 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 | |||
| 247 | function ResultCard(props: { readonly row: Row }) { | 219 | function ResultCard(props: { readonly row: Row }) { |
| 248 | const { row } = props; | 220 | const { row } = props; |
| 249 | return ( | 221 | return ( |
| @@ -253,7 +225,13 @@ function ResultCard(props: { readonly row: Row }) { · 3 unmodified lines | |||
| 253 | > | 225 | > |
| 254 | <div className="flex items-start justify-between gap-2"> | 226 | <div className="flex items-start justify-between gap-2"> |
| 255 | <span className="flex size-8 shrink-0 items-center justify-center"> | 227 | <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 | /> | ||
| 257 | </span> | 235 | </span> |
| 258 | {row.added ? ( | 236 | {row.added ? ( |
| 259 | // The add happened HERE — the card is the receipt. View jumps to | 237 | // The add happened HERE — the card is the receipt. View jumps to |
| @@ -725,7 +703,8 @@ export function IntegrationBrowsePage() { · 465 unmodified lines | |||
| 725 | title, | 703 | title, |
| 726 | kindKey, | 704 | kindKey, |
| 727 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), | 705 | ...(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 } : {}), | ||
| 729 | onSelect: () => pickPreset(entry), | 708 | onSelect: () => pickPreset(entry), |
| 730 | // The rendered title is also what the add flow derives the namespace | 709 | // The rendered title is also what the add flow derives the namespace |
| 731 | // from, so the card recognises an add made through it even when the | 710 | // from, so the card recognises an add made through it even when the |
| @@ -774,8 +753,8 @@ export function IntegrationBrowsePage() { · 42 unmodified lines | |||
| 774 | kindKey: surface.kind, | 753 | kindKey: surface.kind, |
| 775 | domain: entry.domain, | 754 | domain: entry.domain, |
| 776 | ...(description ? { description } : {}), | 755 | ...(description ? { description } : {}), |
| 777 | iconUrl: | 756 | ...(known && "icon" in known && known.icon ? { icon: known.icon } : {}), |
| 778 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), | 757 | url: `https://${entry.domain}`, |
| 779 | onSelect: () => | 758 | onSelect: () => |
| 780 | void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), | 759 | void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), |
| 781 | added: | 760 | added: |
| @@ -854,38 +833,34 @@ export function IntegrationBrowsePage() { · 72 unmodified lines | |||
| 854 | String(row.slug).includes(text)), | 833 | String(row.slug).includes(text)), |
| 855 | ) | 834 | ) |
| 856 | .map((row): Row => { | 835 | .map((row): Row => { |
| 857 | // The SAME icon cascade the sidebar runs — preset icon by exact | 836 | const integrationId = String(row.slug); |
| 858 | // identity, else the logo proxy from the integration's own URL. Two | 837 | const url = |
| 859 | // resolvers for one integration is how the sidebar and the picker | 838 | row.displayUrl ?? |
| 860 | // ended up disagreeing about what DeepWiki looks like. | 839 | integrationInferredUrl({ id: integrationId, name: row.name }) ?? |
| 861 | const iconUrl = | 840 | undefined; |
| 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 | ); | ||
| 877 | return { | 841 | return { |
| 878 | key: `installed-${String(row.slug)}`, | 842 | key: `installed-${integrationId}`, |
| 879 | testId: `installed-${String(row.slug)}`, | 843 | testId: `installed-${integrationId}`, |
| 880 | title: row.name, | 844 | title: row.name, |
| 881 | kindKey: row.kind, | 845 | kindKey: row.kind, |
| 882 | ...(row.description && row.description !== row.name | 846 | ...(row.description && row.description !== row.name |
| 883 | ? { description: tidyDescription(row.description) } | 847 | ? { description: tidyDescription(row.description) } |
| 884 | : {}), | 848 | : {}), |
| 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, | ||
| 886 | onSelect: () => {}, | 861 | onSelect: () => {}, |
| 887 | added: true, | 862 | added: true, |
| 888 | viewSlug: String(row.slug), | 863 | viewSlug: integrationId, |
| 889 | busy: false, | 864 | busy: false, |
| 890 | }; | 865 | }; |
| 891 | }); | 866 | }); |