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