diff --git a/client/src/components/AgentFace.tsx b/client/src/components/AgentFace.tsx
new file mode 100644
index 0000000..d877179
--- /dev/null
+++ b/client/src/components/AgentFace.tsx
@@ -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 = {
+ idle: 'idle',
+ thinking: 'queued',
+ working: 'working',
+ review: 'awaiting review',
+ done: 'done',
+ failed: 'failed',
+}
+
+/**
+ * Deterministic hue in the indigo–violet 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/agent-face.css b/client/src/components/agent-face.css
new file mode 100644
index 0000000..7cf3212
--- /dev/null
+++ b/client/src/components/agent-face.css
@@ -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; } }
diff --git a/client/src/lib/useAgentActivity.ts b/client/src/lib/useAgentActivity.ts
new file mode 100644
index 0000000..997d9dc
--- /dev/null
+++ b/client/src/lib/useAgentActivity.ts
@@ -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>({})
+ const [held, setHeld] = useState>(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(`/api/assembler/agent-activity?agentIds=${encodeURIComponent(key)}`),
+ organizationId
+ ? api.get(`/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],
+ )
+}
diff --git a/client/src/pages/BoardPage.tsx b/client/src/pages/BoardPage.tsx
index 7b3c872..130a042 100644
--- a/client/src/pages/BoardPage.tsx
+++ b/client/src/pages/BoardPage.tsx
@@ -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({
{task.title}
{task.type}
-
+
@@ -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 (
- AI
+
{seat?.roleName ?? 'AI seat'}
)
diff --git a/client/src/pages/OrgChartPage.tsx b/client/src/pages/OrgChartPage.tsx
index 0c928e4..9fc6b63 100644
--- a/client/src/pages/OrgChartPage.tsx
+++ b/client/src/pages/OrgChartPage.tsx
@@ -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 = { 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 (
+
+
+ {d.isAi ? (
+
+ ) : (
+
+ )}
+
+
+ {d.roleName}
+
+ {d.isAi ? d.faceState : d.seatState}
+
+
+
+ )
+}
+
+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() {
-
+
@@ -97,6 +149,7 @@ function buildGraph(
products: Product[],
teams: Team[],
seatsByTeam: Record,
+ 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 })
diff --git a/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerDtos.cs b/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerDtos.cs
index 087f5d5..9a8148e 100644
--- a/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerDtos.cs
+++ b/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerDtos.cs
@@ -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);
diff --git a/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerEndpoints.cs b/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerEndpoints.cs
index 988660b..f2eec8a 100644
--- a/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerEndpoints.cs
+++ b/src/Modules/TeamUp.Modules.Assembler/Endpoints/AssemblerEndpoints.cs
@@ -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 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());
+ }
+
+ // 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