The multiplayer shell rendered IntegrationFavicon without sourceId, so the built-in executor source (no preset icon, no displayUrl) fell through the resolution cascade to the neutral BoxIcon placeholder. Thread sourceId={slug} so it resolves the bundled /favicon-32.png, matching the local/desktop shell. Extract the resolution cascade into a pure integrationFaviconSrc helper and add a repro test pinning the with/without-sourceId divergence for the executor source.
Unified · Split
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | + integrationFaviconSrc, | |
| 4 | 5 | integrationFaviconUrl, |
| 5 | 6 | integrationInferredUrl, |
| 6 | 7 | integrationLocalIconUrl, |
| @@ -30,6 +31,28 @@ describe("IntegrationFavicon", () => { · 23 unmodified lines | ||
| 30 | 31 | expect(integrationLocalIconUrl("openapi")).toBeNull(); |
| 31 | 32 | }); |
| 32 | 33 | |
| 34 | + it("resolves the Executor sidebar icon only when the source id is threaded (cloud/self-host repro)", () => { | |
| 35 | + // Reconstruct the props the multiplayer shell derives for the built-in | |
| 36 | + // executor source (EXECUTOR_SOURCE: kind "built-in", name "Executor", no | |
| 37 | + // displayUrl). The sidebar's IntegrationList builds icon/url exactly this way. | |
| 38 | + const slug = "executor"; | |
| 39 | + const name = "Executor"; | |
| 40 | + const icon = integrationPresetIconUrl({ id: slug, kind: "built-in", name, url: undefined }, []); | |
| 41 | + const url = integrationInferredUrl({ id: slug, name }) ?? undefined; | |
| 42 | + | |
| 43 | + // The built-in source matches no preset and has no inferable host, so the | |
| 44 | + // sourceId branch is the only thing that can resolve its icon. | |
| 45 | + expect(icon).toBeNull(); | |
| 46 | + expect(url).toBeUndefined(); | |
| 47 | + | |
| 48 | + // Bug: cloud/self-host dropped sourceId, so the cascade fell through to null | |
| 49 | + // and rendered the neutral BoxIcon placeholder. | |
| 50 | + expect(integrationFaviconSrc({ icon, url, size: 16 })).toBeNull(); | |
| 51 | + | |
| 52 | + // Fix: threading sourceId resolves the bundled Executor favicon. | |
| 53 | + expect(integrationFaviconSrc({ icon, sourceId: slug, url, size: 16 })).toBe("/favicon-32.png"); | |
| 54 | + }); | |
| 55 | + | |
| 33 | 56 | it("finds preset icons from a source URL", () => { |
| 34 | 57 | expect( |
| 35 | 58 | integrationPresetIconUrl( |
| @@ -160,6 +160,28 @@ export function integrationPresetIconUrl( · 159 unmodified lines | ||
| 160 | 160 | return preset?.icon ?? null; |
| 161 | 161 | } |
| 162 | 162 | |
| 163 | +// Resolution cascade for the rendered favicon: first non-null, non-failed of an | |
| 164 | +// explicit preset icon, the bundled local icon for a known source id, then a | |
| 165 | +// favicon-service URL derived from the source URL. The built-in executor source | |
| 166 | +// has no preset icon and no URL, so it resolves ONLY through the sourceId branch: | |
| 167 | +// callers that drop sourceId fall through to the neutral BoxIcon placeholder. | |
| 168 | +export function integrationFaviconSrc(args: { | |
| 169 | + icon?: string | null; | |
| 170 | + sourceId?: string; | |
| 171 | + url?: string; | |
| 172 | + size: number; | |
| 173 | + failedSrcs?: readonly string[]; | |
| 174 | +}): string | null { | |
| 175 | + const failedSrcs = args.failedSrcs ?? []; | |
| 176 | + return ( | |
| 177 | + [ | |
| 178 | + args.icon ?? null, | |
| 179 | + integrationLocalIconUrl(args.sourceId), | |
| 180 | + integrationFaviconUrl(args.url, args.size), | |
| 181 | + ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null | |
| 182 | + ); | |
| 183 | +} | |
| 184 | + | |
| 163 | 185 | export function IntegrationFavicon({ |
| 164 | 186 | icon, |
| 165 | 187 | sourceId, |
| @@ -172,10 +194,7 @@ export function IntegrationFavicon({ · 6 unmodified lines | ||
| 172 | 194 | size?: number; |
| 173 | 195 | }) { |
| 174 | 196 | const [failedSrcs, setFailedSrcs] = useState<readonly string[]>([]); |
| 175 | − const src = | |
| 176 | − [icon ?? null, integrationLocalIconUrl(sourceId), integrationFaviconUrl(url, size)].find( | |
| 177 | − (candidate) => candidate !== null && !failedSrcs.includes(candidate), | |
| 178 | − ) ?? null; | |
| 197 | + const src = integrationFaviconSrc({ icon, sourceId, url, size, failedSrcs }); | |
| 179 | 198 | |
| 180 | 199 | if (!src) { |
| 181 | 200 | return ( |
| @@ -198,6 +198,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 197 unmodified lines | ||
| 198 | 198 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, |
| 199 | 199 | integrationPlugins, |
| 200 | 200 | )} |
| 201 | + sourceId={slug} | |
| 201 | 202 | url={ |
| 202 | 203 | integration.displayUrl ?? |
| 203 | 204 | integrationInferredUrl({ id: slug, name }) ?? |