Skip to content

Commit

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.

syntaqxcommitted Parent29f6d24Browse files
64 files+1410−2710/64 viewed
+8−0
6060 - Hover hints use the `Tooltip` component, never `title=`. Native
6161 `<select>`, file inputs, checkboxes and radios use their `ui`
6262 components.
63+- **Every avatar is `Avatar`** (`ui/avatar.tsx`), and every agent's is
64+ `Avatar` with `agent` set, or `AgentAvatar` (`components/agent-avatar.tsx`),
65+ which wraps it. That draws the agent's bot face (`AgentFace`,
66+ `components/agent-face.tsx`) from its `look` or its `avatar_seed`, puts
67+ the agent marker in its corner and names it "…, agent" to a screen
68+ reader. Never hand-draw an agent's face, a sparkle, a letter on a tile or
69+ the marker at a call site: a place that shows an agent differently is a
70+ bug.
6371 - Look at every page you change, in light and dark, on a desktop and a
6472 phone, before calling it done.
6573
+70−2
7676 personal one.
7777 3. Choose **Draft it**. g1t drafts the whole agent with one quick model
7878 call, charged to you: a name (choose **Another name** to shuffle), a
79− handle, a face (choose **Another face**), a title, its job, its
79+ handle, a [face](#its-face) of its own, a title, its job, its
8080 responsibilities and its personality, which
8181 [skills](/guides/agent-skills/) it keeps on, the integrations it would
8282 use, routines that would suit it, its model limits and its budget.
353353 Add free text to refine the preset: *Answers in Spanish when asked in
354354 Spanish.* or *Uses British spelling.*
355355
356+## Its face
357+
358+Every agent has a face: a small bot, drawn from seven parts, that shows
359+wherever the agent does: in Chat, on its profile, in the People directory,
360+as an assignee or a reviewer, on the commits it authors, in notifications.
361+Nothing is uploaded; the face is drawn on the page, so it is sharp at any
362+size and the same everywhere.
363+
364+| Part | Choices |
365+| --- | --- |
366+| **Shape** | Round, square, squircle, blob, hex |
367+| **Colour** | Lavender, mint, peach, sky, pink, lemon, teal, coral, moss, slate. Each reads on the light theme and the dark one. |
368+| **Eyes** | Dots, round, wide, happy, visor, sleepy |
369+| **Mouth** | Smile, grin, flat, open, none |
370+| **Antenna** | None, one, two, bulb |
371+| **Accessory** | Nothing, headphones, bow, glasses, hat, spark |
372+| **Pattern** | Plain, stripes, dots, sheen |
373+
374+An agent you haven't given a face wears the one drawn from its `avatar_seed`
375+(its handle, unless you set another): the same seed always draws the same
376+face, so every agent has a face of its own from the moment it's made, and a
377+rename never changes it. Faces are alive but calm: each blinks every few
378+seconds at a pace of its own, so a list of them never blinks in unison, and
379+breathes a little. What the agent is doing shows on it:
380+
381+| The face | Means |
382+| --- | --- |
383+| Blinking, breathing | Idle. |
384+| Eyes narrowed, a soft pulse | Working on a session or a reply. |
385+| Eyes shut, a small *z* | Paused or out of budget. |
386+| A grin and a bounce, briefly | A session finished well. |
387+
388+If your system asks for reduced motion, faces hold still, in the pose
389+their state would give them. At 20 px and under, the accessory and
390+pattern are left off so the face still reads. g1t itself keeps its own
391+mark, the pixel 1 of the logo, in place of a face. An agent with an
392+uploaded picture shows that instead.
393+
394+**The agent marker is always shown.** Whatever the face, every agent's
395+avatar carries a small bot on a lavender disc in its corner, from 16 px up,
396+and reads to a screen reader as *Margo, agent*. Below 16 px the squircle
397+tile the face sits on tells it from a person's round avatar. Nothing you
398+choose turns the marker off.
399+
400+### Choose a face
401+
402+Open the agent's **Profile** (or the card while you
403+[describe a new one](#describe-it)) and find **Face**:
404+
405+1. The preview shows the face large, and how it looks idle, working,
406+ asleep and happy.
407+2. Choose any part from its row: each choice is drawn as the face would
408+ look with it.
409+3. **Shuffle** draws a face at random. **Reset to its own** gives the agent
410+ back the face its seed draws.
411+4. **Match its voice** borrows the expression its personality preset
412+ suggests (a terse operator gets a visor and a flat mouth, a friendly one
413+ happy eyes and a smile). Only when you choose it: a preset never changes
414+ a face behind your back.
415+5. **Save changes** (or **Create**). The face is part of the agent's
416+ definition, so it is a saved version like any other change.
417+
418+Owners change a workspace agent's face; a member changes their own
419+personal agent's. In the API, the face is `look` on the agent, an object
420+with the seven parts, or `null` for the seed's face. Set it through the
421+same update call as any other field, and read it back wherever the agent
422+is returned.
423+
356424 ## Subagents
357425
358426 Subagents are the specialised help an agent keeps for its own work, the
725793 | **Routines** | Work it does on a schedule or when something happens. See [routines](/guides/agent-routines/). |
726794 | **Spend** | Its [effort](#effort) and what each level has cost it, suggestions to spend less, and spend this month against its budget: by day, kind of work, model and who asked, and its costliest sessions. |
727795 | **Activity** | Its latest replies and sessions, with who asked, the model and the cost. |
728−| **Profile** | Its definition: identity, job, personality, models and effort, budget and what it may do alone, with every saved version. Owners edit a workspace agent here, and a member their own personal agent, in the form or [in words](#change-it-in-words). |
796+| **Profile** | Its definition: identity, [face](#its-face), job, personality, models and effort, budget and what it may do alone, with every saved version. Owners edit a workspace agent here, and a member their own personal agent, in the form or [in words](#change-it-in-words). |
729797
730798 The **Agents** page above them all shows the agent budget, sessions waiting
731799 on you and working now, every agent with its live sessions and its month's
+24−2
173173 | --- | --- |
174174 | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. |
175175 | **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. |
176−| **Direct messages** | Your conversations with people and with agents, the latest first. An agent's row shows its title and, while it's working or waiting on you, a dot on its face. The pencil starts a new message. |
177−| **Agents** | The workspace's agents you haven't talked to yet, @g1t first. Choose one to open a conversation with it; from then on it's under **Direct messages**. **All agents** opens Agents. Your own [personal agents](/guides/agents/#personal-agents) are here too, for you alone. Owners hire one with **+**, and members create a personal one. |
176+| **People** | Your direct messages with people, the latest first. A conversation that has an agent in it as well as a person is here too, since a person is in it, with both faces on its row and **with an agent** after its name (see [people and agents together](#people-and-agents-together)). The pencil starts a new message. Empty, it says **Message a teammate.** |
177+| **Agents** | Your direct messages with agents alone, the latest first, then the workspace's agents you haven't talked to yet, @g1t first. Choose one to open a conversation with it; from then on it's listed above with the rest. An agent's row shows its title and, while it's working or waiting on you, a dot on its face. **All agents** opens Agents. Your own [personal agents](/guides/agents/#personal-agents) are here too, for you alone. Owners hire one with **+**, and members create a personal one. Empty, it says **Message an agent.** |
178178
179+People and agents are never mixed in one list: a conversation with an
180+agent is always under **Agents**, and anywhere an agent appears, its
181+avatar carries the agent marker, a small bot on a lavender disc in its
182+corner (see [an agent's face](/guides/agents/#its-face)).
183+
179184 Unread conversations are in bold with a count; mentions of you are counted
180185 in lavender. **All**, **Unread** and **Mentions** filter every section, and
181186 **Jump to channel or person** finds any of them by name.
203208 where you asked links to it. See
204209 [hand off](/guides/agents/#hand-off).
205210
211+### People and agents together
212+
213+A direct message can have both people and agents in it, and it is never
214+left to guesswork which is which:
215+
216+| Where | What says an agent is in it |
217+| --- | --- |
218+| The sidebar | The conversation lists under **People**, since a person is in it. Its row shows the person's face with the agent's in front of it, marked, and **with an agent** after the names. |
219+| The header | A chip, always shown: each agent's face and handle, and that it is in the conversation (*@sam, agent, is in this conversation*). |
220+| The composer | Its placeholder names everyone and marks the agents: *Message @mike and @sam (agent)*. |
221+| The first message | The first time you open it on a device, a line above the start of the conversation: *Sam is an agent and reads everything here; it can act on what you say.* Dismiss it and it stays away. |
222+| Every message | An agent's messages carry the agent marker on its avatar and **Agent** beside its name, as everywhere. |
223+
224+Everything said in such a conversation is read by the agents in it, and
225+they answer every message from a person, as in any direct message with an
226+agent.
227+
206228 ## Threads
207229
208230 Reply to any message to start a thread under it. The thread keeps a side
+161−1
6161
6262 --animate-fade-up: fade-up 0.6s ease-out both;
6363 --animate-dash: dash 1.2s linear infinite;
64− /* An agent's face, on hover: a small, quick wiggle (components/agent-avatar.tsx). */
64+ /* A template's creature, on hover: a small, quick wiggle (components/agent-avatar.tsx). */
6565 --animate-wiggle: wiggle 0.45s ease-in-out;
6666
6767 /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */
172172 }
173173
174174 /*
175+ * An agent's face (components/agent-face.tsx). Its colours are the face's
176+ * own body colour (set on the element, a light-dark() pair) and these: the
177+ * ink of its eyes and mouth, a second ink for what sits outside the head,
178+ * the rim around its head, a highlight, the glow of a visor or a working
179+ * face, and two accents for a bow and a spark. It moves with transforms and opacity only: a blink and a breath
180+ * on its own beat (the delays are set per face from its seed), a pulse
181+ * while it works, a bounce when it is happy, a z when it sleeps. The
182+ * reduced-motion rule below stops every one of them, and the state's
183+ * still pose (narrowed or closed eyes) is a plain transform, so it holds.
184+ */
185+.agent-face {
186+ --face-ink: #1b1b22;
187+ /* What sits outside the head (antenna, hat, headphones, a z) reads on the page, not the body. */
188+ --face-ink-out: light-dark(#1b1b22, #e9e9ee);
189+ --face-rim: color-mix(in oklab, var(--face-body), #000 24%);
190+ --face-light: #ffffff;
191+ --face-glow: light-dark(#0d7a52, #86efc4);
192+ --face-ribbon: light-dark(#6b52c8, #b6a8ff);
193+ --face-spark: light-dark(#d4a90f, #f2dc72);
194+ display: inline-block;
195+ vertical-align: middle;
196+}
197+.agent-face .face-bob,
198+.agent-face .face-eyes,
199+.agent-face .face-pupils,
200+.agent-face .face-glow,
201+.agent-face .face-bulb,
202+.agent-face .face-spark,
203+.agent-face .face-z {
204+ transform-box: fill-box;
205+ transform-origin: center;
206+}
207+.agent-face .face-bob {
208+ animation: face-bob 4.2s ease-in-out infinite;
209+ animation-delay: var(--face-bob-delay, 0s);
210+}
211+.agent-face .face-eyes {
212+ animation: face-blink var(--face-blink-period, 5s) ease-in-out infinite;
213+ animation-delay: var(--face-blink-delay, 0s);
214+}
215+.agent-face .face-pupils {
216+ transition: transform 0.25s ease-out;
217+}
218+/* A glance toward the pointer, on hover only. */
219+.agent-face:hover .face-pupils,
220+a:hover > .agent-face .face-pupils,
221+button:hover > .agent-face .face-pupils {
222+ transform: translateX(2.5px);
223+}
224+.agent-face .face-glow {
225+ opacity: 0;
226+}
227+.agent-face .face-spark {
228+ animation: face-twinkle 2.8s ease-in-out infinite;
229+ animation-delay: var(--face-bob-delay, 0s);
230+}
231+/* Working: eyes narrowed, no blink, a soft pulse around the head and in the bulb. */
232+.agent-face[data-state="working"] .face-eyes {
233+ animation: none;
234+ transform: scaleY(0.55);
235+}
236+.agent-face[data-state="working"] .face-glow {
237+ animation: face-pulse 1.6s ease-in-out infinite;
238+}
239+.agent-face[data-state="working"] .face-bulb {
240+ animation: face-bulb 1.6s ease-in-out infinite;
241+}
242+/* Asleep: eyes shut, a slower breath, a z drifting up. */
243+.agent-face[data-state="asleep"] .face-eyes {
244+ animation: none;
245+ transform: scaleY(0.1);
246+}
247+.agent-face[data-state="asleep"] .face-bob {
248+ animation-duration: 6s;
249+}
250+.agent-face[data-state="asleep"] .face-z {
251+ animation: face-zz 2.6s ease-in-out infinite;
252+}
253+.agent-face[data-state="asleep"] .face-z-2 {
254+ animation-delay: 1.3s;
255+}
256+/* Happy: a bounce, twice, then still. */
257+.agent-face[data-state="happy"] .face-bob {
258+ animation: face-happy 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) 2;
259+}
260+@keyframes face-blink {
261+ 0%,
262+ 91%,
263+ 100% {
264+ transform: scaleY(1);
265+ }
266+ 94%,
267+ 96% {
268+ transform: scaleY(0.08);
269+ }
270+}
271+@keyframes face-bob {
272+ 0%,
273+ 100% {
274+ transform: translateY(0);
275+ }
276+ 50% {
277+ transform: translateY(-1.6px);
278+ }
279+}
280+@keyframes face-pulse {
281+ 0%,
282+ 100% {
283+ opacity: 0.15;
284+ }
285+ 50% {
286+ opacity: 0.55;
287+ }
288+}
289+@keyframes face-bulb {
290+ 0%,
291+ 100% {
292+ opacity: 0.6;
293+ transform: scale(1);
294+ }
295+ 50% {
296+ opacity: 1;
297+ transform: scale(1.15);
298+ }
299+}
300+@keyframes face-twinkle {
301+ 0%,
302+ 100% {
303+ opacity: 0.75;
304+ transform: scale(0.9) rotate(0deg);
305+ }
306+ 50% {
307+ opacity: 1;
308+ transform: scale(1.1) rotate(20deg);
309+ }
310+}
311+@keyframes face-zz {
312+ 0% {
313+ opacity: 0;
314+ transform: translate(0, 4px);
315+ }
316+ 30% {
317+ opacity: 1;
318+ }
319+ 100% {
320+ opacity: 0;
321+ transform: translate(5px, -9px);
322+ }
323+}
324+@keyframes face-happy {
325+ 0%,
326+ 100% {
327+ transform: translateY(0) scale(1);
328+ }
329+ 40% {
330+ transform: translateY(-6px) scale(1.06);
331+ }
332+}
333+
334+/*
175335 * Appearance (lib/theme.ts): with no choice, or Auto, the page follows
176336 * the system; a choice of Light or Dark is data-theme on <html>.
177337 */
+23−36
1−import { useMemo } from "react";
1+import type { AgentLook, AgentStatus } from "@g1t/contracts";
22
3−import { G1tMark } from "./orchestrator";
4−
5−import { COLUMNS, ROWS, creature } from "../lib/pixel-creature";
3+import { type FaceState, faceStateOf } from "./agent-face";
64 import { Avatar } from "./ui/avatar";
75
86 /** What an agent's face is drawn from. */
1311 name?: string;
1412 avatar?: string | null;
1513 avatar_seed?: string | null;
14+ /** Its chosen face; null or missing, the seed's. */
15+ look?: AgentLook | null;
1616 builtin?: boolean;
17+ /** Its status, when the place it shows should show it on its face. */
18+ status?: AgentStatus | null;
1719 };
1820
19−/** The seed an agent's creature grows from: its own, else its id, else its handle. */
21+/** The seed an agent's face grows from: its own, else its id, else its handle. */
2022 export function seedOf(agent: AgentLike): string {
2123 return agent.avatar_seed || agent.id || agent.handle || agent.name || "agent";
2224 }
2325
24−/** The pixel creature for a seed, on its own ground; wiggles a little on hover. */
25−export function PixelCreature({ seed, size = 20, className = "" }: { seed: string; size?: number; className?: string }) {
26− const { cells, colour } = useMemo(() => creature(seed), [seed]);
27− // Cells of 10, the 5×7 sprite centred in a 90×90 square.
28− const x0 = (90 - COLUMNS * 10) / 2;
29− const y0 = (90 - ROWS * 10) / 2;
30− return (
31− <span
32− aria-hidden="true"
33− className={`inline-flex shrink-0 items-center justify-center hover:animate-wiggle motion-reduce:animate-none ${className}`}
34− style={{ width: size, height: size, borderRadius: Math.round(size * 0.28), background: colour.ground, boxShadow: `inset 0 0 0 1px ${colour.fg}33` }}
35− >
36− <svg viewBox="0 0 90 90" width={size} height={size} shapeRendering="crispEdges">
37− {cells.map((row, r) =>
38− row.map((cell, c) =>
39− cell === 0 ? null : (
40− <rect key={`${r}:${c}`} x={x0 + c * 10} y={y0 + r * 10} width={10} height={10} fill={cell === 2 ? colour.ground : colour.fg} />
41− ),
42− ),
43− )}
44− </svg>
45− </span>
46− );
47−}
48−
4926 /**
50− * An agent's face wherever it appears: an uploaded picture, g1t's pixel 1,
51− * or its own creature.
27+ * An agent's face wherever it appears: the shared Avatar with `agent` set
28+ * (components/ui/avatar.tsx), so it is an uploaded picture, g1t's pixel 1,
29+ * or its bot face, always with the agent marker. `state` says what the
30+ * face is doing; left out, the agent's status decides, if it is known.
5231 */
53−export function AgentAvatar({ agent, size = 20 }: { agent: AgentLike; size?: number }) {
54− if (agent.builtin || agent.handle === "g1t" || agent.name === "g1t") return <G1tMark size={size} />;
55− if (agent.avatar) return <Avatar name={agent.handle ?? agent.name ?? "agent"} image={agent.avatar} size={size} square />;
56− return <PixelCreature seed={seedOf(agent)} size={size} />;
32+export function AgentAvatar({ agent, size = 20, state, ring }: { agent: AgentLike; size?: number; state?: FaceState; ring?: string }) {
33+ const g1t = agent.builtin || agent.handle === "g1t" || agent.name === "g1t";
34+ return (
35+ <Avatar
36+ name={agent.handle ?? agent.name ?? "agent"}
37+ image={agent.avatar}
38+ system={g1t}
39+ size={size}
40+ ring={ring}
41+ agent={{ look: agent.look ?? null, seed: seedOf(agent), state: state ?? faceStateOf(agent.status) }}
42+ />
43+ );
5744 }
+343−0
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+}
+2−2
66 import { Plus } from "lucide-react";
77 import { Link } from "react-router";
88
9−import { PixelCreature } from "./agent-avatar";
9+import { AgentAvatar } from "./agent-avatar";
1010 import { ButtonLink } from "./ui";
1111 import { buttonVariants } from "./ui/button";
1212 import { Card } from "./ui/card";
4141 <Card asChild className="relative flex flex-col p-4 transition-colors hover:border-line-strong">
4242 <article>
4343 <div className="flex items-start gap-3">
44− <PixelCreature seed={template.handle} size={44} />
44+ <AgentAvatar agent={{ handle: template.handle }} size={44} />
4545 <div className="min-w-0 grow">
4646 <h3 className="truncate text-sm font-semibold">
4747 <Link to={templatePath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
+21−10
22 import { type ReactNode, useState } from "react";
33 import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router";
44
5−import type { AgentEffortCosts, AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts";
5+import type { AgentEffortCosts, AgentLook, AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts";
66
7−import { type AgentLike, AgentAvatar, PixelCreature } from "./agent-avatar";
7+import { type AgentLike, AgentAvatar } from "./agent-avatar";
88 import { StatusDot, statusLabel } from "./chat/marks";
99 import { isOrchestrator } from "./orchestrator";
1010 import { Button } from "./ui/button";
1818 import { EffortPicker } from "./effort";
1919 import { effortSetting } from "../lib/effort";
2020 import { RoleFields, SubagentsField } from "./agent-role";
21+import { FaceEditor } from "./agents/face-editor";
2122 import { type AgentTeamRef, groupByTeam } from "../lib/people";
2223 import type { AgentsLayoutData } from "../routes/workspace/agents/layout";
2324
2627 return useRouteLoaderData("routes/workspace/agents/layout") as AgentsLayoutData | undefined;
2728 }
2829
29−/** An agent's face: its picture, or the sparkle mark. */
30−export function AgentFace({ agent, size = 20 }: { agent: AgentLike; size?: number }) {
31− return <AgentAvatar agent={agent} size={size} />;
30+/** An agent's face: its picture, g1t's mark, or its bot face, with the agent marker (components/agent-avatar.tsx). */
31+export function AgentFace({ agent, size = 20, ring }: { agent: AgentLike; size?: number; ring?: string }) {
32+ return <AgentAvatar agent={agent} size={size} ring={ring} />;
3233 }
3334
3435 function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
115116 }
116117 >
117118 <span className="relative shrink-0">
118− <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} />
119− {!isOrchestrator(agent) && (
120− <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-sidebar" />
119+ <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} ring="var(--color-sidebar)" />
120+ {!isOrchestrator(agent) && agent.status !== "idle" && (
121+ <StatusDot status={agent.status} className="absolute -top-0.5 -right-0.5 ring-2 ring-sidebar" />
121122 )}
122123 </span>
123124 <span className="min-w-0 grow leading-tight">
336337 <Plus size={16} />
337338 </span>
338339 ) : (
339− <PixelCreature seed={seed ?? name} size={40} />
340+ <AgentAvatar agent={{ handle: seed ?? name }} size={40} />
340341 )}
341342 <span className="min-w-0 grow">
342343 <span className="block truncate text-sm font-semibold">{name}</span>
487488 const { pathname } = useLocation();
488489 const busy = navigation.state === "submitting" && navigation.formAction === pathname;
489490 const [preset, setPreset] = useState(draft.personality_preset);
491+ // Its face: the look chosen here, or null for the one its seed draws (components/agents/face-editor.tsx).
492+ const [look, setLook] = useState<AgentLook | null>(draft.look ?? null);
493+ const faceSeed = seed ?? handle ?? "agent";
490494 const [advanced, setAdvanced] = useState(Boolean(draft.routing.pinned));
491495 const e = errors ?? {};
492496 const presetAbout = PRESETS.find((p) => p.value === preset)?.about;
506510 }
507511 >
508512 <div className="flex items-center gap-3">
509− <AgentAvatar agent={{ handle: handle || "agent", avatar_seed: seed ?? handle, builtin: locked }} size={44} />
513+ <AgentAvatar agent={{ handle: handle || "agent", avatar_seed: faceSeed, look, builtin: locked }} size={44} />
510514 <div className="min-w-0 text-sm">
511515 <p className="truncate font-semibold">{name || "Your new agent"}</p>
512516 <p className="truncate text-xs text-muted">{handle ? `@${handle}` : "@handle"}</p>
570574 </FormSection>
571575
572576 {!locked && (
577+ <FormSection title="Face" about="Its bot face, wherever it appears: shape, colour, eyes, mouth and a few extras. It blinks on its own, narrows its eyes while it works and sleeps when it's paused. Every agent has a face of its own from the start; change any part of it here.">
578+ <input type="hidden" name="look" value={look ? JSON.stringify(look) : "null"} />
579+ <FaceEditor value={look} seed={faceSeed} name={name || "Your new agent"} preset={preset} onChange={setLook} />
580+ </FormSection>
581+ )}
582+
583+ {!locked && (
573584 <FormSection
574585 title="Role"
575586 about={
+9−15
44 * chat beside it, and, on an agent's profile, a box to change it in words
55 * with the change shown before it is saved.
66 */
7−import { ArrowUp, Check, Dices, Lock, MessageSquare, Plug, RotateCcw, Shuffle, Sparkles, Users, Wand2 } from "lucide-react";
7+import { ArrowUp, Check, Lock, MessageSquare, Plug, RotateCcw, Shuffle, Sparkles, Users, Wand2 } from "lucide-react";
88 import { type ReactNode, useEffect, useRef, useState } from "react";
99 import { Form, Link, useFetcher, useNavigation } from "react-router";
1010
1111 import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts";
1212
1313 import { TeamPicker } from "../agent-role";
14−import { AgentAvatar, PixelCreature } from "../agent-avatar";
14+import { AgentAvatar } from "../agent-avatar";
15+import { FaceEditor } from "./face-editor";
1516 import { Markdown } from "../markdown";
1617 import { Alert } from "../ui/alert";
1718 import { Badge } from "../ui/badge";
176177 preventScrollReset
177178 className="inline-flex h-8 items-center gap-2 rounded-full border border-line bg-surface pr-3 pl-1 text-[0.8125rem] text-fg/85 transition-colors hover:border-line-strong hover:text-fg"
178179 >
179− <PixelCreature seed={template.handle} size={24} className="rounded-full" />
180+ <AgentAvatar agent={{ handle: template.handle }} size={24} />
180181 {template.title}
181182 </Link>
182183 </li>
265266 <div className="overflow-hidden rounded-2xl border border-line bg-surface">
266267 <div className="flex items-start gap-3 px-4 pt-4 pb-4 sm:px-5">
267268 <div className="relative shrink-0">
268− <AgentAvatar agent={{ handle: value.handle, avatar_seed: seed }} size={56} />
269− <Hint label="Another face">
270− <Button
271− type="button"
272− aria-label="Another face"
273− onClick={() => set({ avatar_seed: `${value.handle || "agent"}-${Math.random().toString(36).slice(2, 7)}` })}
274− variant="outline" size="icon"
275− className="absolute -right-1.5 -bottom-1.5 size-6 rounded-full bg-bg text-muted shadow-sm"
276− >
277− <Dices size={13} />
278− </Button>
279− </Hint>
269+ <AgentAvatar agent={{ handle: value.handle, avatar_seed: seed, look: value.look ?? null }} size={56} />
280270 </div>
281271 <div className="grid min-w-0 grow gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
282272 <div>
332322 </Section>
333323 )}
334324
325+ <Section title="Face" about="Its bot face, wherever it appears. It has one of its own already; change any part, shuffle, or give it back.">
326+ <FaceEditor value={value.look ?? null} seed={seed} name={value.display_name || "Your new agent"} preset={value.personality_preset} onChange={(look) => set({ look })} />
327+ </Section>
328+
335329 <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply.">
336330 <textarea
337331 aria-label="Job"
+140−0
1+import { RotateCcw, Shuffle, Smile } from "lucide-react";
2+
3+import type { AgentLook } from "@g1t/contracts";
4+import { LOOK_KEYS, LOOK_OPTIONS, PRESET_EXPRESSIONS, lookFromSeed, randomLook, sameLook } from "@g1t/contracts/agent-look";
5+
6+import { AgentFace, FACE_COLOR_LABELS, type FaceState, faceColorCss } from "../agent-face";
7+import { Avatar } from "../ui/avatar";
8+import { Button } from "../ui/button";
9+import { Hint } from "../ui/hint";
10+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
11+
12+/** What each choice is called, in the hint over it. */
13+const LABELS: { [K in keyof AgentLook]: Record<AgentLook[K], string> } = {
14+ shape: { round: "Round", square: "Square", squircle: "Squircle", blob: "Blob", hex: "Hex" },
15+ color: FACE_COLOR_LABELS,
16+ eyes: { dots: "Dots", round: "Round", wide: "Wide", happy: "Happy", visor: "Visor", sleepy: "Sleepy" },
17+ mouth: { smile: "Smile", grin: "Grin", flat: "Flat", open: "Open", none: "No mouth" },
18+ antenna: { none: "No antenna", single: "One", double: "Two", bulb: "Bulb" },
19+ accessory: { none: "Nothing", headphones: "Headphones", bow: "Bow", glasses: "Glasses", hat: "Hat", spark: "Spark" },
20+ pattern: { none: "Plain", stripe: "Stripes", dots: "Dots", gradient: "Sheen" },
21+};
22+
23+const PARTS: { [K in keyof AgentLook]: string } = {
24+ shape: "Shape",
25+ color: "Colour",
26+ eyes: "Eyes",
27+ mouth: "Mouth",
28+ antenna: "Antenna",
29+ accessory: "Accessory",
30+ pattern: "Pattern",
31+};
32+
33+const STATES: { state: FaceState; label: string }[] = [
34+ { state: "idle", label: "Idle: it blinks and breathes" },
35+ { state: "working", label: "Working: eyes narrowed, a soft pulse" },
36+ { state: "asleep", label: "Asleep: paused or out of budget" },
37+ { state: "happy", label: "Happy: a session finished well" },
38+];
39+
40+/**
41+ * An agent's face, chosen part by part (docs.g1t.sh/guides/agents/, "Its
42+ * face"): a live preview, each state it can be in, then a row of choices
43+ * for each of the seven parts, drawn as the face would be with that
44+ * choice. Shuffle draws a random face; Reset gives it back the face its
45+ * seed draws, which is what `null` means. A personality preset can lend
46+ * its expression, but only when asked: a chosen look is never changed
47+ * behind someone's back.
48+ */
49+export function FaceEditor({
50+ value,
51+ seed,
52+ name = "agent",
53+ preset,
54+ onChange,
55+}: {
56+ /** The chosen look, or null for the seed's face. */
57+ value: AgentLook | null;
58+ seed: string;
59+ /** The agent's name, for the preview's label. */
60+ name?: string;
61+ /** Its personality preset, whose expression it can borrow. */
62+ preset?: string | null;
63+ onChange: (look: AgentLook | null) => void;
64+}) {
65+ const own = lookFromSeed(seed);
66+ const current = value ?? own;
67+ const expression = preset ? PRESET_EXPRESSIONS[preset] : undefined;
68+ const wearsExpression = expression ? Object.entries(expression).every(([key, choice]) => current[key as keyof AgentLook] === choice) : true;
69+ return (
70+ <div className="grid gap-5 sm:grid-cols-[8rem_minmax(0,1fr)]">
71+ <div className="flex flex-col items-center gap-3 sm:items-start">
72+ <Avatar name={name} size={96} agent={{ look: value, seed }} />
73+ <div role="list" aria-label="How it looks in each state" className="flex items-center gap-2">
74+ {STATES.map(({ state, label }) => (
75+ <Hint key={state} label={label}>
76+ <span role="listitem" className="inline-flex rounded-md">
77+ <AgentFace look={value} seed={seed} size={28} state={state} />
78+ </span>
79+ </Hint>
80+ ))}
81+ </div>
82+ <p className="text-xs text-faint">{value ? "A chosen face." : "Its own face, drawn from its seed."}</p>
83+ </div>
84+ <div className="min-w-0 space-y-3">
85+ <div className="flex flex-wrap gap-2">
86+ <Button type="button" variant="outline" size="sm" onClick={() => onChange(randomLook())}>
87+ <Shuffle size={14} />
88+ Shuffle
89+ </Button>
90+ <Button type="button" variant="outline" size="sm" disabled={!value || sameLook(value, own)} onClick={() => onChange(null)}>
91+ <RotateCcw size={14} />
92+ Reset to its own
93+ </Button>
94+ {expression && (
95+ <Hint label={`A ${preset} voice suggests ${Object.values(expression).join(" and ")}. Only when you choose it.`}>
96+ <Button type="button" variant="ghost" size="sm" disabled={wearsExpression} onClick={() => onChange({ ...current, ...expression })}>
97+ <Smile size={14} />
98+ Match its voice
99+ </Button>
100+ </Hint>
101+ )}
102+ </div>
103+ {LOOK_KEYS.map((key) => (
104+ <div key={key} className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
105+ <span id={`face-${key}`} className="w-20 shrink-0 text-xs font-medium text-fg-soft">
106+ {PARTS[key]}
107+ </span>
108+ <ToggleGroup
109+ type="single"
110+ variant="outline"
111+ size="sm"
112+ value={current[key]}
113+ onValueChange={(choice) => {
114+ if (choice) onChange({ ...current, [key]: choice } as AgentLook);
115+ }}
116+ aria-labelledby={`face-${key}`}
117+ className="min-w-0"
118+ >
119+ {(LOOK_OPTIONS[key] as readonly AgentLook[typeof key][]).map((choice) => (
120+ <Hint key={choice} label={(LABELS[key] as Record<string, string>)[choice]}>
121+ <ToggleGroupItem
122+ value={choice}
123+ aria-label={(LABELS[key] as Record<string, string>)[choice]}
124+ className="h-11 min-w-11 px-1.5 data-[state=on]:shadow-[inset_0_0_0_1.5px_var(--color-accent)]"
125+ >
126+ {key === "color" ? (
127+ <span aria-hidden="true" className="size-6 rounded-full ring-1 ring-black/15 ring-inset" style={{ background: faceColorCss(choice as AgentLook["color"]) }} />
128+ ) : (
129+ <AgentFace look={{ ...current, [key]: choice } as AgentLook} seed={seed} size={36} className="[&_.face-eyes]:animate-none [&_.face-bob]:animate-none" />
130+ )}
131+ </ToggleGroupItem>
132+ </Hint>
133+ ))}
134+ </ToggleGroup>
135+ </div>
136+ ))}
137+ </div>
138+ </div>
139+ );
140+}
+1−0
268268 autonomy: "what it may do alone",
269269 capacity: "capacity",
270270 avatar_seed: "face",
271+ look: "face",
271272 faces: "who it works with",
272273 skills_off: "skills",
273274 abilities: "abilities",
+2−2
101101 {depth > 0 && <CornerDownRight size={14} className="mt-1 shrink-0 text-faint" aria-hidden="true" />}
102102 {showAgent && (
103103 <span className="mt-0.5 shrink-0">
104− <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={22} />
104+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={22} />
105105 </span>
106106 )}
107107 <div className="min-w-0 grow">
154154 <Card asChild className="relative flex flex-col p-4 transition-colors hover:border-line-strong">
155155 <li>
156156 <div className="flex items-center gap-2.5">
157− <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={28} />
157+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={28} />
158158 <div className="min-w-0 grow leading-tight">
159159 <p className="truncate text-sm font-medium">{session.agent_name}</p>
160160 <p className="truncate text-xs text-faint">
+84−3
3838 import { Composer } from "./composer";
3939 import { type LiveState, useChatLive } from "./live";
4040 import { AgentPill, MemberAvatar } from "./marks";
41+import { AgentAvatar } from "../agent-avatar";
4142 import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
4243 import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
4344
6465 import { Button } from "../ui/button";
6566 import { Card } from "../ui/card";
6667 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
67−import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat";
68+import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat";
6869 import { codeAccessPath } from "../../lib/workspace-nav";
6970 import { conversationCache } from "./conversation-cache";
7071 // Reactions and the workspace's own emoji (components/emoji).
599600 const typers = [...typing.values()].map((t) => t.member);
600601 const name = channel.name ?? "";
601602 const archived = channel.kind === "channel" && !!channel.archived_at;
603+ // A mixed direct message names each member and says which are agents.
602604 const placeholder = isDm
603− ? `Message ${others.map(shownName).join(", ") || "yourself"}`
605+ ? isMixed(others)
606+ ? dmPlaceholder(others)
607+ : `Message ${others.map(shownName).join(", ") || "yourself"}`
604608 : `Message #${name}. @ a teammate or an agent`;
605609 const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null;
606610 const cardContext = useMemo(
838842 }) {
839843 const channel = data.channel;
840844 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
845+ const mixed = channel.kind === "dm" && isMixed(others);
841846 return (
842847 <header data-page-head className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
843848 <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden">
896901 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
897902 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
898903 )}
904+ {mixed && <MixedChip agents={agentsAmong(others)} />}
899905 {/* One person: their status under their name, as their card has it. */}
900906 {!agentDm && others.length === 1 && others[0]!.kind === "user" && <PersonStatusLine person={{ id: others[0]!.id, username: others[0]!.name }} className="truncate text-xs leading-tight text-muted" />}
901907 </div>
958964 const channel = data.channel;
959965 if (channel.kind === "dm") {
960966 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
967+ const mixed = isMixed(others);
961968 return (
962969 <div className="px-2 pb-6 sm:px-3">
963970 <div className="flex -space-x-2">
970977 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
971978 {others.map(shownName).join(", ") || "Just you"}
972979 {agent && <AgentPill />}
980+ {mixed && (
981+ <Badge tone="accent" size="md" className="font-normal">
982+ with an agent
983+ </Badge>
984+ )}
973985 </h2>
974986 <p className="mt-1 max-w-xl text-sm text-muted">
975987 {agent
976988 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
977− : "This is the start of your conversation."}
989+ : mixed
990+ ? "A conversation between people and an agent. Everyone here, the agent too, reads every message."
991+ : "This is the start of your conversation."}
978992 </p>
993+ {mixed && <MixedNotice channelId={channel.id} others={others} />}
979994 </div>
980995 );
981996 }
9901005 );
9911006 }
9921007
1008+/**
1009+ * The chip a mixed direct message carries in its header, always: each agent
1010+ * in it by face and handle, said to be an agent, so a person writing to a
1011+ * colleague never forgets who else reads along.
1012+ */
1013+function MixedChip({ agents }: { agents: MemberProfile[] }) {
1014+ const handles = agents.map((a) => `@${shownHandle(a)}`);
1015+ const list = handles.length <= 2 ? handles.join(" and ") : `${handles.slice(0, -1).join(", ")} and ${handles[handles.length - 1]}`;
1016+ return (
1017+ <Badge tone="accent" size="md" className="mt-0.5 max-w-full gap-1.5 pl-1 font-normal">
1018+ <span className="flex shrink-0 -space-x-1">
1019+ {agents.slice(0, 3).map((a) => (
1020+ <AgentAvatar key={a.id} agent={a} size={16} ring="var(--color-bg)" />
1021+ ))}
1022+ </span>
1023+ <span className="truncate">
1024+ {list}, {agents.length === 1 ? "agent" : "agents"}
1025+ <span className="max-md:hidden">, {agents.length === 1 ? "is" : "are"} in this conversation</span>
1026+ </span>
1027+ </Badge>
1028+ );
1029+}
1030+
1031+const MIXED_SEEN_KEY = (channelId: string) => `g1t:chat-mixed-seen:${channelId}`;
1032+
1033+/**
1034+ * The one-line notice above a mixed conversation's first message, the
1035+ * first time a person opens it on this device: who the agents are and
1036+ * that they read everything and can act on it. Dismissed, it stays away.
1037+ */
1038+function MixedNotice({ channelId, others }: { channelId: string; others: MemberProfile[] }) {
1039+ const [seen, setSeen] = useState(true);
1040+ useEffect(() => {
1041+ try {
1042+ setSeen(localStorage.getItem(MIXED_SEEN_KEY(channelId)) === "1");
1043+ } catch {
1044+ setSeen(false);
1045+ }
1046+ }, [channelId]);
1047+ const dismiss = () => {
1048+ setSeen(true);
1049+ try {
1050+ localStorage.setItem(MIXED_SEEN_KEY(channelId), "1");
1051+ } catch {
1052+ // Not kept: it shows again next time, which is fine.
1053+ }
1054+ };
1055+ const words = mixedNotice(others);
1056+ if (seen || !words) return null;
1057+ return (
1058+ <Card tone="bg" radius="lg" className="mt-4 flex max-w-xl items-center gap-2.5 py-2 pr-1.5 pl-3 text-sm text-fg-soft" role="note">
1059+ <span className="flex shrink-0 -space-x-1">
1060+ {agentsAmong(others).slice(0, 3).map((a) => (
1061+ <AgentAvatar key={a.id} agent={a} size={20} ring="var(--color-bg)" />
1062+ ))}
1063+ </span>
1064+ <span className="min-w-0 grow">{words}</span>
1065+ <Hint label="Got it">
1066+ <Button type="button" variant="ghost" size="icon-sm" aria-label="Dismiss" onClick={dismiss} className="shrink-0 text-faint">
1067+ <X size={14} />
1068+ </Button>
1069+ </Hint>
1070+ </Card>
1071+ );
1072+}
1073+
9931074 function DayRule({ label }: { label: string }) {
9941075 return (
9951076 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
+43−25
1−import { Sparkles } from "lucide-react";
2−
3−import type { AgentStatus } from "@g1t/contracts";
1+import type { AgentStatus, MemberProfile } from "@g1t/contracts";
42
53 import { AgentAvatar } from "../agent-avatar";
64 import { WithPresence } from "../presence";
97 import { Avatar } from "../ui/avatar";
108
119 /**
12− * A workspace agent's face when it has no picture: a sparkle on a lavender
13− * square, so agents read apart from people (round letters) at a glance.
14− */
15−export function AgentMark({ size = 20, className }: { size?: number; className?: string }) {
16− return (
17− <span
18− aria-hidden="true"
19− className={cn("inline-flex shrink-0 items-center justify-center bg-accent/18 text-accent ring-1 ring-accent/30 ring-inset", className)}
20− style={{ width: size, height: size, borderRadius: Math.round(size * 0.28) }}
21− >
22− <Sparkles size={Math.max(10, Math.round(size * 0.56))} strokeWidth={2.1} />
23− </span>
24− );
25−}
26−
27−/**
28− * A member's face: a person's avatar, or an agent's picture or mark. With
29− * `presence`, a person's carries their dot (active, away, notifications
30− * paused; components/presence.tsx), cut out of `ring`, the colour behind
31− * it. Agents keep their own status, shown apart.
10+ * A member's face: a person's avatar, or an agent's picture or bot face
11+ * with the agent marker (components/ui/avatar.tsx). With `presence`, a
12+ * person's carries their dot (active, away, notifications paused;
13+ * components/presence.tsx), cut out of `ring`, the colour behind it, which
14+ * also rings the agent marker. Agents keep their own status, shown apart.
3215 */
3316 export function MemberAvatar({
3417 member,
3619 presence = false,
3720 ring,
3821 }: {
39− member: { kind: "user" | "agent"; id?: string; name: string; avatar: string | null; avatar_seed?: string | null };
22+ member: { kind: "user" | "agent"; id?: string; name: string; avatar: string | null; avatar_seed?: string | null; look?: MemberProfile["look"] };
4023 size?: number;
4124 presence?: boolean;
4225 ring?: string;
4326 }) {
44− if (member.kind === "agent") return <AgentAvatar agent={member} size={size} />;
27+ if (member.kind === "agent") return <AgentAvatar agent={member} size={size} ring={ring} />;
4528 const avatar = <Avatar name={member.name} image={member.avatar} size={size} />;
4629 if (!presence) return avatar;
4730 return (
5134 );
5235 }
5336
37+/**
38+ * A person and an agent together, for a mixed direct message's row: the
39+ * person's face behind, the agent's in front with its marker, so the pair
40+ * says at a glance that an agent is in the conversation.
41+ */
42+export function PairAvatar({
43+ person,
44+ agent,
45+ size = 20,
46+ ring = "var(--color-bg)",
47+}: {
48+ person: { kind: "user" | "agent"; id?: string; name: string; avatar: string | null };
49+ agent: { kind: "user" | "agent"; id?: string; name: string; avatar: string | null; avatar_seed?: string | null; look?: MemberProfile["look"] };
50+ size?: number;
51+ ring?: string;
52+}) {
53+ const each = Math.max(16, Math.round(size * 0.85));
54+ const width = Math.round(each * 1.55);
55+ return (
56+ <span className="relative inline-flex shrink-0" style={{ width, height: size + 2 }} aria-hidden="true">
57+ <span className="absolute top-0 left-0 inline-flex rounded-full" style={{ boxShadow: `0 0 0 1.5px ${ring}` }}>
58+ <Avatar name={person.name} image={person.avatar} size={each} />
59+ </span>
60+ <span className="absolute inline-flex rounded-md" style={{ left: width - each, top: size + 2 - each, boxShadow: `0 0 0 1.5px ${ring}` }}>
61+ <AgentAvatar agent={agent} size={each} ring={ring} />
62+ </span>
63+ </span>
64+ );
65+}
66+
5467 /** AGENT, beside an agent's name wherever it speaks. */
5568 export function AgentPill({ className }: { className?: string }) {
69+ // The avatar beside it carries the agent marker; this is the word, for where the name stands alone.
70+ return <AgentWord className={className} />;
71+}
72+
73+function AgentWord({ className }: { className?: string }) {
5674 return (
5775 <span
5876 className={cn(
+38−20
88 import { conversationCache } from "./conversation-cache";
99 import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
1010 import { DndMenu } from "./dnd-menu";
11−import { MemberAvatar, StatusDot, statusLabel } from "./marks";
11+import { MemberAvatar, PairAvatar, StatusDot, statusLabel } from "./marks";
1212 import { AgentAvatar } from "../agent-avatar";
1313 import { PersonStatusEmoji } from "../presence";
1414 import { isOrchestrator } from "../orchestrator";
15+import { Badge } from "../ui/badge";
1516 import { Button } from "../ui/button";
1617 import { Hint } from "../ui/hint";
1718 import { Skeleton } from "../ui/skeleton";
18−import { type ChatFilter, agentDmOf, channelPath, filterEntries, sections } from "../../lib/chat";
19+import { type ChatFilter, agentDmOf, agentsAmong, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat";
1920 import { feedConnected } from "../../lib/notify-client";
2021 import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
2122 import { setMutedConversations } from "../../lib/sound-events";
4445
4546 /**
4647 * Chat mode's sidebar: what you pinned, the channels (and a way to browse
47− * the rest), then direct messages, the latest first, with people and
48− * agents alike. Last, the agents you have not talked to yet, g1t first:
49− * one click opens a conversation with any of them. Sections fold, and
50− * stay folded on this device. All, Unread and Mentions filter every
51− * section. It refreshes itself while the tab is shown, so unread counts
52− * move without a reload.
48+ * the rest), then direct messages in two buckets so it is always clear who
49+ * is on the other side: People, your conversations with people, the latest
50+ * first (a conversation with a person and an agent in it is here, marked
51+ * as such), and Agents, your conversations with agents alone, the latest
52+ * first, then the agents you have not talked to yet, g1t first: one click
53+ * opens a conversation with any of them. Sections fold, and stay folded on
54+ * this device. All, Unread and Mentions filter every section. It refreshes
55+ * itself while the tab is shown, so unread counts move without a reload.
5356 */
5457 export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
5558 const data = useChatData();
8487 }, [sidebar]);
8588 const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e));
8689 const shown = filterEntries(entries, filter, query);
87− const { pinned, channels, dms } = sections(shown);
90+ const { pinned, channels, people, agents: agentConversations } = sections(shown);
8891 const { agentDms } = sections(entries);
8992 const loading = !data;
9093 const owner = data?.role === "owner";
165168 onChange={(event) => setQuery(event.target.value)}
166169 onKeyDown={(event) => {
167170 if (event.key === "Enter") {
168− const first = [...pinned, ...channels, ...dms][0];
171+ const first = [...pinned, ...channels, ...people, ...agentConversations][0];
169172 if (first) {
170173 navigate(channelPath(slug, first.channel));
171174 setQuery("");
232235 )}
233236 </Section>
234237 <Section
235− id="dms"
236− title="Direct messages"
238+ id="people"
239+ title="People"
237240 folded={folded}
238241 onFold={fold}
239242 action={<NewMessageButton slug={slug} />}
240− keep={dms.filter(keepShown).map(conversation)}
243+ keep={people.filter(keepShown).map(conversation)}
241244 >
242− {dms.map(conversation)}
243− {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate or an agent.</Empty>}
245+ {people.map(conversation)}
246+ {people.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
244247 </Section>
245− {(agents.length > 0 || (filter === "all" && !query)) && (
248+ {(agentConversations.length > 0 || agents.length > 0 || (filter === "all" && !query)) && (
246249 <Section
247250 id="agents"
248251 title="Agents"
261264 </Hint>
262265 ) : null
263266 }
267+ keep={agentConversations.filter(keepShown).map(conversation)}
264268 >
269+ {agentConversations.map(conversation)}
270+ {agentConversations.length === 0 && agents.length === 0 && filter === "all" && !query && <Empty>Message an agent.</Empty>}
265271 {agents.map((agent) => (
266272 <AgentRow
267273 key={agent.id}
463469 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
464470 const other = entry.others[0];
465471 const g1t = agent ? isOrchestrator(agent) : false;
472+ // A person and an agent together: both faces, the agent's marked, and the word.
473+ const mixed = mixedDm(entry);
466474 const icon = agent ? (
467475 <AgentFace agent={agent} g1t={g1t} />
476+ ) : mixed ? (
477+ <PairAvatar person={entry.others.find((m) => m.kind === "user")!} agent={agentsAmong(entry.others)[0]!} size={20} ring="var(--chat-sidebar-bg)" />
468478 ) : entry.channel.kind === "dm" && other ? (
469479 // One person: their dot, cut out of the sidebar behind it.
470480 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
484494 current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
485495 }`}
486496 >
487− <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
497+ <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
488498 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
489499 {agent ? agent.display_name : entry.title}
490500 {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>}
492502 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
493503 )}
494504 </span>
505+ {mixed && (
506+ <Badge tone="accent" className="shrink-0 px-1.5 font-normal">
507+ with an agent
508+ </Badge>
509+ )}
495510 {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
496511 <Count entry={entry} />
497512 </NavLink>
501516 );
502517 }
503518
504−/** An agent's face in a row; any but g1t carries its status, cut out of the sidebar behind it. */
519+/**
520+ * An agent's face in a row, the agent marker ringed in the sidebar's own
521+ * colour; any but g1t carries its status in the other corner.
522+ */
505523 function AgentFace({ agent, g1t }: { agent: WorkspaceAgent; g1t: boolean }) {
506524 return (
507525 <span className="relative inline-flex shrink-0">
508− <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
526+ <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} ring="var(--chat-sidebar-bg)" />
509527 {!g1t && agent.status !== "idle" && (
510− <span className="absolute -right-1 -bottom-1 flex rounded-full p-[2px]" style={{ background: "var(--chat-sidebar-bg)" }}>
528+ <span className="absolute -top-1 -right-1 flex rounded-full p-[2px]" style={{ background: "var(--chat-sidebar-bg)" }}>
511529 <StatusDot status={agent.status} className="size-[7px] shadow-none" />
512530 </span>
513531 )}
+2−2
211211 <ul className="mt-1.5 space-y-1.5">
212212 {recs.thin.map((rec) => (
213213 <li key={rec.id} className="flex min-w-0 items-start gap-2 text-xs text-faint">
214− {showAgent && <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed }} size={16} />}
214+ {showAgent && <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed, look: rec.agent_look }} size={16} />}
215215 <span className="min-w-0">
216216 {showAgent && <span className="text-muted">@{rec.agent_handle}: </span>}
217217 {rec.reason}
252252 return (
253253 <li className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 px-4 py-3.5 sm:grid-cols-[auto_minmax(0,1fr)_auto]">
254254 <span className="pt-0.5">
255− {showAgent ? <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed }} size={26} /> : <Gauge size={18} className="text-accent" aria-hidden="true" />}
255+ {showAgent ? <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed, look: rec.agent_look }} size={26} /> : <Gauge size={18} className="text-accent" aria-hidden="true" />}
256256 </span>
257257 <div className="min-w-0">
258258 <p className="text-sm font-medium">{rec.title}</p>
+2−2
103103 };
104104
105105 const people = useCallback(
106− (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => {
106+ (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed" | "look"> => {
107107 const at = key.indexOf(":");
108108 const kind = key.slice(0, at) === "agent" ? "agent" : "user";
109109 const id = key.slice(at + 1);
110110 if (layout?.me.key === key) return { kind: "user", id, name: layout.me.name, display_name: layout.me.display_name, avatar: layout.me.avatar, avatar_seed: null };
111111 const found = names.get(key);
112− return { kind, id, name: found?.name ?? "someone", display_name: found?.display_name ?? (kind === "agent" ? "An agent" : "Someone"), avatar: found?.avatar ?? null, avatar_seed: found?.avatar_seed ?? null };
112+ return { kind, id, name: found?.name ?? "someone", display_name: found?.display_name ?? (kind === "agent" ? "An agent" : "Someone"), avatar: found?.avatar ?? null, avatar_seed: found?.avatar_seed ?? null, look: found?.look ?? null };
113113 },
114114 [layout, names],
115115 );
+2−2
1313 import { syntaxHighlighter } from "@blocknote/code-block";
1414 import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
1515 import { BlockNoteView } from "@blocknote/shadcn";
16−import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts";
16+import type { AgentLook, DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts";
1717 import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
1818 import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1919
2828 import { whoAre } from "../who";
2929
3030 /** Someone who can be mentioned: a person (by username) or an agent (by id). */
31−export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
31+export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null; look?: AgentLook | null };
3232
3333 export type DocEditorProps = {
3434 slug: string;
+1−1
154154 onResolve,
155155 }: {
156156 threads: PageThread[];
157− people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">;
157+ people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed" | "look">;
158158 canComment: boolean;
159159 onPost: (text: string) => Promise<boolean>;
160160 onReply: (thread: string, text: string) => Promise<boolean>;
+2−2
1515 import { Hint } from "../ui/hint";
1616 import { KindIcon } from "./kinds";
1717
18−export function Face({ who, size = 20 }: { who: Pick<MemberProfile, "kind" | "id" | "name" | "avatar" | "avatar_seed">; size?: number }) {
19− if (who.kind === "agent") return <AgentAvatar agent={{ id: who.id, handle: who.name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null }} size={size} />;
18+export function Face({ who, size = 20 }: { who: Pick<MemberProfile, "kind" | "id" | "name" | "avatar" | "avatar_seed" | "look">; size?: number }) {
19+ if (who.kind === "agent") return <AgentAvatar agent={{ id: who.id, handle: who.name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null, look: who.look ?? null }} size={size} />;
2020 return <Avatar name={who.name} image={who.avatar} size={size} />;
2121 }
2222
+2−2
22 * Names and faces for member keys (`user:<id>`, `agent:<id>`): comment
33 * authors and history. Asked of the site once per key per tab.
44 */
5−import type { Result } from "@g1t/contracts";
5+import type { AgentLook, Result } from "@g1t/contracts";
66
77 /** Someone by member key, as the site names them. */
8−export type Who = { key: string; kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed: string | null };
8+export type Who = { key: string; kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed: string | null; look?: AgentLook | null };
99
1010 const known = new Map<string, Who>();
1111
+1−1
4545 if (actor.kind === "user" || actor.kind === "agent") {
4646 return (
4747 <MemberAvatar
48− member={{ kind: actor.kind, id: actor.id, name: actor.name, avatar: actor.avatar ?? null, avatar_seed: actor.avatar_seed ?? null }}
48+ member={{ kind: actor.kind, id: actor.id, name: actor.name, avatar: actor.avatar ?? null, avatar_seed: actor.avatar_seed ?? null, look: actor.look ?? null }}
4949 size={32}
5050 />
5151 );
+6−4
3232 export function AgentPersonFace({ agent, size = 28, ring = "var(--color-bg)" }: { agent: PeopleAgent; size?: number; ring?: string }) {
3333 return (
3434 <span className="relative inline-flex shrink-0">
35− <AgentFace agent={agent} size={size} />
36− <span className="absolute -right-0.5 -bottom-0.5 inline-flex rounded-full" style={{ boxShadow: `0 0 0 2px ${ring}` }}>
37− <StatusDot status={agent.status} className="block" />
38− </span>
35+ <AgentFace agent={agent} size={size} ring={ring} />
36+ {agent.status !== "idle" && (
37+ <span className="absolute -top-0.5 -right-0.5 inline-flex rounded-full" style={{ boxShadow: `0 0 0 2px ${ring}` }}>
38+ <StatusDot status={agent.status} className="block" />
39+ </span>
40+ )}
3941 </span>
4042 );
4143 }
+1−0
124124 WorkspaceAgent,
125125 "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "builtin"
126126 > & {
127+ look?: WorkspaceAgent["look"];
127128 scope?: WorkspaceAgent["scope"];
128129 /** The visible teams it is on (team memberships); absent when they couldn't be read. */
129130 teams?: { slug: string; name: string }[];
+2−2
315315 <ul className="mt-3 ml-10 space-y-2.5">
316316 {shown.map((agent) => (
317317 <li key={agent.id} className="flex items-center gap-2.5 text-sm">
318− <AgentAvatar agent={{ handle: agent.handle, avatar_seed: agent.avatar_seed }} size={20} />
318+ <AgentAvatar agent={{ handle: agent.handle, avatar_seed: agent.avatar_seed, look: agent.look }} size={20} />
319319 <Link to={`/${slug}/-/agents/${agent.handle}/spend`} className="min-w-0 grow truncate hover:underline">
320320 {agent.display_name}
321321 </Link>
477477 <ul className="divide-y divide-line/60">
478478 {sessions.map((session) => (
479479 <li key={session.id} className="group relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
480− <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={24} />
480+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={24} />
481481 <div className="min-w-0 grow">
482482 <Link to={receiptHref(slug, session)} className="block min-w-0 truncate text-sm font-medium after:absolute after:inset-0 hover:underline">
483483 {session.visible ? session.title || "Untitled session" : <PrivateTitle />}
+110−5
1−import { User } from "lucide-react";
2−import { useState } from "react";
1+import { Bot, User } from "lucide-react";
2+import { type ReactNode, useState } from "react";
33 import { useRouteLoaderData } from "react-router";
44
5+import type { AgentLook } from "@g1t/contracts";
6+import { lookFromSeed } from "@g1t/contracts/agent-look";
7+
58 import { usercontentFrom } from "../../lib/addresses";
9+import { AgentFace, type FaceState, faceColorCss } from "../agent-face";
610 import { Mark } from "../logo";
711
812 // shadcn/ui's avatar, as g1t draws one: an uploaded picture, or a letter
9−// whose colour is stable for a given name. It is one element rather than
10−// Radix's root/image/fallback so the server renders the letter at once and
13+// whose colour is stable for a given name; an agent's bot face, with the
14+// agent marker in its corner. It is one element rather than Radix's
15+// root/image/fallback so the server renders the letter at once and
1116 // nothing jumps; an image that fails to load falls back to the letter.
1217
1318 const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
3035 return `${usercontent}/avatars/${avatar}`;
3136 }
3237
38+/** What an agent's avatar is drawn from: its chosen face, else its seed's. */
39+export type AvatarAgent = {
40+ look?: AgentLook | null;
41+ /** What draws the face when there is no look, and times its blink; the name when left out. */
42+ seed?: string | null;
43+ /** What the face is doing (components/agent-face.tsx). */
44+ state?: FaceState;
45+};
46+
3347 /**
3448 * An uploaded avatar if there is one, else a letter avatar whose colour is
3549 * stable for a given name. People are round; a workspace is `square`. An
3650 * image that fails to load falls back to the letter.
51+ *
52+ * With `agent`, it is an agent's: its uploaded picture, g1t's own mark, or
53+ * its bot face (components/agent-face.tsx), on a squircle tile so it reads
54+ * apart from people's round letters at any size, and from 16 px up the
55+ * agent marker in its corner. It then names itself to a screen reader,
56+ * "Margo, agent", where a person's avatar is decoration beside their name.
3757 */
3858 export function Avatar({
3959 name,
4161 square,
4262 image,
4363 system,
64+ agent,
65+ ring,
4466 }: {
4567 name: string;
4668 size?: number;
4971 image?: string | null;
5072 /** g1t itself (a user of kind `system`), whatever the name. */
5173 system?: boolean;
74+ /** An agent's avatar: `true` draws the face its name seeds, an object the look or seed it has. */
75+ agent?: boolean | AvatarAgent;
76+ /** The colour behind the avatar, for the agent marker's ring; the page's by default. */
77+ ring?: string;
5278 }) {
5379 const [failed, setFailed] = useState<string | null>(null);
5480 const usercontent = usercontentFrom(useRouteLoaderData("root"));
81+ const g1t = system || isSystemName(name);
82+ if (agent) {
83+ const own = typeof agent === "object" ? agent : {};
84+ const seed = own.seed || name || "agent";
85+ const radius = Math.round(size * 0.26);
86+ const face = g1t ? (
87+ <span
88+ className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
89+ style={{ width: size, height: size, borderRadius: radius }}
90+ >
91+ <Mark className="size-full" />
92+ </span>
93+ ) : image && failed !== image ? (
94+ <img
95+ src={avatarUrl(image, usercontent)}
96+ alt=""
97+ width={size}
98+ height={size}
99+ loading="lazy"
100+ decoding="async"
101+ onError={() => setFailed(image)}
102+ className="inline-block shrink-0 bg-raised object-cover"
103+ style={{ width: size, height: size, borderRadius: radius }}
104+ />
105+ ) : (
106+ <AgentTile size={size} radius={radius} color={faceColorCss((own.look ?? lookFromSeed(seed)).color)}>
107+ <AgentFace look={own.look ?? null} seed={seed} size={size} state={own.state} />
108+ </AgentTile>
109+ );
110+ return (
111+ <span role="img" aria-label={`${name}, agent`} className="relative inline-flex shrink-0 align-middle">
112+ {face}
113+ {size >= 16 && <AgentMarker size={size} ring={ring} />}
114+ </span>
115+ );
116+ }
55117 // g1t itself wears its own mark: the pixel 1 on a dark square.
56− if (system || isSystemName(name)) {
118+ if (g1t) {
57119 return (
58120 <span
59121 aria-hidden="true"
113175 </span>
114176 );
115177 }
178+
179+/**
180+ * The tile an agent's face sits on: a squircle in a wash of the face's own
181+ * colour, so a round-headed bot still reads as an agent beside a person's
182+ * round letter.
183+ */
184+function AgentTile({ size, radius, color, children }: { size: number; radius: number; color: string; children: ReactNode }) {
185+ return (
186+ <span
187+ className="inline-flex shrink-0 items-center justify-center overflow-visible"
188+ style={{
189+ width: size,
190+ height: size,
191+ borderRadius: radius,
192+ background: `color-mix(in oklab, ${color} 20%, transparent)`,
193+ boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 40%, transparent)`,
194+ }}
195+ >
196+ {children}
197+ </span>
198+ );
199+}
200+
201+/**
202+ * The agent marker: a small bot on an accent disc, ringed in the colour
203+ * behind it, in the corner of every agent's avatar from 16 px up (under
204+ * that the squircle tile alone says it). The same mark everywhere, so
205+ * nobody has to read a name to know they are looking at an agent.
206+ */
207+export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
208+ const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
209+ const icon = Math.max(6, Math.round(disc * 0.66));
210+ const inset = Math.round(disc * 0.28);
211+ return (
212+ <span
213+ aria-hidden="true"
214+ className={`absolute flex items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`}
215+ style={{ width: disc, height: disc, right: -inset, bottom: -inset, boxShadow: `0 0 0 ${size >= 28 ? 2 : 1.5}px ${ring}` }}
216+ >
217+ <Bot size={icon} strokeWidth={2.6} absoluteStrokeWidth={false} />
218+ </span>
219+ );
220+}
+2−2
278278 }) {
279279 const face = (size: number) =>
280280 agent ? (
281− <AgentAvatar agent={{ id: agent.ref.id, handle: agent.ref.handle, avatar_seed: agent.ref.avatarSeed }} size={size} />
281+ <AgentAvatar agent={{ id: agent.ref.id, handle: agent.ref.handle, avatar_seed: agent.ref.avatarSeed, look: agent.ref.look }} size={size} />
282282 ) : (
283283 <Avatar name={author} size={size} />
284284 );
751751 const said = verdict === "approve" ? "approved" : verdict === "request_changes" ? "requested changes" : "reviewed";
752752 return (
753753 <span className="flex items-center gap-1.5 text-muted">
754− <AgentAvatar agent={{ id: agent.id, handle: agent.handle, avatar_seed: agent.avatarSeed }} size={16} />
754+ <AgentAvatar agent={{ id: agent.id, handle: agent.handle, avatar_seed: agent.avatarSeed, look: agent.look }} size={16} />
755755 <AgentName agent={agent} href={href} />
756756 <span>{said}</span>
757757 <AdvisoryChip />
+5−0
66 * tested under Node.
77 */
88 import type { AgentBudget, AgentRouting, NewWorkspaceAgent, PersonalityPreset, WorkspaceAgent, WorkspaceAgentScope } from "@g1t/contracts";
9+import { readLook } from "@g1t/contracts/agent-look";
910 import { connectorById, connectorPath } from "@g1t/contracts/connectors";
1011
1112 import { wholeDollars } from "./money.ts";
5051 skills_off: list("skills_off", 40, 20),
5152 template: null,
5253 avatar_seed: text("avatar_seed", 64) || undefined,
54+ look: readLook(d.look),
5355 scope: d.scope === "personal" ? "personal" : "workspace",
5456 };
5557 }
138140 }
139141 plain("personality_preset", "Voice");
140142 plain("personality", "Personality");
143+ if (changes.look !== undefined) {
144+ rows.push({ field: "look", label: "Face", before: agent.look ? "A chosen face" : "Its own face", after: changes.look ? "A chosen face" : "Its own face" });
145+ }
141146 if (changes.skills_off !== undefined) {
142147 const off = new Set(changes.skills_off);
143148 const was = new Set(agent.skills_off ?? []);
+19−1
33 * it: what each field means, and the checks made before it is sent. Pure,
44 * so it is tested on its own.
55 */
6−import type { AgentEffort, ModelTier, NewWorkspaceAgent, PersonalityPreset, SubagentDef, WorkspaceAgent } from "@g1t/contracts";
6+import type { AgentEffort, AgentLook, ModelTier, NewWorkspaceAgent, PersonalityPreset, SubagentDef, WorkspaceAgent } from "@g1t/contracts";
7+import { readLook } from "@g1t/contracts/agent-look";
78
89 import { plainDollars } from "./money.ts";
910
139140 ...(form.get("scope") === "personal" || form.get("scope") === "workspace" ? { scope: form.get("scope") as "personal" | "workspace" } : {}),
140141 ...(form.has("skills_off") ? { skills_off: String(form.get("skills_off") ?? "").split(",").map((id) => id.trim()).filter(Boolean) } : {}),
141142 ...(String(form.get("avatar_seed") ?? "").trim() ? { avatar_seed: String(form.get("avatar_seed")).trim().slice(0, 64) } : {}),
143+ // Its face: a chosen look, or `null` for the seed's; a form without the field leaves it as it is.
144+ ...readLookField(form.get("look")),
142145 },
143146 };
144147 }
146149 /** Most responsibilities an agent lists. */
147150 export const MAX_RESPONSIBILITIES = 8;
148151
152+/**
153+ * The Face section's field: `null` (the word) gives the agent back its
154+ * seed's face, a look as JSON sets one, and nothing or something that is
155+ * not a look changes nothing.
156+ */
157+export function readLookField(value: FormDataEntryValue | null): { look?: AgentLook | null } {
158+ const text = String(value ?? "").trim();
159+ if (!text) return {};
160+ if (text === "null") return { look: null };
161+ const look = readLook(text);
162+ return look ? { look } : {};
163+}
164+
149165 /** The subagents, as the form sends them (JSON); null when they are not valid. */
150166 function readSubagents(value: FormDataEntryValue | null): SubagentDef[] | null {
151167 const text = String(value ?? "").trim();
211227 | "autonomy"
212228 | "capacity"
213229 | "template"
230+ | "look"
214231 >;
215232
216233 export const BLANK_DRAFT: AgentDraft = {
229246 autonomy: { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" },
230247 capacity: 3,
231248 template: null,
249+ look: null,
232250 };
233251
234252 /** Dollars from micro-dollars, for a form field: empty for none, `20` for a whole sum, else to the cent. */
+23−5
99 channelName,
1010 channelPath,
1111 dayLabel,
12+ dmPlaceholder,
1213 filterEntries,
1314 inline,
15+ mixedDm,
16+ mixedNotice,
1417 mentionNames,
1518 mentionQuery,
1619 mergeMessages,
149152 entry("d1", { kind: "dm", title: "reviewer", others: [bot], last: "2026-10-02T00:00:00Z", mentions: 1, unread: 1 }),
150153 entry("d2", { kind: "dm", title: "ana", others: [ana], last: "2026-10-05T00:00:00Z" }),
151154 entry("noisy", { muted: true, unread: 9 }),
155+ // Ana and the reviewer together: a person is in it, so it lists under People, marked as mixed.
156+ entry("d3", { kind: "dm", title: "ana, reviewer", others: [ana, bot], last: "2026-10-03T00:00:00Z" }),
157+ entry("d4", { kind: "dm", title: "notes", others: [], last: "2026-10-01T00:00:00Z" }),
152158 ];
153− const { pinned, channels, agentDms, dms } = sections(all);
159+ const { pinned, channels, agentDms, people, agents } = sections(all);
154160 assert.deepEqual(pinned.map((e) => e.title), ["general"]);
155161 assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]);
156− // Direct messages, the latest first: people and agents alike.
157− assert.deepEqual(dms.map((e) => e.title), ["ana", "reviewer"]);
158− assert.equal(agentDms.get("a1")?.title, "reviewer");
162+ // Direct messages in two buckets, each the latest first: people (mixed ones and your notes among them), and agents alone.
163+ assert.deepEqual(people.map((e) => e.title), ["ana", "ana, reviewer", "notes"]);
164+ assert.deepEqual(agents.map((e) => e.title), ["reviewer"]);
165+ assert.equal(agentDms.get("a1")?.title, "reviewer", "the agent's own conversation, not the mixed one");
166+ assert.deepEqual(all.map(mixedDm), [false, false, false, false, false, true, false]);
159167 assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "reviewer", "noisy"]);
160168 assert.deepEqual(filterEntries(all, "mentions", "").map((e) => e.title), ["reviewer"]);
161− assert.deepEqual(filterEntries(all, "all", "@Review").map((e) => e.title), ["reviewer"]);
169+ // Jump to finds the agent's own conversation and the mixed one it is in.
170+ assert.deepEqual(filterEntries(all, "all", "@Review").map((e) => e.title), ["reviewer", "ana, reviewer"]);
162171 assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 });
163172 });
164173
174+test("a mixed conversation says who the agents are, in the composer and above its first message", () => {
175+ assert.equal(dmPlaceholder([ana, bot]), "Message @ana and @reviewer (agent)");
176+ assert.equal(dmPlaceholder([{ ...ana, display_username: "Ana" }, bot, { ...bot, id: "a2", name: "scribe", display_name: "Scribe" }]), "Message @Ana, @reviewer (agent) and @scribe (agent)");
177+ assert.equal(dmPlaceholder([]), "Message yourself");
178+ assert.equal(mixedNotice([ana, bot]), "Reviewer is an agent and reads everything here; it can act on what you say.");
179+ assert.equal(mixedNotice([ana, bot, { ...bot, id: "a2", name: "scribe", display_name: "Scribe" }]), "Reviewer and Scribe are agents and read everything here; they can act on what you say.");
180+ assert.equal(mixedNotice([ana]), "");
181+});
182+
165183 test("addresses, names, money and reconnecting", () => {
166184 assert.equal(channelPath("acme", { id: "c1", kind: "channel", name: "general" }), "/acme/-/chat/general");
167185 assert.equal(channelPath("acme", { id: "d1", kind: "dm", name: null }), "/acme/-/chat/dm/d1");
+62−6
168168 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
169169 }
170170
171+type Members = Pick<MemberProfile, "kind">[];
172+
173+/** Whether a direct message is with agents alone (one or several, nobody else). */
174+export function agentsOnlyDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
175+ return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent");
176+}
177+
178+/**
179+ * Whether a direct message is mixed: at least one person besides you and
180+ * at least one agent in it. Such a conversation lists under People (a
181+ * person is in it) and says, everywhere it shows, that an agent is too.
182+ */
183+export function mixedDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
184+ return entry.channel.kind === "dm" && isMixed(entry.others);
185+}
186+
187+/** Whether the others in a conversation are a person or more and an agent or more. */
188+export function isMixed(others: Members): boolean {
189+ return others.some((member) => member.kind === "agent") && others.some((member) => member.kind === "user");
190+}
191+
192+/** The agents among a conversation's other members. */
193+export function agentsAmong<M extends Pick<MemberProfile, "kind">>(others: readonly M[]): M[] {
194+ return others.filter((member) => member.kind === "agent");
195+}
196+
197+/**
198+ * The composer's placeholder in a direct message: "Message @mike and @sam
199+ * (agent)", each agent said to be one, so nobody writes to a person and
200+ * reaches a bot unawares. A conversation with yourself alone is for notes.
201+ */
202+export function dmPlaceholder(others: readonly Pick<MemberProfile, "kind" | "name" | "display_username">[]): string {
203+ if (others.length === 0) return "Message yourself";
204+ const names = others.map((member) => `@${shownHandle(member)}${member.kind === "agent" ? " (agent)" : ""}`);
205+ const list = names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
206+ return `Message ${list}`;
207+}
208+
209+/**
210+ * The one-line notice above a mixed conversation's first message, the
211+ * first time a person opens it: who the agents are and that they read and
212+ * can act on what is said.
213+ */
214+export function mixedNotice(others: readonly Pick<MemberProfile, "kind" | "name" | "display_name" | "display_username">[]): string {
215+ const agents = agentsAmong(others).map(shownName);
216+ if (agents.length === 0) return "";
217+ if (agents.length === 1) return `${agents[0]} is an agent and reads everything here; it can act on what you say.`;
218+ const list = `${agents.slice(0, -1).join(", ")} and ${agents[agents.length - 1]}`;
219+ return `${list} are agents and read everything here; they can act on what you say.`;
220+}
221+
171222 /**
172223 * The sidebar's sections, in the order they show: what is pinned (the
173− * `starred` preference), channels, and direct messages, the latest first,
174− * with people and agents alike (an agent is a member you message like
175− * anyone). `agentDms` finds the direct message with each agent, by agent
176− * id, so the Agents section lists only the agents you have not talked to.
224+ * `starred` preference), channels, then direct messages in two buckets,
225+ * each the latest first: People, every conversation with a person in it
226+ * (a mixed one too, since a person is in it), and Agents, conversations
227+ * with agents alone. `agentDms` finds the direct message with each agent
228+ * on its own, by agent id, so the Agents section lists only the agents
229+ * you have not talked to yet.
177230 */
178231 export function sections(entries: ChatSidebarEntry[]): {
179232 pinned: ChatSidebarEntry[];
180233 channels: ChatSidebarEntry[];
181234 agentDms: Map<string, ChatSidebarEntry>;
182− dms: ChatSidebarEntry[];
235+ people: ChatSidebarEntry[];
236+ agents: ChatSidebarEntry[];
183237 } {
184238 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
185239 // Direct messages: the latest conversation first, as people scan them.
190244 const agent = agentDmOf(entry);
191245 if (agent) agentDms.set(agent, entry);
192246 }
247+ const dms = entries.filter((e) => !e.starred && e.channel.kind === "dm");
193248 return {
194249 pinned: entries.filter((e) => e.starred).sort(byName),
195250 channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
196251 agentDms,
197− dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort(byRecent),
252+ people: dms.filter((e) => !agentsOnlyDm(e)).sort(byRecent),
253+ agents: dms.filter(agentsOnlyDm).sort(byRecent),
198254 };
199255 }
200256
+1−0
7474 agent_handle: "margo",
7575 agent_name: "Margo",
7676 agent_avatar_seed: "margo",
77+ agent_look: null,
7778 subagent: null,
7879 kind: "chat",
7980 parent_id: null,
+3−3
88 /** An agent as People's pages need it. */
99 export type PeopleAgent = Pick<
1010 WorkspaceAgent,
11− "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "status" | "responsibilities" | "builtin" | "spent_month_micros"
11+ "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "look" | "title" | "role" | "status" | "responsibilities" | "builtin" | "spent_month_micros"
1212 >;
1313
1414 /** Just what People's pages read of an agent, so loaders send no more. */
1515 export function peopleAgent(agent: PeopleAgent): PeopleAgent {
16− const { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros } = agent;
17− return { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros };
16+ const { id, handle, display_name, avatar, avatar_seed, look, title, role, status, responsibilities, builtin, spent_month_micros } = agent;
17+ return { id, handle, display_name, avatar, avatar_seed, look, title, role, status, responsibilities, builtin, spent_month_micros };
1818 }
1919
2020 /** Where a person's profile is. */
+0−24
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { COLUMNS, ROWS, creature } from "./pixel-creature.ts";
5−
6−test("a seed always draws the same creature, symmetric and whole", () => {
7− assert.deepEqual(creature("margo"), creature("margo"));
8− for (const seed of ["margo", "ship", "a1", "reviewer", "", "zz-top"]) {
9− const { cells, colour } = creature(seed);
10− assert.equal(cells.length, ROWS);
11− assert.match(colour.fg, /^#[0-9a-f]{6}$/);
12− for (const row of cells) {
13− assert.equal(row.length, COLUMNS);
14− assert.deepEqual(row, [...row].reverse(), `${seed} is symmetric`);
15− }
16− // The spine holds it together.
17− for (let row = 1; row < ROWS - 1; row++) assert.notEqual(cells[row]![2], 0);
18− }
19−});
20−
21−test("different seeds give different creatures", () => {
22− const drawn = new Set(["margo", "ship", "triage", "scribe", "builder", "reviewer", "nova", "pixel"].map((seed) => JSON.stringify(creature(seed))));
23− assert.ok(drawn.size >= 7);
24−});
+0−77
1−/**
2− * An agent's face: a little creature drawn from its `avatar_seed`, in the
3− * 5×7 pixel style of the g1t logo. The same seed always gives the same
4− * creature: symmetric, in one colour from a palette that reads on the dark
5− * base, and sometimes with eyes. Pure, so it is tested on its own.
6− */
7−
8−/** Colours that sit well on --g1t-bg, each with a darker shade for its ground. */
9−export const CREATURE_COLOURS: { fg: string; ground: string }[] = [
10− { fg: "#b6a8ff", ground: "#221e38" }, // lavender
11− { fg: "#86efc4", ground: "#10302a" }, // mint
12− { fg: "#ffbd8c", ground: "#35241a" }, // peach
13− { fg: "#8ab4ff", ground: "#18233a" }, // sky
14− { fg: "#ff9ecf", ground: "#36192a" }, // pink
15− { fg: "#f2dc72", ground: "#302a12" }, // lemon
16− { fg: "#7fe3e0", ground: "#112f30" }, // teal
17− { fg: "#ff8f85", ground: "#381a19" }, // coral
18−];
19−
20−export const COLUMNS = 5;
21−export const ROWS = 7;
22−
23−/** A cell: empty, body, or an eye. */
24−export type Cell = 0 | 1 | 2;
25−
26−export type Creature = { cells: Cell[][]; colour: { fg: string; ground: string } };
27−
28−/** FNV-1a, 32 bits: the seed as a number. */
29−function hash(text: string): number {
30− let h = 0x811c9dc5;
31− for (let i = 0; i < text.length; i++) {
32− h ^= text.charCodeAt(i);
33− h = Math.imul(h, 0x01000193);
34− }
35− return h >>> 0;
36−}
37−
38−/** mulberry32: a small, good-enough generator from one number. */
39−function generator(seed: number): () => number {
40− let a = seed;
41− return () => {
42− a = (a + 0x6d2b79f5) | 0;
43− let t = Math.imul(a ^ (a >>> 15), 1 | a);
44− t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
45− return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
46− };
47−}
48−
49−/** The creature for a seed: the left three columns chosen, mirrored to five. */
50−export function creature(seed: string): Creature {
51− const random = generator(hash(seed || "agent"));
52− // The first draws of a generator from near seeds sit close together:
53− // spent, and the colour taken from a hash of its own.
54− for (let i = 0; i < 4; i++) random();
55− const colour = CREATURE_COLOURS[(hash(`colour:${seed}`) >>> 11) % CREATURE_COLOURS.length]!;
56− const cells: Cell[][] = Array.from({ length: ROWS }, () => Array<Cell>(COLUMNS).fill(0));
57− for (let row = 0; row < ROWS; row++) {
58− for (let column = 0; column < 3; column++) {
59− // Fuller towards the middle, so it reads as a body, not noise.
60− const density = column === 2 ? 0.78 : column === 1 ? 0.6 : 0.42;
61− const filled: Cell = random() < density ? 1 : 0;
62− cells[row]![column] = filled;
63− cells[row]![COLUMNS - 1 - column] = filled;
64− }
65− }
66− // A spine down the middle from the second row to the second last, so it holds together.
67− for (let row = 1; row < ROWS - 1; row++) cells[row]![2] = 1;
68− // Eyes, more often than not: a pair on the second or third row, in a filled face.
69− if (random() < 0.7) {
70− const row = random() < 0.5 ? 2 : 3;
71− for (const column of [1, 3]) cells[row]![column] = 2;
72− for (const column of [0, 1, 2, 3, 4]) if (cells[row]![column] === 0 && column !== 0 && column !== 4) cells[row]![column] = 1;
73− // A brow above the eyes keeps them from floating off the top.
74− if (row === 2) for (const column of [1, 2, 3]) cells[1]![column] = 1;
75− }
76− return { cells, colour };
77−}
+2−2
3434 }
3535
3636 /** One agent's budget and month, as the budgets list shows it. */
37−export type AgentBudgetRow = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar_seed" | "budget" | "spent_month_micros" | "builtin">;
37+export type AgentBudgetRow = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar_seed" | "look" | "budget" | "spent_month_micros" | "builtin">;
3838
3939 /** Every level of budget, widest first, with what is spent against each this month. */
4040 export type Budgets = {
6868 agents: agents
6969 ? agents
7070 .filter((a) => !a.archived_at)
71− .map((a) => ({ id: a.id, handle: a.handle, display_name: a.display_name, avatar_seed: a.avatar_seed, budget: a.budget, spent_month_micros: a.spent_month_micros, builtin: a.builtin }))
71+ .map((a) => ({ id: a.id, handle: a.handle, display_name: a.display_name, avatar_seed: a.avatar_seed, look: a.look ?? null, budget: a.budget, spent_month_micros: a.spent_month_micros, builtin: a.builtin }))
7272 .sort((a, b) => Number(b.builtin) - Number(a.builtin) || b.spent_month_micros - a.spent_month_micros)
7373 : null,
7474 };
+1−0
296296 display_name: agent.display_name,
297297 avatar: agent.avatar,
298298 avatar_seed: agent.avatar_seed,
299+ look: agent.look ?? null,
299300 role: agent.role,
300301 title: agent.title,
301302 status: agent.status,
+1−0
142142 personality: d.personality ?? "",
143143 routing: { floor: d.routing?.floor ?? null, ceiling: d.routing?.ceiling ?? null, providers: [], pinned: null },
144144 budget: { monthly_micros: d.budget?.monthly_micros ?? null, daily_micros: d.budget?.daily_micros ?? null, task_micros: d.budget?.task_micros ?? null },
145+ look: d.look ?? null,
145146 };
146147 }
147148
+3−2
2222
2323 import type { Route } from "./+types/session";
2424 import { AgentAvatar } from "../../../components/agent-avatar";
25+import { sessionFaceOf } from "../../../components/agent-face";
2526 import { agentsAction, answer } from "../../../components/agents/actions.server";
2627 import { type ActionResult, ApproveDialog, Confirm } from "../../../components/agents/dialogs";
2728 import { isLive, kindLabel, sessionRows, whereLabel } from "../../../components/agents/format";
159160 <dl className="mt-5 grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
160161 <Meta label="Agent">
161162 <Link to={`/${slug}/-/agents/${session.agent_handle}`} className="inline-flex items-center gap-1.5 hover:underline">
162− <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={18} />
163+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={18} state={sessionFaceOf(session.status)} />
163164 {session.agent_name}
164165 </Link>
165166 </Meta>
508509 const body = (
509510 <>
510511 {depth > 0 && <CornerDownRight size={12} className="shrink-0 text-faint" />}
511− <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={18} />
512+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={18} state={sessionFaceOf(session.status)} />
512513 <span className="min-w-0 grow">
513514 <span className="block truncate text-[0.8125rem]">{session.visible ? session.title : "A private session"}</span>
514515 <span className="block truncate text-[0.6875rem] text-faint">
+3−3
88 import { Link, data } from "react-router";
99
1010 import type { Route } from "./+types/template";
11−import { PixelCreature } from "../../../components/agent-avatar";
11+import { AgentAvatar } from "../../../components/agent-avatar";
1212 import { StartAction } from "../../../components/agent-templates";
1313 import { PRESETS } from "../../../lib/agent-form";
1414 import { routingWords, templateListings, templatesPath } from "../../../lib/agent-templates";
5757 <div className="pb-4">
5858 {back}
5959 <header className="mt-5 flex flex-wrap items-center gap-4">
60− <PixelCreature seed={template.handle} size={64} />
60+ <AgentAvatar agent={{ handle: template.handle }} size={64} />
6161 <div className="min-w-0 grow basis-60">
6262 <h1 className="text-2xl font-semibold tracking-tight">{template.title}</h1>
6363 <p className="mt-0.5 text-sm text-muted">A role template from g1t · Suggests {template.display_name}</p>
153153 <ul className="mt-2 space-y-1.5">
154154 {agents.map((agent) => (
155155 <li key={agent.id} className="flex items-center gap-2">
156− <PixelCreature seed={agent.handle} size={20} />
156+ <AgentAvatar agent={agent} size={20} />
157157 <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline">
158158 {agent.display_name} <span className="text-faint">@{agent.handle}</span>
159159 </Link>
+1−1
292292 const out = keys.map((key) => {
293293 if (key.startsWith("agent:")) {
294294 const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
295− return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null };
295+ return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null, look: agent?.look ?? null };
296296 }
297297 const username = names[key.slice(5)]?.toLowerCase();
298298 const person = username ? people.find((p) => p.name === username) : undefined;
+1−1
5353 sidebar,
5454 mentionables: [
5555 ...people.map((p): DocMentionable => ({ kind: "user", id: p.name, name: p.name, display_name: p.display_name, avatar: p.avatar })),
56− ...agents.map((a): DocMentionable => ({ kind: "agent", id: a.id, name: a.handle, display_name: a.display_name, avatar: a.avatar, avatar_seed: a.avatar_seed ?? null })),
56+ ...agents.map((a): DocMentionable => ({ kind: "agent", id: a.id, name: a.handle, display_name: a.display_name, avatar: a.avatar, avatar_seed: a.avatar_seed ?? null, look: a.look ?? null })),
5757 ],
5858 zone: zone && knownTimeZone(zone) ? zone : null,
5959 };
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+171−0
1+/**
2+ * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): the little bot
3+ * every agent wears when it has no uploaded picture. A face is a `look`,
4+ * seven choices the agent's owner can make; an agent with no look of its
5+ * own wears the one drawn from its `avatar_seed`, so every agent has a
6+ * stable face from the moment it is made, and keeps it through a rename.
7+ *
8+ * The site draws the face (apps/web/app/components/agent-face.tsx) and the
9+ * agents service keeps it (the `look` column, JSON). No value imports, so
10+ * services test it under Node as it is. Wire shapes are snake_case.
11+ */
12+
13+export const FACE_SHAPES = ["round", "square", "squircle", "blob", "hex"] as const;
14+/** Ten named colours, each with a light and a dark rendering that read on both themes. */
15+export const FACE_COLORS = ["lavender", "mint", "peach", "sky", "pink", "lemon", "teal", "coral", "moss", "slate"] as const;
16+export const FACE_EYES = ["dots", "round", "wide", "happy", "visor", "sleepy"] as const;
17+export const FACE_MOUTHS = ["smile", "grin", "flat", "open", "none"] as const;
18+export const FACE_ANTENNAS = ["none", "single", "double", "bulb"] as const;
19+export const FACE_ACCESSORIES = ["none", "headphones", "bow", "glasses", "hat", "spark"] as const;
20+export const FACE_PATTERNS = ["none", "stripe", "dots", "gradient"] as const;
21+
22+export type FaceShape = (typeof FACE_SHAPES)[number];
23+export type FaceColor = (typeof FACE_COLORS)[number];
24+export type FaceEyes = (typeof FACE_EYES)[number];
25+export type FaceMouth = (typeof FACE_MOUTHS)[number];
26+export type FaceAntenna = (typeof FACE_ANTENNAS)[number];
27+export type FaceAccessory = (typeof FACE_ACCESSORIES)[number];
28+export type FacePattern = (typeof FACE_PATTERNS)[number];
29+
30+/** What an agent's face is made of. Every field is one of its listed choices. */
31+export type AgentLook = {
32+ shape: FaceShape;
33+ color: FaceColor;
34+ eyes: FaceEyes;
35+ mouth: FaceMouth;
36+ antenna: FaceAntenna;
37+ accessory: FaceAccessory;
38+ pattern: FacePattern;
39+};
40+
41+/** Each part of a look and its choices, in the order the Face editor shows them. */
42+export const LOOK_OPTIONS: { [K in keyof AgentLook]: readonly AgentLook[K][] } = {
43+ shape: FACE_SHAPES,
44+ color: FACE_COLORS,
45+ eyes: FACE_EYES,
46+ mouth: FACE_MOUTHS,
47+ antenna: FACE_ANTENNAS,
48+ accessory: FACE_ACCESSORIES,
49+ pattern: FACE_PATTERNS,
50+};
51+
52+export const LOOK_KEYS = Object.keys(LOOK_OPTIONS) as (keyof AgentLook)[];
53+
54+/** FNV-1a, 32 bits: a seed as a number. */
55+function fnv(text: string): number {
56+ let h = 0x811c9dc5;
57+ for (let i = 0; i < text.length; i++) {
58+ h ^= text.charCodeAt(i);
59+ h = Math.imul(h, 0x01000193);
60+ }
61+ return h >>> 0;
62+}
63+
64+/** One choice for a part, from the seed and the part's name: parts vary independently. */
65+function pick<T>(seed: string, part: string, options: readonly T[], weights?: readonly number[]): T {
66+ const roll = fnv(`${part}:${seed}`) >>> 8;
67+ if (!weights) return options[roll % options.length]!;
68+ const total = weights.reduce((sum, w) => sum + w, 0);
69+ let at = roll % total;
70+ for (let i = 0; i < options.length; i++) {
71+ at -= weights[i]!;
72+ if (at < 0) return options[i]!;
73+ }
74+ return options[options.length - 1]!;
75+}
76+
77+/**
78+ * The face an agent wears when it has chosen none: the same for the same
79+ * seed everywhere, and different from its neighbours' as far as seven
80+ * parts allow. Plainer choices (no antenna, no accessory, no pattern) are
81+ * weighted so faces read as a set rather than a costume party, and `none`
82+ * never lands on both the eyes' expression and the mouth.
83+ */
84+export function lookFromSeed(seed: string): AgentLook {
85+ const s = seed || "agent";
86+ const look: AgentLook = {
87+ shape: pick(s, "shape", FACE_SHAPES, [3, 2, 4, 2, 2]),
88+ color: pick(s, "color", FACE_COLORS),
89+ eyes: pick(s, "eyes", FACE_EYES, [3, 3, 2, 2, 2, 1]),
90+ mouth: pick(s, "mouth", FACE_MOUTHS, [4, 2, 2, 2, 1]),
91+ antenna: pick(s, "antenna", FACE_ANTENNAS, [4, 3, 2, 2]),
92+ accessory: pick(s, "accessory", FACE_ACCESSORIES, [7, 1, 1, 1, 1, 1]),
93+ pattern: pick(s, "pattern", FACE_PATTERNS, [6, 1, 1, 2]),
94+ };
95+ if (look.mouth === "none" && look.eyes === "visor") look.mouth = "flat";
96+ return look;
97+}
98+
99+/**
100+ * A look as it was sent or stored, checked: every part present and one of
101+ * its choices. Null when it is not one (a service refuses it, a page draws
102+ * the seed's face instead).
103+ */
104+export function readLook(raw: unknown): AgentLook | null {
105+ const value = typeof raw === "string" ? parse(raw) : raw;
106+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
107+ const given = value as Record<string, unknown>;
108+ const look: Partial<AgentLook> = {};
109+ for (const key of LOOK_KEYS) {
110+ const options = LOOK_OPTIONS[key] as readonly string[];
111+ const choice = given[key];
112+ if (typeof choice !== "string" || !options.includes(choice)) return null;
113+ (look as Record<string, string>)[key] = choice;
114+ }
115+ return look as AgentLook;
116+}
117+
118+function parse(text: string): unknown {
119+ try {
120+ return JSON.parse(text);
121+ } catch {
122+ return null;
123+ }
124+}
125+
126+/** Whether two looks are the same face. */
127+export function sameLook(a: AgentLook | null | undefined, b: AgentLook | null | undefined): boolean {
128+ if (!a || !b) return a == b;
129+ return LOOK_KEYS.every((key) => a[key] === b[key]);
130+}
131+
132+/** The face an agent shows: its own look, else the one its seed draws. */
133+export function lookOf(agent: { look?: AgentLook | null; avatar_seed?: string | null; id?: string | null; handle?: string | null; name?: string | null }): AgentLook {
134+ return agent.look ?? lookFromSeed(agent.avatar_seed || agent.id || agent.handle || agent.name || "agent");
135+}
136+
137+/**
138+ * A random look, for the Shuffle button: `random` is `Math.random` unless
139+ * a test hands in its own. Every part is drawn evenly, so shuffling shows
140+ * the whole range.
141+ */
142+export function randomLook(random: () => number = Math.random): AgentLook {
143+ const draw = <T>(options: readonly T[]): T => options[Math.min(options.length - 1, Math.floor(random() * options.length))]!;
144+ return {
145+ shape: draw(FACE_SHAPES),
146+ color: draw(FACE_COLORS),
147+ eyes: draw(FACE_EYES),
148+ mouth: draw(FACE_MOUTHS),
149+ antenna: draw(FACE_ANTENNAS),
150+ accessory: draw(FACE_ACCESSORIES),
151+ pattern: draw(FACE_PATTERNS),
152+ };
153+}
154+
155+/**
156+ * What a personality preset suggests for a face that has not been chosen:
157+ * a precise voice looks precise. Only a suggestion, applied by the Face
158+ * editor when the person has made no choice; it never changes a look an
159+ * agent already has.
160+ */
161+export const PRESET_EXPRESSIONS: Record<string, Partial<AgentLook>> = {
162+ crisp: { eyes: "dots", mouth: "flat" },
163+ friendly: { eyes: "happy", mouth: "smile" },
164+ socratic: { eyes: "round", mouth: "open" },
165+ terse: { eyes: "visor", mouth: "flat" },
166+};
167+
168+/** The seed's face with the preset's expression laid over it, for an agent that has chosen no look. */
169+export function suggestedLook(seed: string, preset: string | null | undefined): AgentLook {
170+ return { ...lookFromSeed(seed), ...(preset ? (PRESET_EXPRESSIONS[preset] ?? {}) : {}) };
171+}
+4−1
88 import type { ServiceBinding } from "./clients";
99 import type { User } from "./identity";
1010 import type { Result } from "./result";
11+import type { AgentLook } from "./agent-look";
1112 import type { AskerAccess } from "./workspace-agents";
1213
1314 /** Who is speaking: a person (by user id) or a workspace agent (by agent id). */
4950 * it; optional so profiles a page makes for itself need not.
5051 */
5152 title?: string | null;
52− /** What an agent's pixel creature is drawn from; null for a person. Always set by chat. */
53+ /** What an agent's face is drawn from when it chose none; null for a person. Always set by chat. */
5354 avatar_seed?: string | null;
55+ /** An agent's chosen face (./agent-look.ts); null for a person, or an agent wearing its seed's face. */
56+ look?: AgentLook | null;
5457 };
5558
5659 /**
+2−0
2424 * where that person can edit AND the space lets agents edit
2525 * (`agent_mode: "edit"`). Otherwise its edit becomes a suggestion.
2626 */
27+import type { AgentLook } from "./agent-look";
2728 import type { MemberProfile, Principal } from "./chat";
2829 import type { ServiceBinding } from "./clients";
2930 import type { User } from "./identity";
106107 display_name: string;
107108 avatar: string | null;
108109 avatar_seed?: string | null;
110+ look?: AgentLook | null;
109111 role: DocRole;
110112 };
111113
+1−0
44 export * from "./account-deletion";
55 export * from "./accounts";
66 export * from "./actions";
7+export * from "./agent-look";
78 export * from "./agents";
89 export * from "./audit";
910 export * from "./billing";
+4−1
77 * events service about every inbox item, and the site forwards each tab's
88 * `/-/live` socket to it. Wire shapes are snake_case end to end.
99 */
10+import type { AgentLook } from "./agent-look";
1011 import type { CardAction } from "./chat";
1112 import type { ServiceBinding } from "./clients";
1213 import type { User } from "./identity";
2425 name: string;
2526 /** A person's uploaded avatar hash, or null for the letter avatar. */
2627 avatar?: string | null;
27− /** What an agent's pixel creature is drawn from. */
28+ /** What an agent's face is drawn from when it chose none. */
2829 avatar_seed?: string | null;
30+ /** An agent's chosen face (./agent-look.ts). */
31+ look?: AgentLook | null;
2932 };
3033
3134 export type FeedNotification = {
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.