Files
Teamup/client/src/components/agent-face.css
T
soroush.asadi d50cd2790e 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>
2026-06-15 15:21:10 +03:30

147 lines
4.7 KiB
CSS

/*
* 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; } }