Animated agent faces driven by live run state

Each AI agent now has an expressive Companion face (AgentFace) whose animation
maps to its real AgentRun state — idle, thinking (queued), working (running),
review (held), done, failed — so a glance at the board or org chart reads as live
status, the same way the seat-state triad reads human/open/AI. Pure CSS keyframes
(no animation dependency), em-scaled across four sizes, per-agent hue derived
deterministically in the indigo band, reduced-motion respected.

Adds a per-team agent-activity read endpoint (latest run status per agent) and a
self-contained polling hook (useAgentActivity) that merges run activity with
governance holds. Wired into the board assignee chips and the org chart (a custom
React Flow seat node with hidden handles so edges still connect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-15 15:21:10 +03:30
parent c8d9af6191
commit d50cd2790e
7 changed files with 433 additions and 15 deletions
+69
View File
@@ -0,0 +1,69 @@
import { cn } from '@/lib/utils'
import './agent-face.css'
/**
* The live state of an agent, mapped from its latest AgentRun (+ governance hold) onto an expression.
* `idle` = nothing in flight; `thinking` = queued; `working` = running; `review` = output held in the
* inbox; `done` = just completed & executed; `failed` = the run errored.
*/
export type FaceState = 'idle' | 'thinking' | 'working' | 'review' | 'done' | 'failed'
export type FaceSize = 'sm' | 'md' | 'lg' | 'xl'
interface AgentFaceProps {
name?: string | null
/** Used only to seed the per-agent hue and the accessible label — never drawn on the face. */
monogram?: string | null
state?: FaceState
size?: FaceSize
className?: string
}
const STATE_LABEL: Record<FaceState, string> = {
idle: 'idle',
thinking: 'queued',
working: 'working',
review: 'awaiting review',
done: 'done',
failed: 'failed',
}
/**
* Deterministic hue in the indigoviolet band [225, 265] so every agent is distinct yet stays inside
* the AI = indigo identity. Seeded by the agent's monogram/name so it is stable across renders and
* needs no stored field.
*/
function hueFor(seed: string): number {
let h = 0
for (let i = 0; i < seed.length; i += 1) h = (h * 31 + seed.charCodeAt(i)) >>> 0
return 225 + (h % 41)
}
/** The expressive Companion face. One component, every surface — sized by `size`, animated by `state`. */
export function AgentFace({ name, monogram, state = 'idle', size = 'md', className }: AgentFaceProps) {
const hue = hueFor((monogram || name || 'agent').trim().toLowerCase())
const label = `${name ?? 'AI agent'}${STATE_LABEL[state]}`
return (
<span
className={cn('agent-face', `af-${size}`, className)}
data-state={state}
style={{ ['--hue' as string]: hue }}
role="img"
aria-label={label}
title={label}
>
<span className="af-ring" />
<span className="af-spin" />
<span className="af-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
<span className="af-head" />
<span className="af-eye af-eye-l" />
<span className="af-eye af-eye-r" />
<span className="af-mouth" />
</span>
)
}
+146
View File
@@ -0,0 +1,146 @@
/*
* The Companion agent face. One expressive face used at every size; the animation is load-bearing —
* it maps to a real AgentRun state (queued/running/held/completed/failed) so a glance reads as live
* status, the same way the seat-state triad reads human/open/AI. All metrics are in `em` and the size
* classes set the root font-size, so the whole face scales from a board chip to the configurator.
*/
.agent-face {
position: relative;
display: inline-block;
width: 6em;
height: 6em;
flex: none;
line-height: 0;
--rc: #64748b; /* state ring colour, overridden per state */
--hue: 245;
}
.agent-face.af-sm { font-size: 3.3px; }
.agent-face.af-md { font-size: 7.3px; }
.agent-face.af-lg { font-size: 14px; }
.agent-face.af-xl { font-size: 20px; }
.af-head {
position: absolute;
inset: 0;
border-radius: 30%;
background: hsl(var(--hue) 62% 62%);
animation: af-breathe 3.4s ease-in-out infinite;
}
.af-ring {
position: absolute;
inset: -0.55em;
border-radius: 32%;
border: 0.18em solid var(--rc);
opacity: 0.85;
transition: border-color 0.35s ease, opacity 0.35s ease;
}
.af-spin {
position: absolute;
inset: -0.55em;
border-radius: 32%;
border: 0.18em solid transparent;
border-top-color: var(--rc);
opacity: 0;
}
.af-eye {
position: absolute;
top: 0.42em;
width: 0.13em;
height: 0.13em;
width: 0.8em;
height: 0.8em;
background: #fff;
border-radius: 50%;
animation: af-blink 4s infinite;
}
.af-eye-l { left: 0.27em; }
.af-eye-r { right: 0.27em; }
.af-mouth {
position: absolute;
bottom: 0.24em;
left: 50%;
transform: translateX(-50%);
width: 1.15em;
height: 0.2em;
border-radius: 0.2em;
background: rgba(255, 255, 255, 0.85);
}
.af-dots {
position: absolute;
top: -0.15em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.22em;
opacity: 0;
}
.af-dots i {
width: 0.36em;
height: 0.36em;
border-radius: 50%;
background: #6366f1;
animation: af-bob 0.9s infinite;
}
.af-dots i:nth-child(2) { animation-delay: 0.15s; }
.af-dots i:nth-child(3) { animation-delay: 0.3s; }
/* The mouth and thinking-dots are clutter at chip size — eyes + ring carry the state there. */
.af-sm .af-mouth,
.af-sm .af-dots { display: none; }
/* ---- state: ring colour ---- */
.agent-face[data-state='idle'] { --rc: #64748b; }
.agent-face[data-state='thinking'] { --rc: #6366f1; }
.agent-face[data-state='working'] { --rc: #6366f1; }
.agent-face[data-state='review'] { --rc: #f59e0b; }
.agent-face[data-state='done'] { --rc: #14b8a6; }
.agent-face[data-state='failed'] { --rc: #ef4444; }
/* ---- state: expression ---- */
.agent-face[data-state='thinking'] .af-eye { top: 0.36em; height: 0.5em; border-radius: 40%; }
.agent-face[data-state='thinking'] .af-dots { opacity: 1; }
.agent-face[data-state='thinking'] .af-ring { animation: af-rpulse 1.4s ease-in-out infinite; }
.agent-face[data-state='working'] .af-eye { height: 0.92em; top: 0.4em; }
.agent-face[data-state='working'] .af-mouth { width: 0.6em; }
.agent-face[data-state='working'] .af-spin { opacity: 1; animation: af-spin 1.05s linear infinite; }
.agent-face[data-state='working'] .af-ring { opacity: 0.3; }
.agent-face[data-state='review'] .af-ring { animation: af-rpulse 1s ease-in-out infinite; }
.agent-face[data-state='review'] .af-eye { top: 0.34em; }
.agent-face[data-state='done'] .af-eye {
height: 0.42em;
border-radius: 0 0 0.8em 0.8em;
top: 0.5em;
}
.agent-face[data-state='done'] .af-mouth {
width: 1.4em;
height: 0.62em;
border-radius: 0 0 1.4em 1.4em;
border-bottom: 0.2em solid #fff;
background: transparent;
}
.agent-face[data-state='done'] .af-ring { animation: af-pop 0.5s ease-out; }
.agent-face[data-state='failed'] .af-head { background: hsl(var(--hue) 14% 56%); }
.agent-face[data-state='failed'] .af-eye { height: 0.28em; border-radius: 0.14em; top: 0.56em; background: #e6e0ef; }
.agent-face[data-state='failed'] .af-mouth {
width: 0.85em;
height: 0.55em;
border-radius: 1.4em 1.4em 0 0;
border-top: 0.2em solid #e6e0ef;
background: transparent;
bottom: 0.2em;
}
@media (prefers-reduced-motion: reduce) {
.af-head, .af-ring, .af-spin, .af-eye, .af-dots i { animation: none !important; }
}
@keyframes af-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.045); } }
@keyframes af-blink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
@keyframes af-spin { to { transform: rotate(360deg); } }
@keyframes af-rpulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.3; } }
@keyframes af-pop { 0% { transform: scale(0.8); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } }
@keyframes af-bob { 0%, 100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(-0.3em); opacity: 1; } }
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '@/lib/api'
import type { FaceState } from '@/components/AgentFace'
interface AgentActivity {
agentId: string
status: string
workItemId: string
updatedAtUtc: string
}
interface PendingReview {
agentId: string
}
/** A just-completed run shows the `done` (teal) face for this long, then settles to `idle`. */
const DONE_WINDOW_MS = 45_000
const POLL_MS = 4_000
function faceFor(activity: AgentActivity | undefined, held: boolean): FaceState {
if (held) return 'review'
if (!activity) return 'idle'
switch (activity.status) {
case 'Failed':
return 'failed'
case 'Running':
return 'working'
case 'Queued':
return 'thinking'
case 'Completed': {
const age = Date.now() - new Date(activity.updatedAtUtc).getTime()
return age >= 0 && age < DONE_WINDOW_MS ? 'done' : 'idle'
}
default:
return 'idle'
}
}
/**
* Polls per-agent run activity (Assembler) and pending holds (Governance) and maps each agent to a
* live face state. Self-contained polling — no query client needed. Pass the agent ids currently on
* screen (the caller already holds them via its seats); an empty list disables the poll.
*/
export function useAgentActivity(organizationId: string | null, agentIds: (string | null | undefined)[]) {
const ids = agentIds.filter((x): x is string => !!x)
const key = [...new Set(ids)].sort().join(',')
const [activity, setActivity] = useState<Record<string, AgentActivity>>({})
const [held, setHeld] = useState<Set<string>>(new Set())
const keyRef = useRef(key)
keyRef.current = key
useEffect(() => {
if (!key) {
setActivity({})
setHeld(new Set())
return
}
let cancelled = false
const tick = async () => {
try {
const [runs, reviews] = await Promise.all([
api.get<AgentActivity[]>(`/api/assembler/agent-activity?agentIds=${encodeURIComponent(key)}`),
organizationId
? api.get<PendingReview[]>(`/api/governance/reviews?organizationId=${organizationId}&status=Pending`)
: Promise.resolve([] as PendingReview[]),
])
if (cancelled) return
setActivity(Object.fromEntries(runs.map((r) => [r.agentId, r])))
setHeld(new Set(reviews.map((r) => r.agentId)))
} catch {
// Keep the last known state on a transient failure — the face just stops updating briefly.
}
}
void tick()
const timer = setInterval(tick, POLL_MS)
return () => {
cancelled = true
clearInterval(timer)
}
// `key` captures the set of agent ids; re-poll when it or the org changes.
}, [key, organizationId])
return useCallback(
(agentId?: string | null): FaceState =>
agentId ? faceFor(activity[agentId], held.has(agentId)) : 'idle',
[activity, held],
)
}
+10 -2
View File
@@ -31,7 +31,9 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { AgentFace, type FaceState } from '@/components/AgentFace'
import { api } from '@/lib/api'
import { useAgentActivity } from '@/lib/useAgentActivity'
import { useMembers, useSeats, type MemberRow, type SeatRow } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
@@ -79,6 +81,7 @@ export function BoardPage() {
const members = useMembers(organizationId)
const seats = useSeats(teamId)
const agentState = useAgentActivity(organizationId, seats.map((s) => s.agentId))
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
@@ -244,6 +247,7 @@ export function BoardPage() {
memberId={memberId}
members={members}
seats={seats}
agentState={agentState}
onOpen={() => setOpenTaskId(task.id)}
/>
))}
@@ -298,12 +302,14 @@ function DraggableCard({
memberId,
members,
seats,
agentState,
onOpen,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
agentState: (agentId?: string | null) => FaceState
onOpen: () => void
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id })
@@ -324,7 +330,7 @@ function DraggableCard({
<span className="text-sm font-medium leading-snug">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
<AssigneeChip task={task} memberId={memberId} members={members} seats={seats} />
<AssigneeChip task={task} memberId={memberId} members={members} seats={seats} agentState={agentState} />
</CardContent>
</Card>
</div>
@@ -337,17 +343,19 @@ function AssigneeChip({
memberId,
members,
seats,
agentState,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
agentState: (agentId?: string | null) => FaceState
}) {
if (task.assigneeKind === 'Agent') {
const seat = seats.find((s) => s.agentId === task.assigneeId)
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="grid size-5 place-items-center rounded bg-seat-ai text-[9px] font-bold text-white">AI</span>
<AgentFace size="sm" name={seat?.roleName} monogram={seat?.roleName} state={agentState(task.assigneeId)} />
{seat?.roleName ?? 'AI seat'}
</span>
)
+64 -13
View File
@@ -1,12 +1,59 @@
import { useEffect, useMemo, useState } from 'react'
import { Background, ReactFlow, type Edge, type Node } from '@xyflow/react'
import { Background, Handle, Position, ReactFlow, type Edge, type Node, type NodeProps } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { AgentFace, type FaceState } from '@/components/AgentFace'
import { api } from '@/lib/api'
import { useAgentActivity } from '@/lib/useAgentActivity'
import { useAuth } from '@/store/auth'
import type { SeatRow } from '@/lib/useDirectory'
interface SeatNodeData {
roleName: string
seatState: string
isAi: boolean
faceState: FaceState
[key: string]: unknown
}
const SEAT_BG: Record<string, string> = { Ai: '#4f46e5', Human: '#475569', Open: '#d97706' }
/** A seat in the org chart. AI seats wear their live face; the triad colour stays load-bearing. */
function SeatNode({ data }: NodeProps) {
const d = data as SeatNodeData
return (
<div
style={{
background: SEAT_BG[d.seatState] ?? '#475569',
color: 'white',
borderRadius: 8,
width: 180,
padding: '8px 10px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
{d.isAi ? (
<AgentFace size="md" name={d.roleName} monogram={d.roleName} state={d.faceState} />
) : (
<span style={{ width: 14, height: 14, borderRadius: '50%', background: 'rgba(255,255,255,0.85)' }} />
)}
<span style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.15, minWidth: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.roleName}
</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>{d.isAi ? d.faceState : d.seatState}</span>
</span>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
</div>
)
}
const nodeTypes = { seat: SeatNode }
interface Division {
id: string
name: string
@@ -66,9 +113,14 @@ export function OrgChartPage() {
})()
}, [organizationId])
const agentState = useAgentActivity(
organizationId,
Object.values(seatsByTeam).flat().map((s) => s.agentId),
)
const { nodes, edges } = useMemo(
() => buildGraph(divisions, products, teams, seatsByTeam),
[divisions, products, teams, seatsByTeam],
() => buildGraph(divisions, products, teams, seatsByTeam, agentState),
[divisions, products, teams, seatsByTeam, agentState],
)
return (
@@ -83,7 +135,7 @@ export function OrgChartPage() {
</p>
</div>
<div className="min-h-[480px] flex-1 overflow-hidden rounded-xl border bg-background">
<ReactFlow nodes={nodes} edges={edges} fitView proOptions={{ hideAttribution: true }}>
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView proOptions={{ hideAttribution: true }}>
<Background gap={20} />
</ReactFlow>
</div>
@@ -97,6 +149,7 @@ function buildGraph(
products: Product[],
teams: Team[],
seatsByTeam: Record<string, SeatRow[]>,
agentStateFor: (agentId?: string | null) => FaceState,
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
@@ -141,18 +194,16 @@ function buildGraph(
const seats = seatsByTeam[team.id] ?? []
seats.forEach((seat, seatIndex) => {
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
const isAi = seat.state === 'Ai'
nodes.push({
id: seat.id,
type: 'seat',
position: { x: x + 10, y: seatY + seatIndex * SEAT_HEIGHT },
data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
style: {
background: color,
color: 'white',
borderRadius: 8,
border: 'none',
fontSize: 12,
width: 180,
data: {
roleName: seat.roleName,
seatState: seat.state,
isAi,
faceState: isAi ? agentStateFor(seat.agentId) : 'idle',
},
})
edges.push({ id: `${team.id}-${seat.id}`, source: team.id, target: seat.id })
@@ -13,3 +13,9 @@ internal sealed record RunResponse(
string? Prompt,
string? Output,
string? Error);
internal sealed record AgentActivityResponse(
Guid AgentId,
string Status,
Guid WorkItemId,
DateTimeOffset UpdatedAtUtc);
@@ -18,6 +18,53 @@ internal static class AssemblerEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("assembler")));
group.MapPost("/runs", CreateRun).RequireAuthorization();
group.MapGet("/runs/{id:guid}", GetRun).RequireAuthorization();
group.MapGet("/agent-activity", GetAgentActivity).RequireAuthorization();
}
// The live pulse behind each agent's face: the latest run status per agent. The client passes the
// ids of the AI seats it is showing (it already holds them) and composes the on-screen face state —
// this keeps the module boundary clean (Assembler owns runs; it never reaches into seats/teams).
private static async Task<IResult> GetAgentActivity(
string? agentIds, AssemblerDbContext db, CancellationToken ct)
{
var ids = (agentIds ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => Guid.TryParse(s, out var g) ? g : (Guid?)null)
.Where(g => g.HasValue)
.Select(g => g!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
{
return Results.Ok(Array.Empty<AgentActivityResponse>());
}
// Latest run per agent. Project the few columns we need, then pick the newest per agent in
// memory — at dogfood scale this is a small set and avoids brittle GroupBy translation.
var runs = await db.AgentRuns
.Where(r => r.AgentId != null && ids.Contains(r.AgentId!.Value))
.Select(r => new
{
AgentId = r.AgentId!.Value,
r.Status,
r.WorkItemId,
r.CreatedAtUtc,
r.CompletedAtUtc,
})
.ToListAsync(ct);
var activity = runs
.GroupBy(r => r.AgentId)
.Select(g => g.OrderByDescending(r => r.CreatedAtUtc).First())
.Select(r => new AgentActivityResponse(
r.AgentId,
r.Status.ToString(),
r.WorkItemId,
r.CompletedAtUtc ?? r.CreatedAtUtc))
.ToList();
return Results.Ok(activity);
}
// Dispatch a task to an AI seat: record a queued AgentRun and enqueue the job. The worker