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.
| 60 | 60 | - Hover hints use the `Tooltip` component, never `title=`. Native | |
| 61 | 61 | `<select>`, file inputs, checkboxes and radios use their `ui` | |
| 62 | 62 | 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. | |
| 63 | 71 | - Look at every page you change, in light and dark, on a desktop and a | |
| 64 | 72 | phone, before calling it done. | |
| 65 | 73 |
| 76 | 76 | personal one. | |
| 77 | 77 | 3. Choose **Draft it**. g1t drafts the whole agent with one quick model | |
| 78 | 78 | 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 | |
| 80 | 80 | responsibilities and its personality, which | |
| 81 | 81 | [skills](/guides/agent-skills/) it keeps on, the integrations it would | |
| 82 | 82 | use, routines that would suit it, its model limits and its budget. | |
| ⋯ | |||
| 353 | 353 | Add free text to refine the preset: *Answers in Spanish when asked in | |
| 354 | 354 | Spanish.* or *Uses British spelling.* | |
| 355 | 355 | ||
| 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 | + | ||
| 356 | 424 | ## Subagents | |
| 357 | 425 | ||
| 358 | 426 | Subagents are the specialised help an agent keeps for its own work, the | |
| ⋯ | |||
| 725 | 793 | | **Routines** | Work it does on a schedule or when something happens. See [routines](/guides/agent-routines/). | | |
| 726 | 794 | | **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. | | |
| 727 | 795 | | **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). | | |
| 729 | 797 | ||
| 730 | 798 | The **Agents** page above them all shows the agent budget, sessions waiting | |
| 731 | 799 | on you and working now, every agent with its live sessions and its month's | |
| 173 | 173 | | --- | --- | | |
| 174 | 174 | | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. | | |
| 175 | 175 | | **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.** | | |
| 178 | 178 | ||
| 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 | + | ||
| 179 | 184 | Unread conversations are in bold with a count; mentions of you are counted | |
| 180 | 185 | in lavender. **All**, **Unread** and **Mentions** filter every section, and | |
| 181 | 186 | **Jump to channel or person** finds any of them by name. | |
| ⋯ | |||
| 203 | 208 | where you asked links to it. See | |
| 204 | 209 | [hand off](/guides/agents/#hand-off). | |
| 205 | 210 | ||
| 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 | + | ||
| 206 | 228 | ## Threads | |
| 207 | 229 | ||
| 208 | 230 | Reply to any message to start a thread under it. The thread keeps a side | |
| 61 | 61 | ||
| 62 | 62 | --animate-fade-up: fade-up 0.6s ease-out both; | |
| 63 | 63 | --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). */ | |
| 65 | 65 | --animate-wiggle: wiggle 0.45s ease-in-out; | |
| 66 | 66 | ||
| 67 | 67 | /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */ | |
| ⋯ | |||
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | 174 | /* | |
| 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 | + | /* | |
| 175 | 335 | * Appearance (lib/theme.ts): with no choice, or Auto, the page follows | |
| 176 | 336 | * the system; a choice of Light or Dark is data-theme on <html>. | |
| 177 | 337 | */ | |
| 1 | − | import { useMemo } from "react"; | |
| 1 | + | import type { AgentLook, AgentStatus } from "@g1t/contracts"; | |
| 2 | 2 | ||
| 3 | − | import { G1tMark } from "./orchestrator"; | |
| 4 | − | ||
| 5 | − | import { COLUMNS, ROWS, creature } from "../lib/pixel-creature"; | |
| 3 | + | import { type FaceState, faceStateOf } from "./agent-face"; | |
| 6 | 4 | import { Avatar } from "./ui/avatar"; | |
| 7 | 5 | ||
| 8 | 6 | /** What an agent's face is drawn from. */ | |
| ⋯ | |||
| 13 | 11 | name?: string; | |
| 14 | 12 | avatar?: string | null; | |
| 15 | 13 | avatar_seed?: string | null; | |
| 14 | + | /** Its chosen face; null or missing, the seed's. */ | |
| 15 | + | look?: AgentLook | null; | |
| 16 | 16 | builtin?: boolean; | |
| 17 | + | /** Its status, when the place it shows should show it on its face. */ | |
| 18 | + | status?: AgentStatus | null; | |
| 17 | 19 | }; | |
| 18 | 20 | ||
| 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. */ | |
| 20 | 22 | export function seedOf(agent: AgentLike): string { | |
| 21 | 23 | return agent.avatar_seed || agent.id || agent.handle || agent.name || "agent"; | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 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 | − | ||
| 49 | 26 | /** | |
| 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. | |
| 52 | 31 | */ | |
| 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 | + | ); | |
| 57 | 44 | } | |
| 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 | + | } |
| 6 | 6 | import { Plus } from "lucide-react"; | |
| 7 | 7 | import { Link } from "react-router"; | |
| 8 | 8 | ||
| 9 | − | import { PixelCreature } from "./agent-avatar"; | |
| 9 | + | import { AgentAvatar } from "./agent-avatar"; | |
| 10 | 10 | import { ButtonLink } from "./ui"; | |
| 11 | 11 | import { buttonVariants } from "./ui/button"; | |
| 12 | 12 | import { Card } from "./ui/card"; | |
| ⋯ | |||
| 41 | 41 | <Card asChild className="relative flex flex-col p-4 transition-colors hover:border-line-strong"> | |
| 42 | 42 | <article> | |
| 43 | 43 | <div className="flex items-start gap-3"> | |
| 44 | − | <PixelCreature seed={template.handle} size={44} /> | |
| 44 | + | <AgentAvatar agent={{ handle: template.handle }} size={44} /> | |
| 45 | 45 | <div className="min-w-0 grow"> | |
| 46 | 46 | <h3 className="truncate text-sm font-semibold"> | |
| 47 | 47 | <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"> | |
| 2 | 2 | import { type ReactNode, useState } from "react"; | |
| 3 | 3 | import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { AgentEffortCosts, AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts"; | |
| 5 | + | import type { AgentEffortCosts, AgentLook, AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { type AgentLike, AgentAvatar, PixelCreature } from "./agent-avatar"; | |
| 7 | + | import { type AgentLike, AgentAvatar } from "./agent-avatar"; | |
| 8 | 8 | import { StatusDot, statusLabel } from "./chat/marks"; | |
| 9 | 9 | import { isOrchestrator } from "./orchestrator"; | |
| 10 | 10 | import { Button } from "./ui/button"; | |
| ⋯ | |||
| 18 | 18 | import { EffortPicker } from "./effort"; | |
| 19 | 19 | import { effortSetting } from "../lib/effort"; | |
| 20 | 20 | import { RoleFields, SubagentsField } from "./agent-role"; | |
| 21 | + | import { FaceEditor } from "./agents/face-editor"; | |
| 21 | 22 | import { type AgentTeamRef, groupByTeam } from "../lib/people"; | |
| 22 | 23 | import type { AgentsLayoutData } from "../routes/workspace/agents/layout"; | |
| 23 | 24 | ||
| ⋯ | |||
| 26 | 27 | return useRouteLoaderData("routes/workspace/agents/layout") as AgentsLayoutData | undefined; | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 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} />; | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { | |
| ⋯ | |||
| 115 | 116 | } | |
| 116 | 117 | > | |
| 117 | 118 | <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" /> | |
| 121 | 122 | )} | |
| 122 | 123 | </span> | |
| 123 | 124 | <span className="min-w-0 grow leading-tight"> | |
| ⋯ | |||
| 336 | 337 | <Plus size={16} /> | |
| 337 | 338 | </span> | |
| 338 | 339 | ) : ( | |
| 339 | − | <PixelCreature seed={seed ?? name} size={40} /> | |
| 340 | + | <AgentAvatar agent={{ handle: seed ?? name }} size={40} /> | |
| 340 | 341 | )} | |
| 341 | 342 | <span className="min-w-0 grow"> | |
| 342 | 343 | <span className="block truncate text-sm font-semibold">{name}</span> | |
| ⋯ | |||
| 487 | 488 | const { pathname } = useLocation(); | |
| 488 | 489 | const busy = navigation.state === "submitting" && navigation.formAction === pathname; | |
| 489 | 490 | 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"; | |
| 490 | 494 | const [advanced, setAdvanced] = useState(Boolean(draft.routing.pinned)); | |
| 491 | 495 | const e = errors ?? {}; | |
| 492 | 496 | const presetAbout = PRESETS.find((p) => p.value === preset)?.about; | |
| ⋯ | |||
| 506 | 510 | } | |
| 507 | 511 | > | |
| 508 | 512 | <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} /> | |
| 510 | 514 | <div className="min-w-0 text-sm"> | |
| 511 | 515 | <p className="truncate font-semibold">{name || "Your new agent"}</p> | |
| 512 | 516 | <p className="truncate text-xs text-muted">{handle ? `@${handle}` : "@handle"}</p> | |
| ⋯ | |||
| 570 | 574 | </FormSection> | |
| 571 | 575 | ||
| 572 | 576 | {!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 && ( | |
| 573 | 584 | <FormSection | |
| 574 | 585 | title="Role" | |
| 575 | 586 | about={ | |
| 4 | 4 | * chat beside it, and, on an agent's profile, a box to change it in words | |
| 5 | 5 | * with the change shown before it is saved. | |
| 6 | 6 | */ | |
| 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"; | |
| 8 | 8 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 9 | 9 | import { Form, Link, useFetcher, useNavigation } from "react-router"; | |
| 10 | 10 | ||
| 11 | 11 | import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | 13 | import { TeamPicker } from "../agent-role"; | |
| 14 | − | import { AgentAvatar, PixelCreature } from "../agent-avatar"; | |
| 14 | + | import { AgentAvatar } from "../agent-avatar"; | |
| 15 | + | import { FaceEditor } from "./face-editor"; | |
| 15 | 16 | import { Markdown } from "../markdown"; | |
| 16 | 17 | import { Alert } from "../ui/alert"; | |
| 17 | 18 | import { Badge } from "../ui/badge"; | |
| ⋯ | |||
| 176 | 177 | preventScrollReset | |
| 177 | 178 | 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" | |
| 178 | 179 | > | |
| 179 | − | <PixelCreature seed={template.handle} size={24} className="rounded-full" /> | |
| 180 | + | <AgentAvatar agent={{ handle: template.handle }} size={24} /> | |
| 180 | 181 | {template.title} | |
| 181 | 182 | </Link> | |
| 182 | 183 | </li> | |
| ⋯ | |||
| 265 | 266 | <div className="overflow-hidden rounded-2xl border border-line bg-surface"> | |
| 266 | 267 | <div className="flex items-start gap-3 px-4 pt-4 pb-4 sm:px-5"> | |
| 267 | 268 | <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} /> | |
| 280 | 270 | </div> | |
| 281 | 271 | <div className="grid min-w-0 grow gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]"> | |
| 282 | 272 | <div> | |
| ⋯ | |||
| 332 | 322 | </Section> | |
| 333 | 323 | )} | |
| 334 | 324 | ||
| 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 | + | ||
| 335 | 329 | <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply."> | |
| 336 | 330 | <textarea | |
| 337 | 331 | aria-label="Job" | |
| 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 | + | } |
| 268 | 268 | autonomy: "what it may do alone", | |
| 269 | 269 | capacity: "capacity", | |
| 270 | 270 | avatar_seed: "face", | |
| 271 | + | look: "face", | |
| 271 | 272 | faces: "who it works with", | |
| 272 | 273 | skills_off: "skills", | |
| 273 | 274 | abilities: "abilities", |
| 101 | 101 | {depth > 0 && <CornerDownRight size={14} className="mt-1 shrink-0 text-faint" aria-hidden="true" />} | |
| 102 | 102 | {showAgent && ( | |
| 103 | 103 | <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} /> | |
| 105 | 105 | </span> | |
| 106 | 106 | )} | |
| 107 | 107 | <div className="min-w-0 grow"> | |
| ⋯ | |||
| 154 | 154 | <Card asChild className="relative flex flex-col p-4 transition-colors hover:border-line-strong"> | |
| 155 | 155 | <li> | |
| 156 | 156 | <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} /> | |
| 158 | 158 | <div className="min-w-0 grow leading-tight"> | |
| 159 | 159 | <p className="truncate text-sm font-medium">{session.agent_name}</p> | |
| 160 | 160 | <p className="truncate text-xs text-faint"> | |
| 38 | 38 | import { Composer } from "./composer"; | |
| 39 | 39 | import { type LiveState, useChatLive } from "./live"; | |
| 40 | 40 | import { AgentPill, MemberAvatar } from "./marks"; | |
| 41 | + | import { AgentAvatar } from "../agent-avatar"; | |
| 41 | 42 | import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence"; | |
| 42 | 43 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; | |
| 43 | 44 | ||
| ⋯ | |||
| 64 | 65 | import { Button } from "../ui/button"; | |
| 65 | 66 | import { Card } from "../ui/card"; | |
| 66 | 67 | 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"; | |
| 68 | 69 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 69 | 70 | import { conversationCache } from "./conversation-cache"; | |
| 70 | 71 | // Reactions and the workspace's own emoji (components/emoji). | |
| ⋯ | |||
| 599 | 600 | const typers = [...typing.values()].map((t) => t.member); | |
| 600 | 601 | const name = channel.name ?? ""; | |
| 601 | 602 | const archived = channel.kind === "channel" && !!channel.archived_at; | |
| 603 | + | // A mixed direct message names each member and says which are agents. | |
| 602 | 604 | const placeholder = isDm | |
| 603 | − | ? `Message ${others.map(shownName).join(", ") || "yourself"}` | |
| 605 | + | ? isMixed(others) | |
| 606 | + | ? dmPlaceholder(others) | |
| 607 | + | : `Message ${others.map(shownName).join(", ") || "yourself"}` | |
| 604 | 608 | : `Message #${name}. @ a teammate or an agent`; | |
| 605 | 609 | const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null; | |
| 606 | 610 | const cardContext = useMemo( | |
| ⋯ | |||
| 838 | 842 | }) { | |
| 839 | 843 | const channel = data.channel; | |
| 840 | 844 | const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; | |
| 845 | + | const mixed = channel.kind === "dm" && isMixed(others); | |
| 841 | 846 | return ( | |
| 842 | 847 | <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"> | |
| 843 | 848 | <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden"> | |
| ⋯ | |||
| 896 | 901 | {agentDm && (titleOf(agentDm) ?? agentDm.role) && ( | |
| 897 | 902 | <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p> | |
| 898 | 903 | )} | |
| 904 | + | {mixed && <MixedChip agents={agentsAmong(others)} />} | |
| 899 | 905 | {/* One person: their status under their name, as their card has it. */} | |
| 900 | 906 | {!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" />} | |
| 901 | 907 | </div> | |
| ⋯ | |||
| 958 | 964 | const channel = data.channel; | |
| 959 | 965 | if (channel.kind === "dm") { | |
| 960 | 966 | const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; | |
| 967 | + | const mixed = isMixed(others); | |
| 961 | 968 | return ( | |
| 962 | 969 | <div className="px-2 pb-6 sm:px-3"> | |
| 963 | 970 | <div className="flex -space-x-2"> | |
| ⋯ | |||
| 970 | 977 | <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 971 | 978 | {others.map(shownName).join(", ") || "Just you"} | |
| 972 | 979 | {agent && <AgentPill />} | |
| 980 | + | {mixed && ( | |
| 981 | + | <Badge tone="accent" size="md" className="font-normal"> | |
| 982 | + | with an agent | |
| 983 | + | </Badge> | |
| 984 | + | )} | |
| 973 | 985 | </h2> | |
| 974 | 986 | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 975 | 987 | {agent | |
| 976 | 988 | ? `${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."} | |
| 978 | 992 | </p> | |
| 993 | + | {mixed && <MixedNotice channelId={channel.id} others={others} />} | |
| 979 | 994 | </div> | |
| 980 | 995 | ); | |
| 981 | 996 | } | |
| ⋯ | |||
| 990 | 1005 | ); | |
| 991 | 1006 | } | |
| 992 | 1007 | ||
| 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 | + | ||
| 993 | 1074 | function DayRule({ label }: { label: string }) { | |
| 994 | 1075 | return ( | |
| 995 | 1076 | <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center"> | |
| 1 | − | import { Sparkles } from "lucide-react"; | |
| 2 | − | ||
| 3 | − | import type { AgentStatus } from "@g1t/contracts"; | |
| 1 | + | import type { AgentStatus, MemberProfile } from "@g1t/contracts"; | |
| 4 | 2 | ||
| 5 | 3 | import { AgentAvatar } from "../agent-avatar"; | |
| 6 | 4 | import { WithPresence } from "../presence"; | |
| ⋯ | |||
| 9 | 7 | import { Avatar } from "../ui/avatar"; | |
| 10 | 8 | ||
| 11 | 9 | /** | |
| 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. | |
| 32 | 15 | */ | |
| 33 | 16 | export function MemberAvatar({ | |
| 34 | 17 | member, | |
| ⋯ | |||
| 36 | 19 | presence = false, | |
| 37 | 20 | ring, | |
| 38 | 21 | }: { | |
| 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"] }; | |
| 40 | 23 | size?: number; | |
| 41 | 24 | presence?: boolean; | |
| 42 | 25 | ring?: string; | |
| 43 | 26 | }) { | |
| 44 | − | if (member.kind === "agent") return <AgentAvatar agent={member} size={size} />; | |
| 27 | + | if (member.kind === "agent") return <AgentAvatar agent={member} size={size} ring={ring} />; | |
| 45 | 28 | const avatar = <Avatar name={member.name} image={member.avatar} size={size} />; | |
| 46 | 29 | if (!presence) return avatar; | |
| 47 | 30 | return ( | |
| ⋯ | |||
| 51 | 34 | ); | |
| 52 | 35 | } | |
| 53 | 36 | ||
| 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 | + | ||
| 54 | 67 | /** AGENT, beside an agent's name wherever it speaks. */ | |
| 55 | 68 | 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 }) { | |
| 56 | 74 | return ( | |
| 57 | 75 | <span | |
| 58 | 76 | className={cn( | |
| 8 | 8 | import { conversationCache } from "./conversation-cache"; | |
| 9 | 9 | import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 10 | 10 | import { DndMenu } from "./dnd-menu"; | |
| 11 | − | import { MemberAvatar, StatusDot, statusLabel } from "./marks"; | |
| 11 | + | import { MemberAvatar, PairAvatar, StatusDot, statusLabel } from "./marks"; | |
| 12 | 12 | import { AgentAvatar } from "../agent-avatar"; | |
| 13 | 13 | import { PersonStatusEmoji } from "../presence"; | |
| 14 | 14 | import { isOrchestrator } from "../orchestrator"; | |
| 15 | + | import { Badge } from "../ui/badge"; | |
| 15 | 16 | import { Button } from "../ui/button"; | |
| 16 | 17 | import { Hint } from "../ui/hint"; | |
| 17 | 18 | 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"; | |
| 19 | 20 | import { feedConnected } from "../../lib/notify-client"; | |
| 20 | 21 | import { FALLBACK_REFRESH_MS } from "../../lib/notify-store"; | |
| 21 | 22 | import { setMutedConversations } from "../../lib/sound-events"; | |
| ⋯ | |||
| 44 | 45 | ||
| 45 | 46 | /** | |
| 46 | 47 | * 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. | |
| 53 | 56 | */ | |
| 54 | 57 | export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) { | |
| 55 | 58 | const data = useChatData(); | |
| ⋯ | |||
| 84 | 87 | }, [sidebar]); | |
| 85 | 88 | const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e)); | |
| 86 | 89 | const shown = filterEntries(entries, filter, query); | |
| 87 | − | const { pinned, channels, dms } = sections(shown); | |
| 90 | + | const { pinned, channels, people, agents: agentConversations } = sections(shown); | |
| 88 | 91 | const { agentDms } = sections(entries); | |
| 89 | 92 | const loading = !data; | |
| 90 | 93 | const owner = data?.role === "owner"; | |
| ⋯ | |||
| 165 | 168 | onChange={(event) => setQuery(event.target.value)} | |
| 166 | 169 | onKeyDown={(event) => { | |
| 167 | 170 | if (event.key === "Enter") { | |
| 168 | − | const first = [...pinned, ...channels, ...dms][0]; | |
| 171 | + | const first = [...pinned, ...channels, ...people, ...agentConversations][0]; | |
| 169 | 172 | if (first) { | |
| 170 | 173 | navigate(channelPath(slug, first.channel)); | |
| 171 | 174 | setQuery(""); | |
| ⋯ | |||
| 232 | 235 | )} | |
| 233 | 236 | </Section> | |
| 234 | 237 | <Section | |
| 235 | − | id="dms" | |
| 236 | − | title="Direct messages" | |
| 238 | + | id="people" | |
| 239 | + | title="People" | |
| 237 | 240 | folded={folded} | |
| 238 | 241 | onFold={fold} | |
| 239 | 242 | action={<NewMessageButton slug={slug} />} | |
| 240 | − | keep={dms.filter(keepShown).map(conversation)} | |
| 243 | + | keep={people.filter(keepShown).map(conversation)} | |
| 241 | 244 | > | |
| 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>} | |
| 244 | 247 | </Section> | |
| 245 | − | {(agents.length > 0 || (filter === "all" && !query)) && ( | |
| 248 | + | {(agentConversations.length > 0 || agents.length > 0 || (filter === "all" && !query)) && ( | |
| 246 | 249 | <Section | |
| 247 | 250 | id="agents" | |
| 248 | 251 | title="Agents" | |
| ⋯ | |||
| 261 | 264 | </Hint> | |
| 262 | 265 | ) : null | |
| 263 | 266 | } | |
| 267 | + | keep={agentConversations.filter(keepShown).map(conversation)} | |
| 264 | 268 | > | |
| 269 | + | {agentConversations.map(conversation)} | |
| 270 | + | {agentConversations.length === 0 && agents.length === 0 && filter === "all" && !query && <Empty>Message an agent.</Empty>} | |
| 265 | 271 | {agents.map((agent) => ( | |
| 266 | 272 | <AgentRow | |
| 267 | 273 | key={agent.id} | |
| ⋯ | |||
| 463 | 469 | const agent = agentId ? agents.find((a) => a.id === agentId) : null; | |
| 464 | 470 | const other = entry.others[0]; | |
| 465 | 471 | 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); | |
| 466 | 474 | const icon = agent ? ( | |
| 467 | 475 | <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)" /> | |
| 468 | 478 | ) : entry.channel.kind === "dm" && other ? ( | |
| 469 | 479 | // One person: their dot, cut out of the sidebar behind it. | |
| 470 | 480 | <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" /> | |
| ⋯ | |||
| 484 | 494 | 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" | |
| 485 | 495 | }`} | |
| 486 | 496 | > | |
| 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> | |
| 488 | 498 | <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}> | |
| 489 | 499 | {agent ? agent.display_name : entry.title} | |
| 490 | 500 | {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>} | |
| ⋯ | |||
| 492 | 502 | <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert /> | |
| 493 | 503 | )} | |
| 494 | 504 | </span> | |
| 505 | + | {mixed && ( | |
| 506 | + | <Badge tone="accent" className="shrink-0 px-1.5 font-normal"> | |
| 507 | + | with an agent | |
| 508 | + | </Badge> | |
| 509 | + | )} | |
| 495 | 510 | {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />} | |
| 496 | 511 | <Count entry={entry} /> | |
| 497 | 512 | </NavLink> | |
| ⋯ | |||
| 501 | 516 | ); | |
| 502 | 517 | } | |
| 503 | 518 | ||
| 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 | + | */ | |
| 505 | 523 | function AgentFace({ agent, g1t }: { agent: WorkspaceAgent; g1t: boolean }) { | |
| 506 | 524 | return ( | |
| 507 | 525 | <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)" /> | |
| 509 | 527 | {!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)" }}> | |
| 511 | 529 | <StatusDot status={agent.status} className="size-[7px] shadow-none" /> | |
| 512 | 530 | </span> | |
| 513 | 531 | )} | |
| 211 | 211 | <ul className="mt-1.5 space-y-1.5"> | |
| 212 | 212 | {recs.thin.map((rec) => ( | |
| 213 | 213 | <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} />} | |
| 215 | 215 | <span className="min-w-0"> | |
| 216 | 216 | {showAgent && <span className="text-muted">@{rec.agent_handle}: </span>} | |
| 217 | 217 | {rec.reason} | |
| ⋯ | |||
| 252 | 252 | return ( | |
| 253 | 253 | <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]"> | |
| 254 | 254 | <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" />} | |
| 256 | 256 | </span> | |
| 257 | 257 | <div className="min-w-0"> | |
| 258 | 258 | <p className="text-sm font-medium">{rec.title}</p> | |
| 103 | 103 | }; | |
| 104 | 104 | ||
| 105 | 105 | 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"> => { | |
| 107 | 107 | const at = key.indexOf(":"); | |
| 108 | 108 | const kind = key.slice(0, at) === "agent" ? "agent" : "user"; | |
| 109 | 109 | const id = key.slice(at + 1); | |
| 110 | 110 | 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 }; | |
| 111 | 111 | 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 }; | |
| 113 | 113 | }, | |
| 114 | 114 | [layout, names], | |
| 115 | 115 | ); |
| 13 | 13 | import { syntaxHighlighter } from "@blocknote/code-block"; | |
| 14 | 14 | import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react"; | |
| 15 | 15 | 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"; | |
| 17 | 17 | import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react"; | |
| 18 | 18 | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 19 | 19 | ||
| ⋯ | |||
| 28 | 28 | import { whoAre } from "../who"; | |
| 29 | 29 | ||
| 30 | 30 | /** 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 }; | |
| 32 | 32 | ||
| 33 | 33 | export type DocEditorProps = { | |
| 34 | 34 | slug: string; | |
| 154 | 154 | onResolve, | |
| 155 | 155 | }: { | |
| 156 | 156 | 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">; | |
| 158 | 158 | canComment: boolean; | |
| 159 | 159 | onPost: (text: string) => Promise<boolean>; | |
| 160 | 160 | onReply: (thread: string, text: string) => Promise<boolean>; |
| 15 | 15 | import { Hint } from "../ui/hint"; | |
| 16 | 16 | import { KindIcon } from "./kinds"; | |
| 17 | 17 | ||
| 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} />; | |
| 20 | 20 | return <Avatar name={who.name} image={who.avatar} size={size} />; | |
| 21 | 21 | } | |
| 22 | 22 |
| 2 | 2 | * Names and faces for member keys (`user:<id>`, `agent:<id>`): comment | |
| 3 | 3 | * authors and history. Asked of the site once per key per tab. | |
| 4 | 4 | */ | |
| 5 | − | import type { Result } from "@g1t/contracts"; | |
| 5 | + | import type { AgentLook, Result } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | /** 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 }; | |
| 9 | 9 | ||
| 10 | 10 | const known = new Map<string, Who>(); | |
| 11 | 11 |
| 45 | 45 | if (actor.kind === "user" || actor.kind === "agent") { | |
| 46 | 46 | return ( | |
| 47 | 47 | <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 }} | |
| 49 | 49 | size={32} | |
| 50 | 50 | /> | |
| 51 | 51 | ); |
| 32 | 32 | export function AgentPersonFace({ agent, size = 28, ring = "var(--color-bg)" }: { agent: PeopleAgent; size?: number; ring?: string }) { | |
| 33 | 33 | return ( | |
| 34 | 34 | <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 | + | )} | |
| 39 | 41 | </span> | |
| 40 | 42 | ); | |
| 41 | 43 | } |
| 124 | 124 | WorkspaceAgent, | |
| 125 | 125 | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "builtin" | |
| 126 | 126 | > & { | |
| 127 | + | look?: WorkspaceAgent["look"]; | |
| 127 | 128 | scope?: WorkspaceAgent["scope"]; | |
| 128 | 129 | /** The visible teams it is on (team memberships); absent when they couldn't be read. */ | |
| 129 | 130 | teams?: { slug: string; name: string }[]; |
| 315 | 315 | <ul className="mt-3 ml-10 space-y-2.5"> | |
| 316 | 316 | {shown.map((agent) => ( | |
| 317 | 317 | <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} /> | |
| 319 | 319 | <Link to={`/${slug}/-/agents/${agent.handle}/spend`} className="min-w-0 grow truncate hover:underline"> | |
| 320 | 320 | {agent.display_name} | |
| 321 | 321 | </Link> | |
| ⋯ | |||
| 477 | 477 | <ul className="divide-y divide-line/60"> | |
| 478 | 478 | {sessions.map((session) => ( | |
| 479 | 479 | <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} /> | |
| 481 | 481 | <div className="min-w-0 grow"> | |
| 482 | 482 | <Link to={receiptHref(slug, session)} className="block min-w-0 truncate text-sm font-medium after:absolute after:inset-0 hover:underline"> | |
| 483 | 483 | {session.visible ? session.title || "Untitled session" : <PrivateTitle />} | |
| 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"; | |
| 3 | 3 | import { useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | + | import type { AgentLook } from "@g1t/contracts"; | |
| 6 | + | import { lookFromSeed } from "@g1t/contracts/agent-look"; | |
| 7 | + | ||
| 5 | 8 | import { usercontentFrom } from "../../lib/addresses"; | |
| 9 | + | import { AgentFace, type FaceState, faceColorCss } from "../agent-face"; | |
| 6 | 10 | import { Mark } from "../logo"; | |
| 7 | 11 | ||
| 8 | 12 | // 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 | |
| 11 | 16 | // nothing jumps; an image that fails to load falls back to the letter. | |
| 12 | 17 | ||
| 13 | 18 | const AVATAR_HUES = [82, 200, 262, 28, 330, 160]; | |
| ⋯ | |||
| 30 | 35 | return `${usercontent}/avatars/${avatar}`; | |
| 31 | 36 | } | |
| 32 | 37 | ||
| 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 | + | ||
| 33 | 47 | /** | |
| 34 | 48 | * An uploaded avatar if there is one, else a letter avatar whose colour is | |
| 35 | 49 | * stable for a given name. People are round; a workspace is `square`. An | |
| 36 | 50 | * 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. | |
| 37 | 57 | */ | |
| 38 | 58 | export function Avatar({ | |
| 39 | 59 | name, | |
| ⋯ | |||
| 41 | 61 | square, | |
| 42 | 62 | image, | |
| 43 | 63 | system, | |
| 64 | + | agent, | |
| 65 | + | ring, | |
| 44 | 66 | }: { | |
| 45 | 67 | name: string; | |
| 46 | 68 | size?: number; | |
| ⋯ | |||
| 49 | 71 | image?: string | null; | |
| 50 | 72 | /** g1t itself (a user of kind `system`), whatever the name. */ | |
| 51 | 73 | 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; | |
| 52 | 78 | }) { | |
| 53 | 79 | const [failed, setFailed] = useState<string | null>(null); | |
| 54 | 80 | 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 | + | } | |
| 55 | 117 | // g1t itself wears its own mark: the pixel 1 on a dark square. | |
| 56 | − | if (system || isSystemName(name)) { | |
| 118 | + | if (g1t) { | |
| 57 | 119 | return ( | |
| 58 | 120 | <span | |
| 59 | 121 | aria-hidden="true" | |
| ⋯ | |||
| 113 | 175 | </span> | |
| 114 | 176 | ); | |
| 115 | 177 | } | |
| 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 | + | } | |
| 278 | 278 | }) { | |
| 279 | 279 | const face = (size: number) => | |
| 280 | 280 | 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} /> | |
| 282 | 282 | ) : ( | |
| 283 | 283 | <Avatar name={author} size={size} /> | |
| 284 | 284 | ); | |
| ⋯ | |||
| 751 | 751 | const said = verdict === "approve" ? "approved" : verdict === "request_changes" ? "requested changes" : "reviewed"; | |
| 752 | 752 | return ( | |
| 753 | 753 | <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} /> | |
| 755 | 755 | <AgentName agent={agent} href={href} /> | |
| 756 | 756 | <span>{said}</span> | |
| 757 | 757 | <AdvisoryChip /> | |
| 6 | 6 | * tested under Node. | |
| 7 | 7 | */ | |
| 8 | 8 | import type { AgentBudget, AgentRouting, NewWorkspaceAgent, PersonalityPreset, WorkspaceAgent, WorkspaceAgentScope } from "@g1t/contracts"; | |
| 9 | + | import { readLook } from "@g1t/contracts/agent-look"; | |
| 9 | 10 | import { connectorById, connectorPath } from "@g1t/contracts/connectors"; | |
| 10 | 11 | ||
| 11 | 12 | import { wholeDollars } from "./money.ts"; | |
| ⋯ | |||
| 50 | 51 | skills_off: list("skills_off", 40, 20), | |
| 51 | 52 | template: null, | |
| 52 | 53 | avatar_seed: text("avatar_seed", 64) || undefined, | |
| 54 | + | look: readLook(d.look), | |
| 53 | 55 | scope: d.scope === "personal" ? "personal" : "workspace", | |
| 54 | 56 | }; | |
| 55 | 57 | } | |
| ⋯ | |||
| 138 | 140 | } | |
| 139 | 141 | plain("personality_preset", "Voice"); | |
| 140 | 142 | 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 | + | } | |
| 141 | 146 | if (changes.skills_off !== undefined) { | |
| 142 | 147 | const off = new Set(changes.skills_off); | |
| 143 | 148 | const was = new Set(agent.skills_off ?? []); | |
| 3 | 3 | * it: what each field means, and the checks made before it is sent. Pure, | |
| 4 | 4 | * so it is tested on its own. | |
| 5 | 5 | */ | |
| 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"; | |
| 7 | 8 | ||
| 8 | 9 | import { plainDollars } from "./money.ts"; | |
| 9 | 10 | ||
| ⋯ | |||
| 139 | 140 | ...(form.get("scope") === "personal" || form.get("scope") === "workspace" ? { scope: form.get("scope") as "personal" | "workspace" } : {}), | |
| 140 | 141 | ...(form.has("skills_off") ? { skills_off: String(form.get("skills_off") ?? "").split(",").map((id) => id.trim()).filter(Boolean) } : {}), | |
| 141 | 142 | ...(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")), | |
| 142 | 145 | }, | |
| 143 | 146 | }; | |
| 144 | 147 | } | |
| ⋯ | |||
| 146 | 149 | /** Most responsibilities an agent lists. */ | |
| 147 | 150 | export const MAX_RESPONSIBILITIES = 8; | |
| 148 | 151 | ||
| 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 | + | ||
| 149 | 165 | /** The subagents, as the form sends them (JSON); null when they are not valid. */ | |
| 150 | 166 | function readSubagents(value: FormDataEntryValue | null): SubagentDef[] | null { | |
| 151 | 167 | const text = String(value ?? "").trim(); | |
| ⋯ | |||
| 211 | 227 | | "autonomy" | |
| 212 | 228 | | "capacity" | |
| 213 | 229 | | "template" | |
| 230 | + | | "look" | |
| 214 | 231 | >; | |
| 215 | 232 | ||
| 216 | 233 | export const BLANK_DRAFT: AgentDraft = { | |
| ⋯ | |||
| 229 | 246 | autonomy: { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" }, | |
| 230 | 247 | capacity: 3, | |
| 231 | 248 | template: null, | |
| 249 | + | look: null, | |
| 232 | 250 | }; | |
| 233 | 251 | ||
| 234 | 252 | /** Dollars from micro-dollars, for a form field: empty for none, `20` for a whole sum, else to the cent. */ | |
| 9 | 9 | channelName, | |
| 10 | 10 | channelPath, | |
| 11 | 11 | dayLabel, | |
| 12 | + | dmPlaceholder, | |
| 12 | 13 | filterEntries, | |
| 13 | 14 | inline, | |
| 15 | + | mixedDm, | |
| 16 | + | mixedNotice, | |
| 14 | 17 | mentionNames, | |
| 15 | 18 | mentionQuery, | |
| 16 | 19 | mergeMessages, | |
| ⋯ | |||
| 149 | 152 | entry("d1", { kind: "dm", title: "reviewer", others: [bot], last: "2026-10-02T00:00:00Z", mentions: 1, unread: 1 }), | |
| 150 | 153 | entry("d2", { kind: "dm", title: "ana", others: [ana], last: "2026-10-05T00:00:00Z" }), | |
| 151 | 154 | 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" }), | |
| 152 | 158 | ]; | |
| 153 | − | const { pinned, channels, agentDms, dms } = sections(all); | |
| 159 | + | const { pinned, channels, agentDms, people, agents } = sections(all); | |
| 154 | 160 | assert.deepEqual(pinned.map((e) => e.title), ["general"]); | |
| 155 | 161 | 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]); | |
| 159 | 167 | assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "reviewer", "noisy"]); | |
| 160 | 168 | 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"]); | |
| 162 | 171 | assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 }); | |
| 163 | 172 | }); | |
| 164 | 173 | ||
| 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 | + | ||
| 165 | 183 | test("addresses, names, money and reconnecting", () => { | |
| 166 | 184 | assert.equal(channelPath("acme", { id: "c1", kind: "channel", name: "general" }), "/acme/-/chat/general"); | |
| 167 | 185 | assert.equal(channelPath("acme", { id: "d1", kind: "dm", name: null }), "/acme/-/chat/dm/d1"); | |
| 168 | 168 | return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null; | |
| 169 | 169 | } | |
| 170 | 170 | ||
| 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 | + | ||
| 171 | 222 | /** | |
| 172 | 223 | * 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. | |
| 177 | 230 | */ | |
| 178 | 231 | export function sections(entries: ChatSidebarEntry[]): { | |
| 179 | 232 | pinned: ChatSidebarEntry[]; | |
| 180 | 233 | channels: ChatSidebarEntry[]; | |
| 181 | 234 | agentDms: Map<string, ChatSidebarEntry>; | |
| 182 | − | dms: ChatSidebarEntry[]; | |
| 235 | + | people: ChatSidebarEntry[]; | |
| 236 | + | agents: ChatSidebarEntry[]; | |
| 183 | 237 | } { | |
| 184 | 238 | const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title); | |
| 185 | 239 | // Direct messages: the latest conversation first, as people scan them. | |
| ⋯ | |||
| 190 | 244 | const agent = agentDmOf(entry); | |
| 191 | 245 | if (agent) agentDms.set(agent, entry); | |
| 192 | 246 | } | |
| 247 | + | const dms = entries.filter((e) => !e.starred && e.channel.kind === "dm"); | |
| 193 | 248 | return { | |
| 194 | 249 | pinned: entries.filter((e) => e.starred).sort(byName), | |
| 195 | 250 | channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName), | |
| 196 | 251 | 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), | |
| 198 | 254 | }; | |
| 199 | 255 | } | |
| 200 | 256 | ||
| 74 | 74 | agent_handle: "margo", | |
| 75 | 75 | agent_name: "Margo", | |
| 76 | 76 | agent_avatar_seed: "margo", | |
| 77 | + | agent_look: null, | |
| 77 | 78 | subagent: null, | |
| 78 | 79 | kind: "chat", | |
| 79 | 80 | parent_id: null, |
| 8 | 8 | /** An agent as People's pages need it. */ | |
| 9 | 9 | export type PeopleAgent = Pick< | |
| 10 | 10 | 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" | |
| 12 | 12 | >; | |
| 13 | 13 | ||
| 14 | 14 | /** Just what People's pages read of an agent, so loaders send no more. */ | |
| 15 | 15 | 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 }; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | /** Where a person's profile is. */ |
| 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 | − | }); |
| 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 | − | } |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | /** 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">; | |
| 38 | 38 | ||
| 39 | 39 | /** Every level of budget, widest first, with what is spent against each this month. */ | |
| 40 | 40 | export type Budgets = { | |
| ⋯ | |||
| 68 | 68 | agents: agents | |
| 69 | 69 | ? agents | |
| 70 | 70 | .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 })) | |
| 72 | 72 | .sort((a, b) => Number(b.builtin) - Number(a.builtin) || b.spent_month_micros - a.spent_month_micros) | |
| 73 | 73 | : null, | |
| 74 | 74 | }; | |
| 296 | 296 | display_name: agent.display_name, | |
| 297 | 297 | avatar: agent.avatar, | |
| 298 | 298 | avatar_seed: agent.avatar_seed, | |
| 299 | + | look: agent.look ?? null, | |
| 299 | 300 | role: agent.role, | |
| 300 | 301 | title: agent.title, | |
| 301 | 302 | status: agent.status, |
| 142 | 142 | personality: d.personality ?? "", | |
| 143 | 143 | routing: { floor: d.routing?.floor ?? null, ceiling: d.routing?.ceiling ?? null, providers: [], pinned: null }, | |
| 144 | 144 | 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, | |
| 145 | 146 | }; | |
| 146 | 147 | } | |
| 147 | 148 |
| 22 | 22 | ||
| 23 | 23 | import type { Route } from "./+types/session"; | |
| 24 | 24 | import { AgentAvatar } from "../../../components/agent-avatar"; | |
| 25 | + | import { sessionFaceOf } from "../../../components/agent-face"; | |
| 25 | 26 | import { agentsAction, answer } from "../../../components/agents/actions.server"; | |
| 26 | 27 | import { type ActionResult, ApproveDialog, Confirm } from "../../../components/agents/dialogs"; | |
| 27 | 28 | import { isLive, kindLabel, sessionRows, whereLabel } from "../../../components/agents/format"; | |
| ⋯ | |||
| 159 | 160 | <dl className="mt-5 grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4"> | |
| 160 | 161 | <Meta label="Agent"> | |
| 161 | 162 | <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)} /> | |
| 163 | 164 | {session.agent_name} | |
| 164 | 165 | </Link> | |
| 165 | 166 | </Meta> | |
| ⋯ | |||
| 508 | 509 | const body = ( | |
| 509 | 510 | <> | |
| 510 | 511 | {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)} /> | |
| 512 | 513 | <span className="min-w-0 grow"> | |
| 513 | 514 | <span className="block truncate text-[0.8125rem]">{session.visible ? session.title : "A private session"}</span> | |
| 514 | 515 | <span className="block truncate text-[0.6875rem] text-faint"> | |
| 8 | 8 | import { Link, data } from "react-router"; | |
| 9 | 9 | ||
| 10 | 10 | import type { Route } from "./+types/template"; | |
| 11 | − | import { PixelCreature } from "../../../components/agent-avatar"; | |
| 11 | + | import { AgentAvatar } from "../../../components/agent-avatar"; | |
| 12 | 12 | import { StartAction } from "../../../components/agent-templates"; | |
| 13 | 13 | import { PRESETS } from "../../../lib/agent-form"; | |
| 14 | 14 | import { routingWords, templateListings, templatesPath } from "../../../lib/agent-templates"; | |
| ⋯ | |||
| 57 | 57 | <div className="pb-4"> | |
| 58 | 58 | {back} | |
| 59 | 59 | <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} /> | |
| 61 | 61 | <div className="min-w-0 grow basis-60"> | |
| 62 | 62 | <h1 className="text-2xl font-semibold tracking-tight">{template.title}</h1> | |
| 63 | 63 | <p className="mt-0.5 text-sm text-muted">A role template from g1t · Suggests {template.display_name}</p> | |
| ⋯ | |||
| 153 | 153 | <ul className="mt-2 space-y-1.5"> | |
| 154 | 154 | {agents.map((agent) => ( | |
| 155 | 155 | <li key={agent.id} className="flex items-center gap-2"> | |
| 156 | − | <PixelCreature seed={agent.handle} size={20} /> | |
| 156 | + | <AgentAvatar agent={agent} size={20} /> | |
| 157 | 157 | <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline"> | |
| 158 | 158 | {agent.display_name} <span className="text-faint">@{agent.handle}</span> | |
| 159 | 159 | </Link> | |
| 292 | 292 | const out = keys.map((key) => { | |
| 293 | 293 | if (key.startsWith("agent:")) { | |
| 294 | 294 | 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 }; | |
| 296 | 296 | } | |
| 297 | 297 | const username = names[key.slice(5)]?.toLowerCase(); | |
| 298 | 298 | const person = username ? people.find((p) => p.name === username) : undefined; |
| 53 | 53 | sidebar, | |
| 54 | 54 | mentionables: [ | |
| 55 | 55 | ...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 })), | |
| 57 | 57 | ], | |
| 58 | 58 | zone: zone && knownTimeZone(zone) ? zone : null, | |
| 59 | 59 | }; |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 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 | + | } |
| 8 | 8 | import type { ServiceBinding } from "./clients"; | |
| 9 | 9 | import type { User } from "./identity"; | |
| 10 | 10 | import type { Result } from "./result"; | |
| 11 | + | import type { AgentLook } from "./agent-look"; | |
| 11 | 12 | import type { AskerAccess } from "./workspace-agents"; | |
| 12 | 13 | ||
| 13 | 14 | /** Who is speaking: a person (by user id) or a workspace agent (by agent id). */ | |
| ⋯ | |||
| 49 | 50 | * it; optional so profiles a page makes for itself need not. | |
| 50 | 51 | */ | |
| 51 | 52 | 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. */ | |
| 53 | 54 | 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; | |
| 54 | 57 | }; | |
| 55 | 58 | ||
| 56 | 59 | /** | |
| 24 | 24 | * where that person can edit AND the space lets agents edit | |
| 25 | 25 | * (`agent_mode: "edit"`). Otherwise its edit becomes a suggestion. | |
| 26 | 26 | */ | |
| 27 | + | import type { AgentLook } from "./agent-look"; | |
| 27 | 28 | import type { MemberProfile, Principal } from "./chat"; | |
| 28 | 29 | import type { ServiceBinding } from "./clients"; | |
| 29 | 30 | import type { User } from "./identity"; | |
| ⋯ | |||
| 106 | 107 | display_name: string; | |
| 107 | 108 | avatar: string | null; | |
| 108 | 109 | avatar_seed?: string | null; | |
| 110 | + | look?: AgentLook | null; | |
| 109 | 111 | role: DocRole; | |
| 110 | 112 | }; | |
| 111 | 113 | ||
| 4 | 4 | export * from "./account-deletion"; | |
| 5 | 5 | export * from "./accounts"; | |
| 6 | 6 | export * from "./actions"; | |
| 7 | + | export * from "./agent-look"; | |
| 7 | 8 | export * from "./agents"; | |
| 8 | 9 | export * from "./audit"; | |
| 9 | 10 | export * from "./billing"; |
| 7 | 7 | * events service about every inbox item, and the site forwards each tab's | |
| 8 | 8 | * `/-/live` socket to it. Wire shapes are snake_case end to end. | |
| 9 | 9 | */ | |
| 10 | + | import type { AgentLook } from "./agent-look"; | |
| 10 | 11 | import type { CardAction } from "./chat"; | |
| 11 | 12 | import type { ServiceBinding } from "./clients"; | |
| 12 | 13 | import type { User } from "./identity"; | |
| ⋯ | |||
| 24 | 25 | name: string; | |
| 25 | 26 | /** A person's uploaded avatar hash, or null for the letter avatar. */ | |
| 26 | 27 | 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. */ | |
| 28 | 29 | avatar_seed?: string | null; | |
| 30 | + | /** An agent's chosen face (./agent-look.ts). */ | |
| 31 | + | look?: AgentLook | null; | |
| 29 | 32 | }; | |
| 30 | 33 | ||
| 31 | 34 | export type FeedNotification = { | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.