Lagebild, Stufe 1: Einheiten als aufklappbare Container (#711)

- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
  darunter, offene Freigaben und der dringlichste Zustand (Urgency:
  haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
  der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
  Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
  dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
  werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
  nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
  Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
  Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
  bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 15:27:33 +02:00
co-authored by Claude Opus 5.5
parent 5b8656d969
commit 920d3fed64
9 changed files with 692 additions and 101 deletions
+77 -6
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
@@ -40,10 +41,33 @@ public sealed record TeamMember(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
long TypicalRunMs,
string? UnitId = null,
bool IsLead = false);
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
/// <summary>
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
/// its own members and those of every unit below. A collapsed container shows this, so a
/// problem three levels down is still visible from the top.
/// </summary>
public sealed record TeamUnit(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<TeamState, int> States,
int PendingApprovals,
TeamState Worst);
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshot(
IReadOnlyList<TeamMember> Members,
IReadOnlyList<AgentLink> RecentLinks,
DateTime AtUtc,
IReadOnlyList<TeamUnit>? Units = null);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null)
TimeProvider? clock = null,
Func<OrgChart>? organisation = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
}
}
var org = organisation?.Invoke() ?? new OrgChart([], configured);
var members = new List<TeamMember>();
foreach (var agent in configured)
{
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
}
return new TeamSnapshot(members, links, now);
return new TeamSnapshot(members, links, now, RollUp(org, members));
}
/// <summary>
/// How much a state needs someone — the higher, the sooner. A container takes the most
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
/// </summary>
public static int Urgency(TeamState state) => state switch
{
TeamState.Stuck => 5,
TeamState.Problem => 4,
TeamState.WaitingForApproval => 3,
TeamState.BudgetExhausted => 2,
TeamState.Working => 1,
_ => 0
};
/// <summary>
/// Every unit with the states of all agents in it and below. Pure, like
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
/// </summary>
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
{
var result = new List<TeamUnit>();
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
{
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
// cycle, so a broken tree cannot hang the overview.
var inside = members
.Where(m => m.UnitId is { } id
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
.ToList();
var states = inside
.GroupBy(m => m.State)
.ToDictionary(g => g.Key, g => g.Count());
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
result.Add(new TeamUnit(
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
}
return result;
}
/// <summary>