1import { useEffect, useId, useState } from "react";
2import {
3 resolveOfficialAcpRegistryIconUrl,
4 officialAcpRegistryIconUrlForAgentId,
5} from "@t3tools/contracts";
6
7import { cn } from "../../lib/utils";
8import { ACPRegistryIcon } from "../Icons";
9
10const ACP_REGISTRY_ICON_CACHE = "t3-acp-registry-icons-v1";
11const MAX_ICON_BYTES = 512 * 1_024;
12const inFlightIcons = new Map<string, Promise<Blob>>();
13
14export { officialAcpRegistryIconUrlForAgentId, resolveOfficialAcpRegistryIconUrl };
15
16async function checkedIconBlob(response: Response): Promise<Blob> {
17 if (!response.ok || response.redirected) throw new Error("ACP registry icon request failed.");
18 const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? "";
19 if (!contentType.startsWith("image/")) throw new Error("ACP registry icon was not an image.");
20 const contentLength = Number(response.headers.get("content-length"));
21 if (Number.isFinite(contentLength) && contentLength > MAX_ICON_BYTES) {
22 throw new Error("ACP registry icon was too large.");
23 }
24 const bytes = await response.arrayBuffer();
25 if (bytes.byteLength > MAX_ICON_BYTES) throw new Error("ACP registry icon was too large.");
26 return new Blob([bytes], { type: contentType });
27}
28
29/** Fetches an official icon once, then serves it from persistent browser cache. */
30export function loadCachedAcpRegistryIcon(iconUrl: string): Promise<Blob> {
31 if (resolveOfficialAcpRegistryIconUrl(iconUrl) !== iconUrl) {
32 return Promise.reject(new Error("ACP registry icon URL was not allowed."));
33 }
34 const pending = inFlightIcons.get(iconUrl);
35 if (pending) return pending;
36 const load = (async () => {
37 let cache: Cache | null = null;
38 if (typeof caches !== "undefined") {
39 try {
40 cache = await caches.open(ACP_REGISTRY_ICON_CACHE);
41 } catch {
42 // Some privacy modes expose CacheStorage but reject access. Icons can
43 // still load normally from the allowlisted registry CDN.
44 }
45 }
46 if (cache !== null) {
47 try {
48 const cached = await cache.match(iconUrl);
49 if (cached) return await checkedIconBlob(cached);
50 } catch {
51 // Ignore unreadable or invalid cache entries and repair from network.
52 }
53 }
54
55 const response = await fetch(iconUrl, {
56 credentials: "omit",
57 redirect: "error",
58 referrerPolicy: "no-referrer",
59 });
60 const clone = response.clone();
61 const blob = await checkedIconBlob(response);
62 await cache?.put(iconUrl, clone).catch(() => undefined);
63 return blob;
64 })();
65 inFlightIcons.set(iconUrl, load);
66 void load.then(
67 () => inFlightIcons.delete(iconUrl),
68 () => inFlightIcons.delete(iconUrl),
69 );
70 return load;
71}
72
73export function AcpRegistryAgentIcon({
74 icon,
75 className,
76 fallbackClassName,
77}: {
78 readonly icon: string | null;
79 readonly className?: string;
80 readonly fallbackClassName?: string;
81}) {
82 const colorFilterId = useId();
83 const iconUrl = resolveOfficialAcpRegistryIconUrl(icon);
84 const [image, setImage] = useState<{
85 readonly iconUrl: string;
86 readonly source: string;
87 readonly status: "loading" | "loaded" | "failed";
88 } | null>(null);
89 const currentImage = image?.iconUrl === iconUrl ? image : null;
90 // Render only after the load pipeline resolves: the validated blob when the
91 // fetch succeeds, or the raw allowlisted CDN URL when it fails (the official
92 // CDN serves no CORS headers, so the validating fetch is often blocked in
93 // cross-origin browser contexts while native <img> loading still works).
94 const source = currentImage?.source ?? null;
95 const status = currentImage?.status ?? "loading";
96
97 useEffect(() => {
98 if (iconUrl === null) return;
99 let active = true;
100 let objectUrl: string | null = null;
101 void loadCachedAcpRegistryIcon(iconUrl)
102 .then((blob) => {
103 if (!active) return;
104 objectUrl = URL.createObjectURL(blob);
105 setImage({ iconUrl, source: objectUrl, status: "loading" });
106 })
107 .catch(() => {
108 if (!active) return;
109 setImage((current) =>
110 current?.iconUrl === iconUrl ? current : { iconUrl, source: iconUrl, status: "loading" },
111 );
112 });
113 return () => {
114 active = false;
115 if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
116 };
117 }, [iconUrl]);
118
119 return (
120 <span
121 aria-hidden
122 className={cn(
123 "relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground",
124 className,
125 )}
126 >
127 {/* Registry glyphs are monochrome. Recolor their alpha rather than
128 inverting RGB values; external images cannot inherit currentColor.
129 A local SVG filter also works when CDN CORS prevents CSS masks. */}
130 <svg aria-hidden className="pointer-events-none absolute size-0" focusable="false">
131 <defs>
132 <filter id={colorFilterId} colorInterpolationFilters="sRGB">
133 <feFlood floodColor="currentColor" />
134 <feComposite in2="SourceAlpha" operator="in" />
135 </filter>
136 </defs>
137 </svg>
138 {status !== "loaded" ? (
139 <ACPRegistryIcon
140 className={cn("size-4", fallbackClassName)}
141 data-slot="acp-icon-fallback"
142 />
143 ) : null}
144 {source !== null && status !== "failed" ? (
145 <img
146 alt=""
147 className={cn(
148 "size-full object-contain",
149 status !== "loaded" && "invisible absolute inset-0",
150 )}
151 style={{ filter: `url(#${colorFilterId})` }}
152 decoding="async"
153 referrerPolicy="no-referrer"
154 src={source}
155 onError={() => {
156 if (iconUrl === null) return;
157 setImage((current) =>
158 current?.iconUrl === iconUrl && current.source !== source
159 ? current
160 : { iconUrl, source, status: "failed" },
161 );
162 }}
163 onLoad={() => {
164 if (iconUrl !== null) setImage({ iconUrl, source, status: "loaded" });
165 }}
166 />
167 ) : null}
168 </span>
169 );
170}