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
+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 })