Skip to content
343 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.1import { type CSSProperties, useId } from "react";
2
3import type { AgentLook, AgentSessionStatus, AgentStatus, FaceColor, FaceShape } from "@g1t/contracts";
4import { lookFromSeed } from "@g1t/contracts/agent-look";
5
6import { cn } from "../lib/cn";
7
8/**
9 * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): a small bot
10 * drawn as SVG from its `look`, seven choices its owner makes, or from its
11 * `avatar_seed` when it has chosen none. The same look draws the same face
12 * at every size, from 16 px in a list to 128 px on a profile; at 20 px and
13 * under the fine details (accessory, pattern, highlights) are left off so
14 * what is left reads.
15 *
16 * It is alive but calm: it blinks every few seconds, at a moment and a
17 * pace of its own so a row of faces never blinks in unison, and breathes
18 * a little. Its `state` says what the agent is doing: `working` narrows
19 * its eyes and pulses softly, `asleep` closes them (with a small z above
20 * 40 px), `happy` is a brief grin and a bounce when work finished well.
21 * Everything moves with CSS transforms and opacity alone (app.css, "An
22 * agent's face"), and prefers-reduced-motion stops all of it: the face is
23 * then still, with the eyes a state would have.
24 */
25export type FaceState = "idle" | "working" | "asleep" | "happy";
26
27/** The face an agent's status calls for, where a list shows its status on its face. */
28export function faceStateOf(status: AgentStatus | null | undefined): FaceState {
29 if (status === "working") return "working";
30 if (status === "paused" || status === "out_of_budget") return "asleep";
31 return "idle";
32}
33
34/** The face beside a session: at work while it runs, happy once it finished well. */
35export function sessionFaceOf(status: AgentSessionStatus | null | undefined): FaceState {
36 if (status === "working" || status === "queued") return "working";
37 if (status === "done") return "happy";
38 return "idle";
39}
40
41/**
42 * Each colour as it reads on the dark theme and the light one: a pastel
43 * body on near-black, a step deeper on white, so the ink of the eyes and
44 * mouth holds at least 7:1 on either.
45 */
46const COLORS: Record<FaceColor, { dark: string; light: string }> = {
47 lavender: { dark: "#b6a8ff", light: "#a494f2" },
48 mint: { dark: "#86efc4", light: "#5fd3a6" },
49 peach: { dark: "#ffbd8c", light: "#f5a872" },
50 sky: { dark: "#8ab4ff", light: "#709ff0" },
51 pink: { dark: "#ff9ecf", light: "#f088bf" },
52 lemon: { dark: "#f2dc72", light: "#e5c94f" },
53 teal: { dark: "#7fe3e0", light: "#58cbc8" },
54 coral: { dark: "#ff8f85", light: "#f27a70" },
55 moss: { dark: "#b9d97a", light: "#9dc25a" },
56 slate: { dark: "#a9b4c6", light: "#8d9ab0" },
57};
58
59/** The colours' names as the Face editor says them. */
60export const FACE_COLOR_LABELS: Record<FaceColor, string> = {
61 lavender: "Lavender",
62 mint: "Mint",
63 peach: "Peach",
64 sky: "Sky",
65 pink: "Pink",
66 lemon: "Lemon",
67 teal: "Teal",
68 coral: "Coral",
69 moss: "Moss",
70 slate: "Slate",
71};
72
73/** A colour's swatch, for the editor: the dark theme's body on dark, the light one's on light. */
74export function faceColorCss(color: FaceColor): string {
75 const c = COLORS[color];
76 return `light-dark(${c.light}, ${c.dark})`;
77}
78
79/** Where each head shape's top is, for the antenna and the hat to sit on. */
80const TOP: Record<FaceShape, number> = { round: 14, square: 16, squircle: 14, blob: 14, hex: 12 };
81
82type HeadProps = { className?: string; fill?: string; stroke?: string; strokeWidth?: number };
83
84/** The head, in a 100×100 box, leaving room above for an antenna. */
85function Head({ shape, ...p }: { shape: FaceShape } & HeadProps) {
86 switch (shape) {
87 case "round":
88 return <circle cx={50} cy={54} r={40} {...p} />;
89 case "square":
90 return <rect x={12} y={16} width={76} height={76} rx={14} {...p} />;
91 case "squircle":
92 return <rect x={10} y={14} width={80} height={80} rx={30} {...p} />;
93 case "blob":
94 return <path d="M50 14 C 72 12, 92 28, 90 52 C 88 78, 70 94, 48 92 C 24 90, 8 74, 10 50 C 12 28, 30 16, 50 14 Z" {...p} />;
95 case "hex":
96 return <polygon points="50,12 86,32 86,76 50,96 14,76 14,32" strokeLinejoin="round" {...p} />;
97 }
98}
99
100/** FNV-1a, 32 bits: the seed as a number, for a blink of its own. */
101function hash(text: string): number {
102 let h = 0x811c9dc5;
103 for (let i = 0; i < text.length; i++) {
104 h ^= text.charCodeAt(i);
105 h = Math.imul(h, 0x01000193);
106 }
107 return h >>> 0;
108}
109
110export function AgentFace({
111 look,
112 seed = "agent",
113 size = 20,
114 state = "idle",
115 className,
116}: {
117 /** The face to draw; left out, the seed's. */
118 look?: AgentLook | null;
119 /** What the face is drawn from when there is no look, and what times its blink. */
120 seed?: string;
121 size?: number;
122 state?: FaceState;
123 className?: string;
124}) {
125 const face = look ?? lookFromSeed(seed);
126 const id = useId();
127 const tiny = size <= 20;
128 const small = size <= 32;
129 // Strokes in the 100-box: thicker as the face gets smaller, so a mouth is a mouth at 16 px.
130 const sw = tiny ? 9 : small ? 7 : 5.5;
131 const colour = COLORS[face.color];
132 const h = hash(seed || "agent");
133 // Blinks 3.6 to 6.8 s apart, starting anywhere in the cycle; breathing on its own beat too.
134 const blinkPeriod = (3600 + (h % 3200)) / 1000;
135 const blinkDelay = -(((h >>> 8) % 6000) / 1000);
136 const bobDelay = -(((h >>> 16) % 4000) / 1000);
137 const happy = state === "happy";
138 const asleep = state === "asleep";
139 const eyes = happy ? "happy" : face.eyes;
140 const mouth = happy ? "grin" : face.mouth;
141 const top = TOP[face.shape];
142 const clip = `${id}-clip`;
143 const sheen = `${id}-sheen`;
144 const style = {
145 "--face-body": `light-dark(${colour.light}, ${colour.dark})`,
146 "--face-blink-period": `${blinkPeriod}s`,
147 "--face-blink-delay": `${blinkDelay}s`,
148 "--face-bob-delay": `${bobDelay}s`,
149 } as CSSProperties;
150 return (
151 <svg
152 viewBox="0 0 100 100"
153 width={size}
154 height={size}
155 aria-hidden="true"
156 data-state={state}
157 data-size={tiny ? "tiny" : small ? "small" : "full"}
158 className={cn("agent-face shrink-0 overflow-visible", className)}
159 style={style}
160 >
161 <defs>
162 <clipPath id={clip}>
163 <Head shape={face.shape} />
164 </clipPath>
165 {face.pattern === "gradient" && !tiny && (
166 <linearGradient id={sheen} x1="0" y1="0" x2="0" y2="1">
167 <stop offset="0" stopColor="#ffffff" stopOpacity="0.45" />
168 <stop offset="0.55" stopColor="#ffffff" stopOpacity="0" />
169 </linearGradient>
170 )}
171 </defs>
172 <g className="face-bob">
173 {/* The antenna, behind the head. */}
174 {face.antenna === "single" && (
175 <g className="face-antenna" stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round">
176 <line x1={50} y1={top} x2={50} y2={7} />
177 <circle cx={50} cy={6} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
178 </g>
179 )}
180 {face.antenna === "double" && (
181 <g className="face-antenna" stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round">
182 <line x1={38} y1={top + 1} x2={31} y2={6} />
183 <line x1={62} y1={top + 1} x2={69} y2={6} />
184 <circle cx={30} cy={5} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
185 <circle cx={70} cy={5} r={tiny ? 5.5 : 4.5} fill="var(--face-ink-out)" stroke="none" />
186 </g>
187 )}
188 {face.antenna === "bulb" && (
189 <g className="face-antenna">
190 <line x1={50} y1={top} x2={50} y2={10} stroke="var(--face-ink-out)" strokeWidth={sw * 0.8} strokeLinecap="round" />
191 <circle className="face-bulb" cx={50} cy={7.5} r={tiny ? 7 : 6.5} fill="var(--face-glow)" stroke="var(--face-ink-out)" strokeWidth={tiny ? 0 : 2.5} />
192 </g>
193 )}
194 {/* The head: its colour, its pattern, its rim; and the glow a working face pulses. */}
195 <Head shape={face.shape} className="face-glow" fill="none" stroke="var(--face-glow)" strokeWidth={10} />
196 <Head shape={face.shape} fill="var(--face-body)" />
197 {!tiny && face.pattern === "stripe" && (
198 <g clipPath={`url(#${clip})`} fill="var(--face-ink)" opacity={0.12}>
199 <polygon points="0,100 100,0 100,16 16,100" />
200 <polygon points="0,78 78,0 90,0 0,90" />
201 </g>
202 )}
203 {!tiny && face.pattern === "dots" && (
204 <g clipPath={`url(#${clip})`} fill="var(--face-ink)" opacity={0.14}>
205 {[
206 [20, 28],
207 [36, 20],
208 [54, 18],
209 [72, 24],
210 [84, 40],
211 [18, 70],
212 [26, 86],
213 [50, 90],
214 [74, 86],
215 [86, 70],
216 ].map(([x, y]) => (
217 <circle key={`${x}:${y}`} cx={x} cy={y} r={4} />
218 ))}
219 </g>
220 )}
221 {!tiny && face.pattern === "gradient" && <Head shape={face.shape} fill={`url(#${sheen})`} />}
222 <Head shape={face.shape} fill="none" stroke="var(--face-rim)" strokeWidth={tiny ? 4 : 3} />
223
224 {/* Eyes, which blink, narrow when working and close asleep. */}
225 <g className="face-eyes">
226 {eyes === "dots" && (
227 <g fill="var(--face-ink)">
228 <circle cx={36} cy={50} r={tiny ? 7 : 5.5} />
229 <circle cx={64} cy={50} r={tiny ? 7 : 5.5} />
230 </g>
231 )}
232 {eyes === "round" && (
233 <g>
234 <circle cx={36} cy={50} r={9} fill="var(--face-ink)" />
235 <circle cx={64} cy={50} r={9} fill="var(--face-ink)" />
236 {!tiny && (
237 <g className="face-shine" fill="var(--face-light)">
238 <circle cx={39} cy={47} r={3} />
239 <circle cx={67} cy={47} r={3} />
240 </g>
241 )}
242 </g>
243 )}
244 {eyes === "wide" && (
245 <g>
246 <circle cx={36} cy={50} r={12} fill="var(--face-light)" />
247 <circle cx={64} cy={50} r={12} fill="var(--face-light)" />
248 <g className="face-pupils" fill="var(--face-ink)">
249 <circle cx={37} cy={51} r={6.5} />
250 <circle cx={65} cy={51} r={6.5} />
251 </g>
252 {!tiny && (
253 <g className="face-shine" fill="var(--face-light)">
254 <circle cx={39.5} cy={48.5} r={2.2} />
255 <circle cx={67.5} cy={48.5} r={2.2} />
256 </g>
257 )}
258 </g>
259 )}
260 {eyes === "happy" && (
261 <g fill="none" stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round">
262 <path d="M27 54 Q36 42 45 54" />
263 <path d="M55 54 Q64 42 73 54" />
264 </g>
265 )}
266 {eyes === "visor" && (
267 <g>
268 <rect x={21} y={41} width={58} height={18} rx={9} fill="var(--face-ink)" />
269 <g className="face-pupils" fill="var(--face-glow)">
270 <circle cx={36} cy={50} r={tiny ? 4.5 : 3.6} />
271 <circle cx={64} cy={50} r={tiny ? 4.5 : 3.6} />
272 </g>
273 </g>
274 )}
275 {eyes === "sleepy" && (
276 <g fill="var(--face-ink)">
277 <path d="M27 50 A9 9 0 0 0 45 50 Z" />
278 <path d="M55 50 A9 9 0 0 0 73 50 Z" />
279 </g>
280 )}
281 </g>
282
283 {/* The mouth. */}
284 {mouth === "smile" && <path d="M40 68 Q50 78 60 68" fill="none" stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round" />}
285 {mouth === "grin" && (
286 <g>
287 <path d="M37 66 Q50 84 63 66 Z" fill="var(--face-ink)" />
288 {!tiny && <path d="M40 67 Q50 71 60 67 L60 70 Q50 74 40 70 Z" fill="var(--face-light)" />}
289 </g>
290 )}
291 {mouth === "flat" && <line x1={42} y1={70} x2={58} y2={70} stroke="var(--face-ink)" strokeWidth={sw} strokeLinecap="round" />}
292 {mouth === "open" && <ellipse cx={50} cy={70} rx={tiny ? 7 : 6} ry={tiny ? 8 : 7} fill="var(--face-ink)" />}
293
294 {/* Accessories, over everything; left off at 20 px and under. */}
295 {!tiny && face.accessory === "glasses" && (
296 <g fill="none" stroke="var(--face-ink)" strokeWidth={3.5} strokeLinecap="round">
297 <circle cx={36} cy={50} r={14} />
298 <circle cx={64} cy={50} r={14} />
299 <line x1={50} y1={49} x2={50} y2={49} strokeWidth={5} />
300 <line x1={20} y1={46} x2={14} y2={43} />
301 <line x1={80} y1={46} x2={86} y2={43} />
302 </g>
303 )}
304 {!tiny && face.accessory === "headphones" && (
305 <g>
306 <path d={`M 14 56 A 36 36 0 0 1 86 56`} fill="none" stroke="var(--face-ink-out)" strokeWidth={5} strokeLinecap="round" />
307 <rect x={6} y={48} width={14} height={22} rx={5} fill="var(--face-ink-out)" />
308 <rect x={80} y={48} width={14} height={22} rx={5} fill="var(--face-ink-out)" />
309 </g>
310 )}
311 {!tiny && face.accessory === "bow" && (
312 <g fill="var(--face-ribbon)" stroke="var(--face-ink-out)" strokeWidth={2} strokeLinejoin="round">
313 <path d={`M74 ${top + 6} L60 ${top - 2} L62 ${top + 14} Z`} />
314 <path d={`M74 ${top + 6} L88 ${top - 2} L86 ${top + 14} Z`} />
315 <circle cx={74} cy={top + 6} r={4} />
316 </g>
317 )}
318 {!tiny && face.accessory === "hat" && (
319 <g fill="var(--face-ink-out)">
320 <rect x={30} y={top - 13} width={40} height={16} rx={5} />
321 <rect x={23} y={top - 2} width={54} height={5.5} rx={2.75} />
322 <rect x={30} y={top - 6} width={40} height={3} fill="var(--face-ribbon)" />
323 </g>
324 )}
325 {!tiny && face.accessory === "spark" && (
326 <path className="face-spark" d={`M84 ${top - 8} L87 ${top - 1} L94 ${top + 2} L87 ${top + 5} L84 ${top + 12} L81 ${top + 5} L74 ${top + 2} L81 ${top - 1} Z`} fill="var(--face-spark)" />
327 )}
328
329 {/* Asleep: a z or two, above 40 px. */}
330 {asleep && size >= 40 && (
331 <g className="face-zz" fill="var(--face-ink-out)" fontFamily="var(--font-sans)" fontWeight={700}>
332 <text x={76} y={28} fontSize={18} className="face-z">
333 z
334 </text>
335 <text x={86} y={16} fontSize={12} className="face-z face-z-2">
336 z
337 </text>
338 </g>
339 )}
340 </g>
341 </svg>
342 );
343}