1import { Image } from "expo-image";
2import { Defs, LinearGradient, Path, Stop, Svg } from "react-native-svg";
3import { View } from "react-native";
4import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
5import { useId, useState } from "react";
6import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
7import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
8import { AppText as Text } from "./AppText";
9import { getProviderClient } from "../lib/providerClients";
10
11type ProviderIconProps = {
12 readonly provider: string | null | undefined;
13 readonly iconUrl?: string | null | undefined;
14 readonly size?: number;
15};
16
17function AcpRegistryFallbackIcon(props: { readonly color: string; readonly size: number }) {
18 return (
19 <Svg width={props.size} height={props.size} viewBox="0 0 576 220" fill="none">
20 <Path
21 fill={props.color}
22 d="M568.003 115.821 517.278 27.966C507.183 10.482 489.084.023 468.894.023c-20.167 0-38.22 10.413-48.338 27.852L343.251 161.75H242.755c-6.525 0-12.369-3.365-15.62-9.004-3.274-5.639-3.274-12.369 0-18.03l50.726-87.855c3.251-5.639 9.094-9.027 15.62-9.027 6.525 0 12.346 3.365 15.62 9.027l3.024 5.229a6.81 6.81 0 0 0 5.911 3.411c2.433 0 4.707-1.319 5.912-3.433l13.437-23.555c1.41-2.479 1.137-5.571-.682-7.753C325.699 7.57 309.874 0 293.322 0c-.66 0-1.319 0-2.001.045-19.281.705-36.561 11.141-46.247 27.898l-44.859 77.714-44.405-76.509C145.465 11.209 126.594.023 106.608.023c-.659 0-1.319 0-2.001.045-19.28.705-36.56 11.141-46.246 27.898L7.658 115.821c-13.915 24.078-8.526 52.999 13.392 71.938 8.844 7.663 20.554 11.869 32.968 11.869h94.63c2.433 0 4.684-1.296 5.912-3.411l13.96-24.191a6.81 6.81 0 0 0 0-6.821c-1.228-2.115-3.479-3.411-5.912-3.411H56.042c-6.526 0-12.369-3.365-15.62-9.004-3.275-5.638-3.275-12.368 0-18.03l50.725-87.854c3.252-5.639 9.095-9.027 15.62-9.027 6.526 0 12.346 3.365 15.62 9.027l72.439 125.62c.205.364.432.682.705 1 3.229 5.139 7.299 9.959 12.255 14.256 8.845 7.662 20.554 11.869 32.968 11.869h80.67l-5.843 10.118a6.81 6.81 0 0 0 0 6.821c1.228 2.114 3.478 3.41 5.911 3.41h27.944c2.432 0 4.683-1.296 5.911-3.41l9.049-15.689 2.774-4.433.114-.205 85.99-149.334c3.251-5.639 9.095-9.027 15.62-9.027 6.526 0 12.369 3.365 15.62 9.027l50.726 87.855c3.251 5.639 3.274 12.391 0 18.03-3.252 5.639-9.095 9.027-15.62 9.027H418.669c-2.433 0-4.684 1.296-5.912 3.41l-13.983 24.192a6.81 6.81 0 0 0 0 6.821c1.228 2.114 3.479 3.41 5.912 3.41H518.21c21.6 0 41.085-11.436 50.816-29.83 9.027-17.053 8.64-37.22-1.045-54Z"
23 />
24 </Svg>
25 );
26}
27
28function AcpRegistryProviderIcon(props: {
29 readonly color: string;
30 readonly iconUrl: string | null | undefined;
31 readonly size: number;
32}) {
33 const iconUrl = resolveOfficialAcpRegistryIconUrl(props.iconUrl);
34 const [image, setImage] = useState<{
35 readonly iconUrl: string;
36 readonly status: "loaded" | "failed";
37 } | null>(null);
38 const currentImage = image?.iconUrl === iconUrl ? image : null;
39 const loaded = currentImage?.status === "loaded";
40
41 return (
42 <View style={{ width: props.size, height: props.size }}>
43 {!loaded ? <AcpRegistryFallbackIcon color={props.color} size={props.size} /> : null}
44 {iconUrl !== null && currentImage?.status !== "failed" ? (
45 <Image
46 accessibilityIgnoresInvertColors
47 cachePolicy="memory-disk"
48 contentFit="contain"
49 recyclingKey={iconUrl}
50 source={{ uri: iconUrl }}
51 style={{
52 position: "absolute",
53 width: props.size,
54 height: props.size,
55 opacity: loaded ? 1 : 0,
56 tintColor: props.color,
57 }}
58 onError={() => setImage({ iconUrl, status: "failed" })}
59 onLoad={() => setImage({ iconUrl, status: "loaded" })}
60 />
61 ) : null}
62 </View>
63 );
64}
65
66export function ProviderIcon(props: ProviderIconProps) {
67 const { themeAppearance } = useAppearancePreferences();
68 const isDarkMode = themeAppearance === "dark";
69 const gradientId = useId();
70 const size = props.size ?? 16;
71 const mono = isDarkMode ? "#e5e5e5" : "#171717";
72
73 if (props.provider?.trim().toLowerCase() === "antigravity") {
74 return (
75 <Image
76 source={require("../../assets/antigravity.png")}
77 style={{ width: size, height: size }}
78 contentFit="contain"
79 />
80 );
81 }
82 if (props.provider === "acpRegistry") {
83 return <AcpRegistryProviderIcon color={mono} iconUrl={props.iconUrl} size={size} />;
84 }
85
86 if (props.provider === "claudeAgent") {
87 return (
88 <Svg width={size} height={size} viewBox="0 0 256 257" fill="none">
89 <Path
90 fill="#D97757"
91 d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
92 />
93 </Svg>
94 );
95 }
96
97 const packageIcon = getProviderClient(props.provider)?.icon;
98 if (packageIcon) {
99 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
100 isDarkMode ? fill.dark : fill.light;
101 return (
102 <Svg width={size} height={size} viewBox={packageIcon.viewBox} fill="none">
103 <Defs>
104 {packageIcon.paths.map((path, index) =>
105 path.gradient ? (
106 <LinearGradient
107 key={path.d}
108 id={`${gradientId}-${index}`}
109 x1={path.gradient.x1}
110 y1={path.gradient.y1}
111 x2={path.gradient.x2}
112 y2={path.gradient.y2}
113 gradientUnits="userSpaceOnUse"
114 >
115 {path.gradient.stops.map((stop) => (
116 <Stop key={stop.offset} offset={stop.offset} stopColor={stop.color} />
117 ))}
118 </LinearGradient>
119 ) : null,
120 )}
121 </Defs>
122 {packageIcon.paths.map((path, index) => (
123 <Path
124 key={path.d}
125 d={path.d}
126 fillRule={path.fillRule}
127 fill={
128 path.gradient
129 ? `url(#${gradientId}-${index})`
130 : themeFill(path.fill ?? packageIcon.fill)
131 }
132 />
133 ))}
134 </Svg>
135 );
136 }
137
138 // codex (and unknown drivers)
139 return (
140 <Svg width={size} height={size} viewBox="100 100 411 411" fill="none">
141 <Path
142 fill={mono}
143 fillRule="evenodd"
144 clipRule="evenodd"
145 d="M252.794 108.802C289.191 99.0484 326.265 110.305 351.148 135.135C385.113 126.072 422.85 134.862 449.492 161.505C476.136 188.149 484.925 225.888 475.862 259.85V259.854C500.696 284.735 511.95 321.81 502.198 358.207C492.447 394.602 464.161 421.084 430.215 430.217C421.083 464.162 394.603 492.448 358.206 502.199C321.812 511.951 284.734 500.693 259.852 475.864C225.887 484.927 188.15 476.137 161.507 449.495C134.864 422.851 126.073 385.111 135.136 351.149C110.304 326.266 99.0496 289.192 108.801 252.795C118.552 216.4 146.84 189.918 180.784 180.785C189.917 146.841 216.396 118.553 252.794 108.802ZM374.292 407.145C374.292 411.271 372.092 415.086 368.517 417.148L283.723 466.102C302.487 480.585 327.555 486.459 352.217 479.852C386.997 470.532 410.068 439.312 410.555 405.006V317.717C410.555 315.08 409.125 312.621 406.843 311.303L374.292 292.509V407.145ZM251.868 415.897C248.296 417.959 243.893 417.959 240.317 415.897L155.526 366.942C152.366 390.436 159.811 415.08 177.866 433.136H177.863C203.325 458.594 241.896 462.962 271.85 446.232L347.449 402.586C349.735 401.268 351.148 398.8 351.148 396.163V358.579L251.868 415.897ZM368.602 220.628C366.319 219.309 363.474 219.318 361.191 220.637L328.641 239.431L427.921 296.749C431.496 298.811 433.697 302.627 433.697 306.752V404.661C455.622 395.654 473.244 376.881 479.851 352.218C489.169 317.442 473.668 281.85 444.201 264.274L368.602 220.628ZM177.303 206.34C155.377 215.348 137.756 234.122 131.148 258.783C121.832 293.561 137.331 329.153 166.799 346.727L242.398 390.373C244.68 391.692 247.525 391.684 249.807 390.366L282.357 371.572L183.078 314.253C179.504 312.189 177.303 308.375 177.303 304.251V206.34ZM259.849 279.145V331.858L305.5 358.213L351.15 331.858V279.145L305.5 252.789L259.849 279.145ZM327.276 144.9C308.512 130.418 283.445 124.543 258.782 131.15C224.002 140.471 200.931 171.691 200.445 205.995V293.286C200.445 295.923 201.875 298.381 204.158 299.7L236.707 318.493V203.856C236.707 199.731 238.909 195.916 242.483 193.853L327.276 144.9ZM433.137 177.867C407.675 152.407 369.103 148.038 339.149 164.769L263.55 208.415C261.265 209.734 259.852 212.202 259.852 214.838V252.423L359.132 195.105C362.703 193.041 367.108 193.041 370.682 195.105L455.473 244.06C458.635 220.567 451.189 195.922 433.135 177.867H433.137Z"
146 />
147 </Svg>
148 );
149}
150
151/**
152 * `ProviderIcon` plus the web sidebar's account badge: an accent-color
153 * initials bubble in the bottom-right corner, drawn when `showBadge` is set
154 * (accent color present, or several instances share this driver). The glyph
155 * dims to 60% opacity while the badge stays fully saturated, matching
156 * `apps/web/src/components/chat/ProviderInstanceIcon.tsx`.
157 */
158export function ProviderInstanceIcon(props: {
159 readonly iconUrl?: string | null;
160 readonly provider: string | null | undefined;
161 readonly size?: number;
162 readonly displayName: string;
163 readonly accentColor?: string;
164 readonly showBadge?: boolean;
165 readonly surfaceColor: string;
166}) {
167 return (
168 <View style={{ position: "relative" }}>
169 <View style={{ opacity: 0.6 }}>
170 <ProviderIcon iconUrl={props.iconUrl} provider={props.provider} size={props.size} />
171 </View>
172 {props.showBadge ? (
173 <View
174 className={props.accentColor ? undefined : "bg-card"}
175 style={{
176 position: "absolute",
177 right: -3,
178 bottom: -3,
179 height: 12,
180 minWidth: 12,
181 paddingHorizontal: 2,
182 borderRadius: 999,
183 borderWidth: 1,
184 borderColor: props.surfaceColor,
185 backgroundColor: props.accentColor,
186 alignItems: "center",
187 justifyContent: "center",
188 }}
189 >
190 <Text
191 className={props.accentColor ? undefined : "text-foreground-muted"}
192 style={{
193 fontSize: 7,
194 fontWeight: "600",
195 lineHeight: 9,
196 color: props.accentColor ? "#ffffff" : undefined,
197 }}
198 >
199 {providerInstanceInitials(props.displayName)}
200 </Text>
201 </View>
202 ) : null}
203 </View>
204 );
205}