Skip to content
Source
Sign inSign up

TimelineToolActivityIcon.tsx

276 lines7.8 KB

1import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
2import { type ToolActivityIcon } from "@t3tools/contracts";
3import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
4import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
5import { use, useState } from "react";
6import { T3Wordmark } from "../T3Wordmark";
7import {
8 BotIcon,
9 BrainIcon,
10 CheckIcon,
11 CircleAlertIcon,
12 EyeIcon,
13 GlobeIcon,
14 ListTodoIcon,
15 MessageCircleIcon,
16 SearchIcon,
17 SmartphoneIcon,
18 SquarePenIcon,
19 TerminalIcon,
20 HammerIcon,
21 WrenchIcon,
22 Code2Icon,
23 XIcon,
24 ZapIcon,
25} from "lucide-react";
26import { useAssetUrlState } from "../../assets/assetUrls";
27import { cn } from "~/lib/utils";
28import { TimelineRowCtx } from "./timelineRowContext";
29​
30export const failedToolIconClassName = "text-tool-error-icon/40";
31​
32export function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean {
33 return toolIcon === undefined;
35​
36export type WorkEntryIconName =
37 | "bot"
38 | "brain"
39 | "browser"
40 | "check"
41 | "list-todo"
42 | "circle-alert"
43 | "computer"
44 | "code"
45 | "device"
46 | "eye"
47 | "globe"
48 | "hammer"
49 | "message-circle"
50 | "search"
51 | "square-pen"
52 | "terminal"
53 | "pull-request"
54 | "t3-code"
55 | "wrench"
56 | "x"
57 | "zap";
58​
59function BrowserAppIcon({ className }: { className: string }) {
60 return (
61 <svg
62 viewBox="0 0 24 24"
63 fill="none"
64 stroke="currentColor"
65 strokeWidth="1.9"
66 strokeLinecap="round"
67 strokeLinejoin="round"
68 className={className}
69 aria-hidden
70 >
71 <path d="M8.5 19H7.2C4.4 19 3 17.5 3 14.6V7.4C3 4.5 4.5 3 7.4 3h8.2C18.5 3 20 4.5 20 7.4v2.4" />
72 <circle cx="7.4" cy="7.2" r="0.75" fill="currentColor" stroke="none" />
73 <path d="M11.2 7.2h4.3" />
74 <path d="m12.4 11.4 7.5 2.6-3.4 1.6-1.5 3.6z" fill="currentColor" stroke="none" />
75 </svg>
76 );
78​
79export function ToolActivityIconView(props: {
80 icon: ToolActivityIcon | undefined;
81 fallbackName: WorkEntryIconName;
82 className: string;
83 muted: boolean;
84}) {
85 const { resolvedTheme } = use(TimelineRowCtx);
86 const fallbackClassName = cn(props.className, props.muted && "opacity-70 light:brightness-60");
87 if (!props.icon) {
88 return <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} />;
89 }
90 if (props.icon._tag === "website") {
91 const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32);
92 return src ? (
93 <ToolActivityImageIcon
94 key={src}
95 cacheKey={src}
96 src={src}
97 fallbackName={props.fallbackName}
98 className={props.className}
99 muted={props.muted}
100 />
101 ) : (
102 <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} />
103 );
104 }
105 if (props.icon._tag === "themed-logo") {
106 const src =
107 resolvedTheme === "dark"
108 ? (props.icon.logoUrlDark ?? props.icon.logoUrl)
109 : props.icon.logoUrl;
110 return (
111 <ToolActivityImageIcon
112 key={src}
113 cacheKey={src}
114 src={src}
115 fallbackName={props.fallbackName}
116 className={props.className}
117 muted={props.muted}
118 />
119 );
120 }
121 return (
122 <NativeAppToolActivityIcon
123 app={props.icon.app}
124 fallbackName={props.fallbackName}
125 className={cn(props.className, "size-5")}
126 muted={props.muted}
127 />
128 );
130​
131function NativeAppToolActivityIcon(props: {
132 app: Extract<ToolActivityIcon, { readonly _tag: "native-app" }>["app"];
133 fallbackName: WorkEntryIconName;
134 className: string;
135 muted: boolean;
136}) {
137 const { activeThreadEnvironmentId } = use(TimelineRowCtx);
138 const asset = useAssetUrlState(activeThreadEnvironmentId, {
139 _tag: "native-app-icon",
140 app: props.app,
141 });
142 if (asset._tag !== "Success") {
143 return (
144 <WorkEntryIcon
145 name={props.fallbackName}
146 className={cn(props.className, props.muted && "opacity-70 light:brightness-60")}
147 />
148 );
149 }
150 const cacheKey = getProjectFaviconCacheKey(
151 activeThreadEnvironmentId,
152 JSON.stringify(props.app),
153 asset.url,
154 );
155 return (
156 <ToolActivityImageIcon
157 key={cacheKey}
158 cacheKey={cacheKey}
159 src={asset.url}
160 fallbackName={props.fallbackName}
161 className={props.className}
162 muted={props.muted}
163 />
164 );
166​
167const loadedToolActivityIconSrcs = new Map<string, string>();
168​
169function ToolActivityImageIcon(props: {
170 cacheKey: string;
171 src: string;
172 fallbackName: WorkEntryIconName;
173 className: string;
174 muted: boolean;
175}) {
176 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
177 () => loadedToolActivityIconSrcs.get(props.cacheKey) ?? null,
178 );
179 const isLoading = displayedSrc !== props.src;
180 const handleLoadError = (failedSrc: string) => {
181 if (loadedToolActivityIconSrcs.get(props.cacheKey) === failedSrc) {
182 loadedToolActivityIconSrcs.delete(props.cacheKey);
183 }
184 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
185 };
186 return (
187 <>
188 {displayedSrc === null ? (
189 <WorkEntryIcon
190 name={props.fallbackName}
191 className={cn(props.className, props.muted && "opacity-70 light:brightness-60")}
192 />
193 ) : null}
194 {displayedSrc ? (
195 <span
196 className={cn(
197 props.className,
198 "inline-block overflow-hidden rounded-xs bg-background",
199 props.muted && "dark:opacity-70",
200 )}
201 >
202 <img
203 src={displayedSrc}
204 alt=""
205 aria-hidden
206 decoding="async"
207 referrerPolicy="no-referrer"
208 className={cn("block size-full object-contain", props.muted && "light:brightness-70")}
209 onError={() => handleLoadError(displayedSrc)}
210 />
211 </span>
212 ) : null}
213 {isLoading ? (
214 <img
215 src={props.src}
216 alt=""
217 aria-hidden
218 decoding="async"
219 referrerPolicy="no-referrer"
220 className="hidden"
221 onLoad={() => {
222 loadedToolActivityIconSrcs.set(props.cacheKey, props.src);
223 setDisplayedSrc(props.src);
224 }}
225 onError={() => handleLoadError(props.src)}
226 />
227 ) : null}
228 </>
229 );
231​
232function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
233 switch (name) {
234 case "pull-request":
235 return <PullRequestGlyph.pullRequest className={className} aria-hidden />;
236 case "bot":
237 return <BotIcon className={className} aria-hidden />;
238 case "brain":
239 return <BrainIcon className={className} aria-hidden />;
240 case "browser":
241 case "computer":
242 return <BrowserAppIcon className={className} />;
243 case "code":
244 return <Code2Icon className={className} aria-hidden />;
245 case "device":
246 return <SmartphoneIcon className={className} aria-hidden />;
247 case "t3-code":
248 return <T3Wordmark className={className} aria-hidden />;
249 case "check":
250 return <CheckIcon className={className} aria-hidden />;
251 case "list-todo":
252 return <ListTodoIcon className={className} aria-hidden />;
253 case "circle-alert":
254 return <CircleAlertIcon className={className} aria-hidden />;
255 case "eye":
256 return <EyeIcon className={className} aria-hidden />;
257 case "globe":
258 return <GlobeIcon className={className} aria-hidden />;
259 case "hammer":
260 return <HammerIcon className={className} aria-hidden />;
261 case "search":
262 return <SearchIcon className={className} aria-hidden />;
263 case "message-circle":
264 return <MessageCircleIcon className={className} aria-hidden />;
265 case "square-pen":
266 return <SquarePenIcon className={className} aria-hidden />;
267 case "terminal":
268 return <TerminalIcon className={className} aria-hidden />;
269 case "wrench":
270 return <WrenchIcon className={className} aria-hidden />;
271 case "x":
272 return <XIcon className={className} aria-hidden />;
273 case "zap":
274 return <ZapIcon className={className} aria-hidden />;
275 }