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
@@ -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);
@@ -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<IResult> 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<AgentActivityResponse>());
}
// 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