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. | 1 | import { type CSSProperties, useId } from "react"; |
| 2 | ||
| 3 | import type { AgentLook, AgentSessionStatus, AgentStatus, FaceColor, FaceShape } from "@g1t/contracts"; | |
| 4 | import { lookFromSeed } from "@g1t/contracts/agent-look"; | |
| 5 | ||
| 6 | import { 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 | */ | |
| 25 | export 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. */ | |
| 28 | export 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. */ | |
| 35 | export 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 | */ | |
| 46 | const 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. */ | |
| 60 | export 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. */ | |
| 74 | export 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. */ | |
| 80 | const TOP: Record<FaceShape, number> = { round: 14, square: 16, squircle: 14, blob: 14, hex: 12 }; | |
| 81 | ||
| 82 | type HeadProps = { className?: string; fill?: string; stroke?: string; strokeWidth?: number }; | |
| 83 | ||
| 84 | /** The head, in a 100×100 box, leaving room above for an antenna. */ | |
| 85 | function 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. */ | |
| 101 | function 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 | ||
| 110 | export 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 | } |