diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx
index 9c8ebf11b..cd97ea869 100644
--- a/apps/cloud/src/web/shell.tsx
+++ b/apps/cloud/src/web/shell.tsx
@@ -28,8 +28,9 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@executor-js/react/components/dropdown-menu";
-import { SourceFavicon } from "@executor-js/react/components/source-favicon";
+import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";
import { CommandPalette } from "@executor-js/react/components/command-palette";
+import { useSourcePlugins } from "@executor-js/sdk/client";
import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { AUTH_PATHS } from "../auth/api";
import { organizationsAtom, switchOrganization, useAuth } from "./auth";
@@ -75,6 +76,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate
function SourceList(props: { pathname: string; onNavigate?: () => void }) {
const scopeId = useScope();
const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
+ const sourcePlugins = useSourcePlugins();
return AsyncResult.match(sources, {
onInitial: () => (
@@ -114,7 +116,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) {
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
-
+
{s.name}
{s.kind}
diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx
index dd6bb8ba2..204118360 100644
--- a/packages/app/src/web/shell.tsx
+++ b/packages/app/src/web/shell.tsx
@@ -7,9 +7,9 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms";
import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
import { Button } from "@executor-js/react/components/button";
-import { SourceFavicon } from "@executor-js/react/components/source-favicon";
+import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";
import { CommandPalette } from "@executor-js/react/components/command-palette";
-import { useClientPlugins } from "@executor-js/sdk/client";
+import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client";
// ── Env ─────────────────────────────────────────────────────────────────
@@ -268,6 +268,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) {
function SourceList(props: { pathname: string; onNavigate?: () => void }) {
const scopeId = useScope();
const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
+ const sourcePlugins = useSourcePlugins();
return AsyncResult.match(sources, {
onInitial: () => Loading…
,
@@ -298,7 +299,11 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) {
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
-
+
{s.name}
{s.kind}
diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx
index 07bcf64cd..6e0583f90 100644
--- a/packages/react/src/components/command-palette.tsx
+++ b/packages/react/src/components/command-palette.tsx
@@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { PlusIcon } from "lucide-react";
-import { SourceFavicon } from "./source-favicon";
+import { SourceFavicon, sourcePresetIconUrl } from "./source-favicon";
import { sourcesOptimisticAtom } from "../api/atoms";
import { useScope } from "../hooks/use-scope";
import { useSourcePlugins } from "@executor-js/sdk/client";
@@ -151,7 +151,7 @@ export function CommandPalette() {
value={`connected ${s.name} ${s.id} ${s.kind}`}
onSelect={() => goToSource(s.id)}
>
-
+
{s.name}
{s.kind}
diff --git a/packages/react/src/components/source-favicon.test.tsx b/packages/react/src/components/source-favicon.test.tsx
index 58010cc0e..0ac8f2a73 100644
--- a/packages/react/src/components/source-favicon.test.tsx
+++ b/packages/react/src/components/source-favicon.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
-import { sourceFaviconUrl, sourceLocalIconUrl } from "./source-favicon";
+import { sourceFaviconUrl, sourceLocalIconUrl, sourcePresetIconUrl } from "./source-favicon";
describe("SourceFavicon", () => {
it("uses the favicon service that handles provider-specific icon locations", () => {
@@ -24,4 +24,90 @@ describe("SourceFavicon", () => {
expect(sourceLocalIconUrl("executor")).toBe("/favicon-32.png");
expect(sourceLocalIconUrl("openapi")).toBeNull();
});
+
+ it("finds preset icons from a source URL", () => {
+ expect(
+ sourcePresetIconUrl(
+ {
+ id: "google_sheets",
+ kind: "googleDiscovery",
+ name: "Google Sheets API",
+ url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
+ },
+ [
+ {
+ key: "googleDiscovery",
+ label: "Google Discovery",
+ add: () => null,
+ edit: () => null,
+ presets: [
+ {
+ id: "google-sheets",
+ name: "Google Sheets",
+ summary: "Spreadsheets.",
+ url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
+ icon: "https://example.com/sheets.svg",
+ },
+ ],
+ },
+ ],
+ ),
+ ).toBe("https://example.com/sheets.svg");
+ });
+
+ it("finds preset icons from display names with suffixes", () => {
+ expect(
+ sourcePresetIconUrl(
+ {
+ id: "google_search_console_api",
+ kind: "googleDiscovery",
+ name: "Google Search Console API",
+ },
+ [
+ {
+ key: "googleDiscovery",
+ label: "Google Discovery",
+ add: () => null,
+ edit: () => null,
+ presets: [
+ {
+ id: "google-search-console",
+ name: "Google Search Console",
+ summary: "Search performance.",
+ icon: "https://example.com/google.svg",
+ },
+ ],
+ },
+ ],
+ ),
+ ).toBe("https://example.com/google.svg");
+ });
+
+ it("finds preset icons from a source id when the URL is missing", () => {
+ expect(
+ sourcePresetIconUrl(
+ {
+ id: "sentry",
+ kind: "mcp",
+ name: "Sentry MCP",
+ },
+ [
+ {
+ key: "mcp",
+ label: "MCP",
+ add: () => null,
+ edit: () => null,
+ presets: [
+ {
+ id: "sentry",
+ name: "Sentry",
+ summary: "Errors.",
+ icon: "https://example.com/sentry.png",
+ },
+ ],
+ },
+ ],
+ ),
+ ).toBe("https://example.com/sentry.png");
+ });
});
diff --git a/packages/react/src/components/source-favicon.tsx b/packages/react/src/components/source-favicon.tsx
index e03f04587..51f7bbf1c 100644
--- a/packages/react/src/components/source-favicon.tsx
+++ b/packages/react/src/components/source-favicon.tsx
@@ -1,5 +1,6 @@
import { BoxIcon } from "lucide-react";
import { useState } from "react";
+import type { SourcePlugin } from "@executor-js/sdk/client";
import { getDomain } from "tldts";
// ---------------------------------------------------------------------------
@@ -19,17 +20,81 @@ export function sourceLocalIconUrl(sourceId: string | undefined): string | null
return "/favicon-32.png";
}
+const KIND_TO_PLUGIN_KEY: Record = {
+ openapi: "openapi",
+ mcp: "mcp",
+ graphql: "graphql",
+ googleDiscovery: "googleDiscovery",
+};
+
+const normalizeUrl = (url: string | undefined): string | null => {
+ if (!url) return null;
+ try {
+ const parsed = new URL(url);
+ parsed.hash = "";
+ parsed.searchParams.sort();
+ return parsed.toString().replace(/\/$/, "");
+ } catch {
+ return url.trim().replace(/\/$/, "");
+ }
+};
+
+const normalizeToken = (value: string | undefined): string =>
+ value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? "";
+
+const tokenMatches = (sourceValue: string, presetValue: string): boolean =>
+ presetValue.length > 0 &&
+ sourceValue.length > 0 &&
+ (sourceValue === presetValue ||
+ sourceValue.includes(presetValue) ||
+ presetValue.includes(sourceValue));
+
+export function sourcePresetIconUrl(
+ source: {
+ readonly id: string;
+ readonly kind: string;
+ readonly name?: string;
+ readonly url?: string;
+ },
+ sourcePlugins: readonly SourcePlugin[],
+): string | null {
+ const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
+ const plugin = sourcePlugins.find((p) => p.key === pluginKey);
+ const presets = plugin?.presets ?? [];
+ const sourceUrl = normalizeUrl(source.url);
+ const sourceId = normalizeToken(source.id);
+ const sourceName = normalizeToken(source.name);
+
+ const preset = presets.find((p) => {
+ const presetUrl = normalizeUrl(p.url);
+ const presetId = normalizeToken(p.id);
+ const presetName = normalizeToken(p.name);
+ return (
+ (sourceUrl !== null && presetUrl === sourceUrl) ||
+ tokenMatches(sourceId, presetId) ||
+ tokenMatches(sourceName, presetName)
+ );
+ });
+
+ return preset?.icon ?? null;
+}
+
export function SourceFavicon({
+ icon,
sourceId,
url,
size = 16,
}: {
+ icon?: string | null;
sourceId?: string;
url?: string;
size?: number;
}) {
- const [failed, setFailed] = useState(false);
- const src = failed ? null : (sourceLocalIconUrl(sourceId) ?? sourceFaviconUrl(url, size));
+ const [failedSrcs, setFailedSrcs] = useState([]);
+ const src =
+ [icon ?? null, sourceLocalIconUrl(sourceId), sourceFaviconUrl(url, size)].find(
+ (candidate) => candidate !== null && !failedSrcs.includes(candidate),
+ ) ?? null;
if (!src) {
return (
@@ -48,7 +113,9 @@ export function SourceFavicon({
width={size}
height={size}
loading="lazy"
- onError={() => setFailed(true)}
+ onError={() =>
+ setFailedSrcs((current) => (current.includes(src) ? current : [...current, src]))
+ }
className="shrink-0 rounded-sm"
style={{ width: size, height: size }}
/>
diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx
index 7ec4a4120..963378f47 100644
--- a/packages/react/src/pages/sources.tsx
+++ b/packages/react/src/pages/sources.tsx
@@ -29,7 +29,7 @@ import {
CardStackEntryMedia,
CardStackEntryTitle,
} from "../components/card-stack";
-import { SourceFavicon } from "../components/source-favicon";
+import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon";
import { Skeleton } from "../components/skeleton";
const KIND_TO_PLUGIN_KEY: Record = {
@@ -411,7 +411,12 @@ function SourceGrid(props: {
-
+
{s.name}