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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user