Unified · Split
| @@ -28,7 +28,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 27 unmodified lines | ||
| 28 | 28 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 29 | 29 | |
| 30 | 30 | import { scenario } from "../src/scenario"; |
| 31 | −import { Cli, RunDir } from "../src/services"; | |
| 31 | +import { Browser, Cli, RunDir, Target } from "../src/services"; | |
| 32 | 32 | import { withLocalServer } from "./local-server"; |
| 33 | 33 | |
| 34 | 34 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| @@ -109,7 +109,10 @@ scenario( · 74 unmodified lines | ||
| 109 | 109 | { timeout: 300_000 }, |
| 110 | 110 | Effect.gen(function* () { |
| 111 | 111 | const cli = yield* Cli; |
| 112 | + const browser = yield* Browser; | |
| 112 | 113 | const runDir = yield* RunDir; |
| 114 | + const target = yield* Target; | |
| 115 | + const identity = yield* target.newIdentity(); | |
| 113 | 116 | const codexHome = makeCodexHome(); |
| 114 | 117 | |
| 115 | 118 | yield* withLocalServer( |
| @@ -142,6 +145,25 @@ scenario( · 26 unmodified lines | ||
| 142 | 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 | 167 | // Curated entries carry the app-server bridge recipe: `codex |
| 146 | 168 | // app-server`, the server name the bridge calls tools on, and the |
| 147 | 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 | 6 | import { Button } from "@executor-js/react/components/button"; |
| 7 | 7 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 8 | +import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | |
| 8 | 9 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; |
| 9 | 10 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 10 | 11 | import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; |
| @@ -150,10 +151,11 @@ export default function CodexPluginAdd(props: { · 139 unmodified lines | ||
| 150 | 151 | {/* The plugin's own icon comes from the local Codex install; without |
| 151 | 152 | one the card still identifies its provider rather than showing a |
| 152 | 153 | 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" | |
| 157 | 159 | /> |
| 158 | 160 | <div className="min-w-0"> |
| 159 | 161 | <div className="flex items-baseline gap-2"> |
| @@ -37,7 +37,7 @@ export type McpPreset = McpRemotePreset | McpStdioPreset; · 36 unmodified lines | ||
| 37 | 37 | // use", …). `command` is deliberately empty: the real spawn recipe is |
| 38 | 38 | // machine-specific and comes from the server-side scanner |
| 39 | 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 | 41 | // plugin's own icon is a machine-local file, so it is served by the local API |
| 42 | 42 | // and resolved with the auth header — a static URL cannot reach it. |
| 43 | 43 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ |
| @@ -6,7 +6,6 @@ import { PlusIcon } from "lucide-react"; · 5 unmodified lines | ||
| 6 | 6 | import { trackEvent } from "../api/analytics"; |
| 7 | 7 | import type { Integration } from "@executor-js/sdk/shared"; |
| 8 | 8 | import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon"; |
| 9 | −import { PresetIcon } from "./preset-icon"; | |
| 10 | 9 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | 10 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 12 | 11 | import { |
| @@ -196,16 +195,10 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 183 unmodified lines | ||
| 196 | 195 | value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`} |
| 197 | 196 | onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)} |
| 198 | 197 | > |
| 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} | |
| 209 | 202 | /> |
| 210 | 203 | <span className="flex-1 truncate">{e.presetName}</span> |
| 211 | 204 | <CommandShortcut>{e.pluginLabel}</CommandShortcut> |
| @@ -33,6 +33,26 @@ describe("IntegrationFavicon", () => { · 32 unmodified lines | ||
| 33 | 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 | 56 | it("uses the Executor favicon for the built-in executor integration", () => { |
| 37 | 57 | expect(integrationLocalIconUrl("executor")).toBe("/favicon-32.png"); |
| 38 | 58 | expect(integrationLocalIconUrl("openapi")).toBeNull(); |
| @@ -1,9 +1,47 @@ | ||
| 1 | 1 | import { BoxIcon } from "lucide-react"; |
| 2 | 2 | import { useEffect, useState } from "react"; |
| 3 | 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 | 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 | 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 | 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 | |
| 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. | |
| 136 | 175 | export function integrationFaviconSrc(args: { |
| 137 | 176 | icon?: string | null; |
| 177 | + fallbackSrc?: string | null; | |
| 138 | 178 | integrationId?: string; |
| 139 | 179 | url?: string; |
| 140 | 180 | size: number; |
| @@ -144,6 +184,7 @@ export function integrationFaviconSrc(args: { · 3 unmodified lines | ||
| 144 | 184 | return ( |
| 145 | 185 | [ |
| 146 | 186 | args.icon ?? null, |
| 187 | + args.fallbackSrc ?? null, | |
| 147 | 188 | integrationLocalIconUrl(args.integrationId), |
| 148 | 189 | integrationFaviconUrl(args.url, args.size), |
| 149 | 190 | ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null |
| @@ -152,21 +193,32 @@ export function integrationFaviconSrc(args: { · 2 unmodified lines | ||
| 152 | 193 | |
| 153 | 194 | export function IntegrationFavicon({ |
| 154 | 195 | icon, |
| 196 | + fallbackSrc, | |
| 155 | 197 | integrationId, |
| 156 | 198 | url, |
| 157 | 199 | size = 16, |
| 200 | + className, | |
| 158 | 201 | }: { |
| 159 | 202 | icon?: string | null; |
| 203 | + fallbackSrc?: string | null; | |
| 160 | 204 | integrationId?: string; |
| 161 | 205 | url?: string; |
| 162 | 206 | size?: number; |
| 207 | + className?: string; | |
| 163 | 208 | }) { |
| 164 | 209 | const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]); |
| 165 | 210 | // `executor:`-scheme icons (served by the local API behind the bearer gate, |
| 166 | 211 | // e.g. a Codex plugin's own icon) resolve asynchronously to a data URI; a |
| 167 | 212 | // null resolution marks the candidate failed so the cascade continues. |
| 168 | 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 | 222 | const isExecutorSrc = cascadeSrc?.startsWith(EXECUTOR_ICON_SCHEME) ?? false; |
| 171 | 223 | |
| 172 | 224 | useEffect(() => { |
| @@ -194,7 +246,7 @@ export function IntegrationFavicon({ · 21 unmodified lines | ||
| 194 | 246 | return ( |
| 195 | 247 | <BoxIcon |
| 196 | 248 | aria-hidden |
| 197 | − className="shrink-0 text-muted-foreground" | |
| 249 | + className={cn("shrink-0 text-muted-foreground", className)} | |
| 198 | 250 | style={{ width: size, height: size }} |
| 199 | 251 | /> |
| 200 | 252 | ); |
| @@ -214,7 +266,7 @@ export function IntegrationFavicon({ · 13 unmodified lines | ||
| 214 | 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 | 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 | 21 | import { Input } from "../components/input"; |
| 22 | 22 | import { PageHeader } from "../components/page"; |
| 23 | 23 | import { |
| 24 | − integrationFaviconUrl, | |
| 24 | + IntegrationFavicon, | |
| 25 | 25 | integrationInferredUrl, |
| 26 | 26 | integrationPresetIconUrl, |
| 27 | 27 | } from "../components/integration-favicon"; |
| @@ -29,7 +29,6 @@ import { Skeleton } from "../components/skeleton"; · 1 unmodified line | ||
| 29 | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 30 | 30 | import { |
| 31 | 31 | availableCatalogKinds, |
| 32 | − catalogLogoUrl, | |
| 33 | 32 | filterCatalogEntries, |
| 34 | 33 | resolveConnectTarget, |
| 35 | 34 | useCatalogBrowse, |
| @@ -168,7 +167,10 @@ interface Row { · 132 unmodified lines | ||
| 168 | 167 | * distinguishable. Omitted when the title is already the domain. */ |
| 169 | 168 | readonly domain?: string; |
| 170 | 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 | 174 | readonly onSelect: () => void; |
| 173 | 175 | readonly added: boolean; |
| 174 | 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 | 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 | 219 | function ResultCard(props: { readonly row: Row }) { |
| 248 | 220 | const { row } = props; |
| 249 | 221 | return ( |
| @@ -253,7 +225,13 @@ function ResultCard(props: { readonly row: Row }) { · 3 unmodified lines | ||
| 253 | 225 | > |
| 254 | 226 | <div className="flex items-start justify-between gap-2"> |
| 255 | 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 | 235 | </span> |
| 258 | 236 | {row.added ? ( |
| 259 | 237 | // The add happened HERE — the card is the receipt. View jumps to |
| @@ -725,7 +703,8 @@ export function IntegrationBrowsePage() { · 465 unmodified lines | ||
| 725 | 703 | title, |
| 726 | 704 | kindKey, |
| 727 | 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 | 708 | onSelect: () => pickPreset(entry), |
| 730 | 709 | // The rendered title is also what the add flow derives the namespace |
| 731 | 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 | 753 | kindKey: surface.kind, |
| 775 | 754 | domain: entry.domain, |
| 776 | 755 | ...(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}`, | |
| 779 | 758 | onSelect: () => |
| 780 | 759 | void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), |
| 781 | 760 | added: |
| @@ -854,38 +833,34 @@ export function IntegrationBrowsePage() { · 72 unmodified lines | ||
| 854 | 833 | String(row.slug).includes(text)), |
| 855 | 834 | ) |
| 856 | 835 | .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; | |
| 877 | 841 | return { |
| 878 | − key: `installed-${String(row.slug)}`, | |
| 879 | − testId: `installed-${String(row.slug)}`, | |
| 842 | + key: `installed-${integrationId}`, | |
| 843 | + testId: `installed-${integrationId}`, | |
| 880 | 844 | title: row.name, |
| 881 | 845 | kindKey: row.kind, |
| 882 | 846 | ...(row.description && row.description !== row.name |
| 883 | 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 | 861 | onSelect: () => {}, |
| 887 | 862 | added: true, |
| 888 | − viewSlug: String(row.slug), | |
| 863 | + viewSlug: integrationId, | |
| 889 | 864 | busy: false, |
| 890 | 865 | }; |
| 891 | 866 | }); |