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
28import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";28import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
29 29
30import { scenario } from "../src/scenario";30import { scenario } from "../src/scenario";
31import { Cli, RunDir } from "../src/services";31import { Browser, Cli, RunDir, Target } from "../src/services";
32import { withLocalServer } from "./local-server";32import { withLocalServer } from "./local-server";
33 33
34const api = composePluginApi([mcpHttpPlugin()] as const);34const 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: `codex167 // Curated entries carry the app-server bridge recipe: `codex
146 // app-server`, the server name the bridge calls tools on, and the168 // 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 refusal169 // 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
5 5
6import { Button } from "@executor-js/react/components/button";6import { Button } from "@executor-js/react/components/button";
7import { FloatActions } from "@executor-js/react/components/float-actions";7import { FloatActions } from "@executor-js/react/components/float-actions";
8import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
8import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";9import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";10import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
10import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";11import { 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; without151 {/* The plugin's own icon comes from the local Codex install; without
151 one the card still identifies its provider rather than showing a152 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 <img154 <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">
packages/plugins/mcp/src/sdk/presets.ts+1−1
@@ -37,7 +37,7 @@ export type McpPreset = McpRemotePreset | McpStdioPreset; · 36 unmodified lines
37// use", …). `command` is deliberately empty: the real spawn recipe is37// use", …). `command` is deliberately empty: the real spawn recipe is
38// machine-specific and comes from the server-side scanner38// machine-specific and comes from the server-side scanner
39// (`codex-plugins.ts`); picking one of these opens the focused Codex add39// (`codex-plugins.ts`); picking one of these opens the focused Codex add
40// screen. The icon uses the `executor:` scheme (see preset-icon.tsx): the40// 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 API41// 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.
43const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({43const 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
6import { trackEvent } from "../api/analytics";6import { trackEvent } from "../api/analytics";
7import type { Integration } from "@executor-js/sdk/shared";7import type { Integration } from "@executor-js/sdk/shared";
8import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon";8import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favicon";
9import { PresetIcon } from "./preset-icon";
10import { integrationsOptimisticAtom } from "../api/atoms";9import { integrationsOptimisticAtom } from "../api/atoms";
11import { useIntegrationPlugins } from "@executor-js/sdk/client";10import { useIntegrationPlugins } from "@executor-js/sdk/client";
12import {11import {
@@ -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 <PresetIcon198 <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>
packages/react/src/components/integration-favicon.test.tsx+20−0
@@ -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();
packages/react/src/components/integration-favicon.tsx+61−9
@@ -1,9 +1,47 @@
1import { BoxIcon } from "lucide-react";1import { BoxIcon } from "lucide-react";
2import { useEffect, useState } from "react";2import { useEffect, useState } from "react";
3import type { IntegrationPlugin } from "@executor-js/sdk/client";3import type { IntegrationPlugin } from "@executor-js/sdk/client";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
4import { getDomain } from "tldts";7import { getDomain } from "tldts";
5 8
6import { EXECUTOR_ICON_SCHEME, resolveExecutorIcon } from "./preset-icon";9import {
10 getExecutorApiBaseUrl,
11 getExecutorServerAuthorizationHeader,
12} from "../api/server-connection";
13import { cn } from "../lib/utils";
14
15// ---------------------------------------------------------------------------
16
17const EXECUTOR_ICON_SCHEME = "executor:";
18
19const IconResponse = Schema.Struct({ icon: Schema.NullOr(Schema.String) });
20const decodeIconResponse = Schema.decodeUnknownOption(IconResponse);
21
22const resolvedExecutorIcons = new Map<string, Promise<string | null>>();
23
24/** Resolves an authenticated local icon path once per browser session. */
25const 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 an168// 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, then169// explicit preset icon, its explicit fallback image, the bundled local icon for
132// the integrations.sh logo proxy derived from the integration URL (which owns170// 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 preset171// 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: callers172// 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.
136export function integrationFaviconSrc(args: {175export 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)) ?? null190 ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null
@@ -152,21 +193,32 @@ export function integrationFaviconSrc(args: { · 2 unmodified lines
152 193
153export function IntegrationFavicon({194export 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; a211 // 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 <BoxIcon247 <BoxIcon
196 aria-hidden248 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 );
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
11import { useEffect, useState } from "react";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";
15
16import {
17 getExecutorApiBaseUrl,
18 getExecutorServerAuthorizationHeader,
19} from "../api/server-connection";
20
21export const EXECUTOR_ICON_SCHEME = "executor:";
22
23const IconResponse = Schema.Struct({ icon: Schema.NullOr(Schema.String) });
24const decodeIconResponse = Schema.decodeUnknownOption(IconResponse);
25
26const 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. */
32export 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. */
61export 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
21import { Input } from "../components/input";21import { Input } from "../components/input";
22import { PageHeader } from "../components/page";22import { PageHeader } from "../components/page";
23import {23import {
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
29import { useExecutorDocumentTitle } from "../lib/document-title";29import { useExecutorDocumentTitle } from "../lib/document-title";
30import {30import {
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 View176 /** Namespace of the installed integration this row matched — the View
@@ -214,36 +216,6 @@ function LoadMoreSentinel(props: { readonly onVisible: () => void }) { · 39 unmodified lines
214// Row216// 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. */
219function 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
247function ResultCard(props: { readonly row: Row }) {219function 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 to237 // 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 namespace709 // 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 the710 // 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 exact836 const integrationId = String(row.slug);
858 // identity, else the logo proxy from the integration's own URL. Two837 const url =
859 // resolvers for one integration is how the sidebar and the picker838 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.name846 ...(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 });