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