diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index f03acf3a..358f37da 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -192,6 +192,13 @@ function AddExtensionContent() { ); })(); + // When the resolved icon URL changes (e.g. the real external icon arrives + // after an async fetch), clear any prior load failure so the retries + // instead of staying on the placeholder. + useEffect(() => { + setInstallIconFailed(false); + }, [installIconURL]); + const [popoverOpen, setPopoverOpen] = useState(false); const [popoverView, setPopoverView] = useState('menu'); const [isDragOver, setIsDragOver] = useState(false); @@ -279,6 +286,23 @@ function AddExtensionContent() { setInstallIconFailed(false); setModalOpen(true); + // The icon is not carried in the URL params, so fetch it from the + // marketplace record. Without this the confirm dialog falls back to the + // /resources/icon endpoint, which 404s for extensions whose icon is an + // external URL (simpleicons / iconify), showing a placeholder. + const cloud = getCloudServiceClientSync(); + cloud + .fetchMarketplaceIcon(extType, author, name) + .then((icon) => { + if (!icon) return; + setInstallInfo((prev) => + prev.plugin_author === author && prev.plugin_name === name + ? { ...prev, plugin_icon: icon } + : prev, + ); + }) + .catch(() => {}); + setSearchParams( (current) => { const next = new URLSearchParams(current); diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index e1c5b33d..e0568978 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -207,6 +207,52 @@ export class CloudServiceClient extends BaseHttpClient { ); } + public getMCPDetail( + author: string, + name: string, + ): Promise<{ mcp: PluginV4 }> { + return this.get<{ mcp: PluginV4 }>( + `/api/v1/marketplace/mcps/${author}/${name}`, + ); + } + + public getSkillDetail( + author: string, + name: string, + ): Promise<{ skill: PluginV4 }> { + return this.get<{ skill: PluginV4 }>( + `/api/v1/marketplace/skills/${author}/${name}`, + ); + } + + /** + * Resolve the marketplace ``icon`` field for an extension by author/name/type. + * Used when the icon is not already known locally (e.g. an install confirm + * dialog opened from a URL query param carries no icon). Returns the raw + * ``icon`` value from the marketplace record (often an absolute external URL), + * or an empty string if it cannot be fetched. + */ + public async fetchMarketplaceIcon( + type: 'plugin' | 'mcp' | 'skill' | undefined, + author: string, + name: string, + ): Promise { + try { + if (type === 'mcp') { + const resp = await this.getMCPDetail(author, name); + return resp?.mcp?.icon || ''; + } + if (type === 'skill') { + const resp = await this.getSkillDetail(author, name); + return resp?.skill?.icon || ''; + } + const resp = await this.getPluginDetail(author, name); + return resp?.plugin?.icon || ''; + } catch { + return ''; + } + } + public getPluginREADME( author: string, pluginName: string,