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
+2 -1
View File
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks")); LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary> /// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents); public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance));
public TelegramClientManager? TelegramClient { get; private init; } public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; } public OpenRouterStatusService? Status { get; private init; }
+77 -6
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging; using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services; namespace ClawdDotNet.App.Services;
@@ -40,10 +41,33 @@ public sealed record TeamMember(
string? LastRunStatus, string? LastRunStatus,
DateTime? LastRunAtUtc, DateTime? LastRunAtUtc,
string? LastRunResult, 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> /// <summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc); /// 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> /// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck. /// #711: The team overview — who is working, who reached whom, where something is stuck.
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
IAuditRepository? audit, IAuditRepository? audit,
IUsageRepository? usage, IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents, 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> /// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3); 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>(); var members = new List<TeamMember>();
foreach (var agent in configured) foreach (var agent in configured)
{ {
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
var state = Classify(runs, approvals, last?.Status, typical, now); var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember( members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals, 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> /// <summary>
@@ -1791,6 +1791,12 @@ td.klein {
gap: 16px; gap: 16px;
} }
.team-eintrag {
text-align: left;
justify-content: flex-start;
width: 100%;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.team-flaeche { flex-direction: column; } .team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; } .team-seitenleiste { width: auto; }
@@ -128,7 +128,7 @@
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen", "org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.", "org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"team.title": "Teamübersicht", "team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.", "team.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.", "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.", "team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)", "team.legendLink": "hat erreicht (verblasst nach 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Freigaben öffnen", "team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen", "team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen", "team.openRuns": "Läufe anzeigen",
"team.unitAgentsOne": "1 Agent",
"team.unitAgents": "{n} Agenten",
"team.legendLead": "Leitung der Einheit",
"team.legendUnit": "Einheit, Klick öffnet sie",
"team.leads": "Leitet",
"team.memberOf": "Mitglied von",
"team.members": "Mitglieder",
"team.subUnits": "Untereinheiten",
"team.collapse": "Einheit zuklappen",
"team.expand": "Einheit aufklappen",
"team.openOrganisation": "Organisation bearbeiten",
"nav.services": "Dienste", "nav.services": "Dienste",
"nav.group.analytics": "Analyse", "nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung", "nav.usage": "Tokennutzung",
@@ -128,7 +128,7 @@
"org.toolsLostConfirm": "Remove the tools and apply", "org.toolsLostConfirm": "Remove the tools and apply",
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.", "org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
"team.title": "Team overview", "team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.", "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.", "team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.", "team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)", "team.legendLink": "reached (fades after 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Open approvals", "team.openApprovals": "Open approvals",
"team.openChat": "Open chat", "team.openChat": "Open chat",
"team.openRuns": "Show runs", "team.openRuns": "Show runs",
"team.unitAgentsOne": "1 agent",
"team.unitAgents": "{n} agents",
"team.legendLead": "lead of the unit",
"team.legendUnit": "unit, click to open",
"team.leads": "Leads",
"team.memberOf": "Member of",
"team.members": "Members",
"team.subUnits": "Sub-units",
"team.collapse": "Collapse unit",
"team.expand": "Expand unit",
"team.openOrganisation": "Edit organisation",
"nav.services": "Services", "nav.services": "Services",
"nav.group.analytics": "Analytics", "nav.group.analytics": "Analytics",
"nav.usage": "Token Usage", "nav.usage": "Token Usage",
+456 -76
View File
@@ -1,12 +1,14 @@
// ClawdDotNet — Team overview (#711) // ClawdDotNet — Team overview (#711)
// //
// Who is working, who reached whom in the last seconds, and where something is stuck — at a // Who is working, who reached whom in the last seconds, and where something is stuck — at a
// glance, like looking across an office. Stage 0: the agents of the active instance and the // glance, like looking across an office. Stage 0 drew the agents and the live links between
// live links between them. With units (#683) they become collapsible containers. // them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
// what happens inside it, so a problem three levels down is still visible from the top.
// //
// Three rules from the concept (Organisation-Konzept, 10a): // Three rules from the concept (Organisation-Konzept, 10a):
// - The layout comes from the structure, not from the hand: no free node editor. // - The layout comes from the structure, not from the hand: no free node editor.
// - The state is decided by the instance (code), the page only draws it. // - The state is decided by the instance (code), the page only draws it. That includes the
// rolled-up counts of a unit — the page does not add them up.
// - A click opens a side panel, not the chat — most problems are solved by an approval. // - A click opens a side panel, not the chat — most problems are solved by an approval.
import { api, holeAktiveInstanz } from "../modules/api.js"; import { api, holeAktiveInstanz } from "../modules/api.js";
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
/** How often the picture is refreshed without an event — for "stuck" and approvals. */ /** How often the picture is refreshed without an event — for "stuck" and approvals. */
const REFRESH_MS = 30_000; const REFRESH_MS = 30_000;
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
const EXPAND_ALL_UP_TO = 15;
/** Unit node ids are prefixed so a unit and an agent may share a name. */
const UNIT_PREFIX = "unit:";
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
const MAX_DEPTH = 64;
// Every state has a colour AND a symbol: colour alone is not enough. // Every state has a colour AND a symbol: colour alone is not enough.
const STATES = { const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" }, Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
@@ -40,7 +51,11 @@ const STATES = {
let librariesLoaded = null; let librariesLoaded = null;
let cy = null; let cy = null;
let snapshot = null; let snapshot = null;
let selectedAgent = null; let unitsById = new Map();
let membersById = new Map();
let expanded = null;
let structureKey = "";
let selected = null;
let panel = null; let panel = null;
let summary = null; let summary = null;
let unsubscribe = null; let unsubscribe = null;
@@ -49,6 +64,9 @@ let fadeTimer = null;
let refreshPending = null; let refreshPending = null;
let resizeObserver = null; let resizeObserver = null;
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
const links = new Map();
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */ /** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
let layoutWidth = 0; let layoutWidth = 0;
@@ -114,11 +132,14 @@ export async function renderTeam() {
} }
canvas.replaceChildren(); canvas.replaceChildren();
indexSnapshot();
expanded = loadExpanded();
for (const link of snapshot.links ?? []) recordLink(link);
drawGraph(canvas); drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity); unsubscribe = abonnieren("activity", onActivity);
refreshTimer = setInterval(refresh, REFRESH_MS); refreshTimer = setInterval(refresh, REFRESH_MS);
fadeTimer = setInterval(fadeLinks, 1_000); fadeTimer = setInterval(drawLinks, 1_000);
} }
/** Called when the route changes: stop listening, stop timers, free the graph. */ /** Called when the route changes: stop listening, stop timers, free the graph. */
@@ -133,7 +154,84 @@ export function leaveTeam() {
resizeObserver = null; resizeObserver = null;
cy?.destroy(); cy?.destroy();
cy = null; cy = null;
selectedAgent = null; selected = null;
structureKey = "";
layoutWidth = 0;
links.clear();
}
// ─── The tree ───
function indexSnapshot() {
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
}
/** The unit and every unit above it, nearest first; stops at a cycle. */
function chain(unitId) {
const result = [];
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
if (result.includes(unit)) break;
result.push(unit);
}
return result;
}
/** Whether the unit and everything above it are expanded — then its content is drawn. */
function isOpen(unitId) {
return chain(unitId).every((u) => expanded.has(u.id));
}
/** Whether the unit itself is drawn, as a box or as a container. */
function isVisible(unit) {
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
}
/**
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
* on its path. Links are drawn between these, so a collapsed unit collects the links of
* everyone inside it.
*/
function representative(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
let rep = null;
for (const unit of chain(member.unitId)) {
if (!expanded.has(unit.id)) rep = unit.id;
}
return rep ? UNIT_PREFIX + rep : agentId;
}
function storageKey() {
return `clawd.team.expanded.${holeAktiveInstanz()}`;
}
function loadExpanded() {
try {
const stored = localStorage.getItem(storageKey());
if (stored) return new Set(JSON.parse(stored));
} catch {
// Private window or blocked storage: fall back to the default below.
}
// A small team is shown whole; a large one starts at the departments.
return snapshot.members.length <= EXPAND_ALL_UP_TO
? new Set(unitsById.keys())
: new Set();
}
function saveExpanded() {
try {
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
} catch {
// Only a convenience — the default applies next time.
}
}
function toggleUnit(unitId, open) {
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
else expanded.delete(unitId);
saveExpanded();
applySnapshot();
} }
// ─── Graph ─── // ─── Graph ───
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
container: canvas, container: canvas,
elements: [], elements: [],
wheelSensitivity: 0.2, wheelSensitivity: 0.2,
minZoom: 0.3, minZoom: 0.2,
maxZoom: 2.5, // A handful of collapsed units would otherwise fill the area with giant boxes.
maxZoom: 1.6,
// Nodes are not dragged: the layout comes from the structure, not the hand. // Nodes are not dragged: the layout comes from the structure, not the hand.
autoungrabify: true, autoungrabify: true,
style: [ style: [
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
} }
}, },
{ selector: "node.aktiv", style: { "border-width": 4 } }, { selector: "node.aktiv", style: { "border-width": 4 } },
{ selector: "node.leitung", style: { "font-weight": "bold" } },
// A collapsed unit: a box that stands for everyone inside it.
{
selector: "node.einheit",
style: {
shape: "round-rectangle",
width: 210,
height: 70,
"border-width": 3,
"border-style": "double",
"background-color": token("--surface-subtil"),
"font-weight": "bold"
}
},
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
// An expanded unit: a container around its members and sub-units.
{
selector: "node:parent",
style: {
shape: "round-rectangle",
padding: 20,
"border-width": 2,
"border-style": "dashed",
"background-color": token("--surface-subtil"),
"background-opacity": 0.5,
"text-valign": "top",
"text-halign": "center",
"text-margin-y": -4,
"text-max-width": 400,
"font-weight": "bold"
}
},
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } }, { selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
{ {
selector: "edge", selector: "edge",
style: { style: {
width: 3, width: "data(width)",
"line-color": token("--accent"), "line-color": token("--accent"),
"target-arrow-color": token("--accent"), "target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle", "target-arrow-shape": "triangle",
"curve-style": "bezier", "curve-style": "bezier",
opacity: "data(opacity)" opacity: "data(opacity)",
label: "data(label)",
"font-size": 11,
color: token("--text"),
"text-background-color": token("--surface"),
"text-background-opacity": 1,
"text-background-padding": 2
} }
} }
] ]
}); });
cy.on("tap", "node", (event) => openPanel(event.target.id())); cy.on("tap", "node", (event) => {
const id = event.target.id();
if (!id.startsWith(UNIT_PREFIX)) {
openPanel({ agent: id });
return;
}
const unitId = id.slice(UNIT_PREFIX.length);
// A collapsed unit opens on the first click — looking inside is what the click is for.
if (!expanded.has(unitId)) toggleUnit(unitId, true);
openPanel({ unit: unitId });
});
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
cy.on("tap", (event) => { if (event.target === cy) closePanel(); }); cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
applySnapshot(true); applySnapshot();
for (const link of snapshot.links ?? []) addLink(link);
observeSize(canvas); observeSize(canvas);
} }
@@ -207,58 +354,164 @@ function observeSize(canvas) {
resizeObserver.observe(canvas); resizeObserver.observe(canvas);
} }
function runLayout() { /** Counts layouts, so a layout overtaken by a newer one stops trying. */
const area = cy.container(); let layoutGeneration = 0;
layoutWidth = area.clientWidth;
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
function layoutOnce(aspectRatio) {
return new Promise((resolve) => {
const layout = cy.layout({ const layout = cy.layout({
name: "elk", name: "elk",
fit: false, fit: false,
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of // The agents are packed evenly into the shape of the area, inside each container as
// the area. rectpacking keeps their order, so a box does not jump around when a // on the top level. rectpacking keeps the order they were added in (units by path,
// state changes. With units (#683) this becomes a layered layout of nested containers. // the lead first), so a box does not jump around when a state changes.
elk: { elk: {
algorithm: "rectpacking", algorithm: "rectpacking",
"elk.spacing.nodeNode": 40, "elk.spacing.nodeNode": 40,
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0 "elk.aspectRatio": aspectRatio
? Math.max(0.5, area.clientWidth / area.clientHeight) },
: 2 // Room for the container's own padding and its label above the members.
nodeLayoutOptions: (node) => node.isParent()
? {
"elk.algorithm": "rectpacking",
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
"elk.spacing.nodeNode": 24
} }
: {}
});
// ELK computes asynchronously; measure once the positions are there.
layout.one("layoutstop", () => {
const box = cy?.elements().boundingBox();
resolve(box && box.h > 0 ? box.w / box.h : 1);
}); });
// ELK computes asynchronously; fit once the positions are there.
layout.one("layoutstop", () => cy?.fit(undefined, 30));
layout.run(); layout.run();
});
} }
function nodeLabel(member) { /**
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
* hint and, with a few large containers on the top level, jumps between a handful of
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
* result closest to the area is kept; with the fit afterwards, that is the one that shows
* the boxes largest.
*/
async function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
const target = area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2;
const generation = ++layoutGeneration;
const miss = (ratio) => Math.abs(Math.log(ratio / target));
let best = null;
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
const ratio = await layoutOnce(hint);
if (!cy || generation !== layoutGeneration) return;
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
else best.last = false;
if (best.miss < 0.15) break;
}
// The best one may not be the last one run.
if (!best.last) await layoutOnce(best.hint);
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
}
function agentLabel(member) {
const state = STATES[member.state] ?? STATES.Idle; const state = STATES[member.state] ?? STATES.Idle;
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : ""; const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`; const lead = member.isLead ? "★ " : "";
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
} }
function applySnapshot(relayout) { /** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
function unitCounts(unit) {
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
const n = unit.states?.[state];
if (n) parts.push(`${STATES[state].symbol} ${n}`);
}
return parts.join(" · ");
}
function unitLabel(unit, open) {
return open
? `▾ ${unit.name} · ${unitCounts(unit)}`
: `▸ ${unit.name}\n${unitCounts(unit)}`;
}
/**
* The nodes as they should be now, parents before children and in a stable order: agents
* without a unit, then the units by path, each with its lead first.
*/
function desiredNodes() {
const nodes = [];
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
}
for (const unit of snapshot.units ?? []) {
if (!isVisible(unit)) continue;
const open = expanded.has(unit.id);
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
if (!open) continue;
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
for (const member of members) {
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
}
}
return nodes;
}
/**
* Brings the graph up to date. When only states changed, the nodes are updated in place and
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
*/
function applySnapshot() {
if (!cy || !snapshot) return; if (!cy || !snapshot) return;
const present = new Set(); const nodes = desiredNodes();
for (const member of snapshot.members) { const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
present.add(member.agentId); const data = (n) => ({
const state = STATES[member.state] ?? STATES.Idle; id: n.id,
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) }; parent: n.parent,
const node = cy.getElementById(member.agentId); colour: n.colour,
if (node.nonempty()) { label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
node.data(data); });
if (key !== structureKey) {
structureKey = key;
cy.elements().remove();
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
for (const n of nodes) decorate(n);
drawLinks();
runLayout();
} else { } else {
cy.add({ group: "nodes", data }); for (const n of nodes) {
relayout = true; const { parent: _parent, ...rest } = data(n);
cy.getElementById(n.id).data(rest);
decorate(n);
} }
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
} }
cy.nodes().filter((n) => !present.has(n.id())).remove();
if (relayout) runLayout();
renderSummary(); renderSummary();
if (selectedAgent) openPanel(selectedAgent); if (selected) openPanel(selected);
}
function decorate(n) {
const node = cy.getElementById(n.id);
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
node.toggleClass("leitung", Boolean(n.member?.isLead));
} }
// ─── Live ─── // ─── Live ───
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
function onActivity(data) { function onActivity(data) {
if (!cy || !forActiveInstance(data)) return; if (!cy || !forActiveInstance(data)) return;
if (data.kind === "link" && data.link) addLink(data.link); if (data.kind === "link" && data.link) {
recordLink(data.link);
drawLinks();
}
// Start and end change states; ask the instance instead of guessing here. // Start and end change states; ask the instance instead of guessing here.
clearTimeout(refreshPending); clearTimeout(refreshPending);
@@ -282,34 +538,65 @@ async function refresh() {
if (!cy) return; if (!cy) return;
try { try {
snapshot = await api("/team"); snapshot = await api("/team");
applySnapshot(false); indexSnapshot();
applySnapshot();
} catch { } catch {
// The next event or tick tries again; a failed refresh keeps the last picture. // The next event or tick tries again; a failed refresh keeps the last picture.
} }
} }
function addLink(link) { function recordLink(link) {
if (!cy) return; const seenAt = new Date(link.atUtc).getTime();
const age = Date.now() - new Date(link.atUtc).getTime(); if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
if (age > LINK_VISIBLE_MS) return; const key = `${link.from}->${link.to}`;
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return; const known = links.get(key);
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
// One edge per pair; a new contact renews it. if (known && known.seenAt >= seenAt) return;
const id = `link:${link.from}->${link.to}`; links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
const existing = cy.getElementById(id);
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
if (existing.nonempty()) existing.data(data);
else cy.add({ group: "edges", data });
} }
function fadeLinks() { /**
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
* that unit, and several such links become one edge with a counter. A link that stays
* inside one collapsed unit is not drawn — it is the unit talking to itself.
*/
function drawLinks() {
if (!cy) return; if (!cy) return;
const now = Date.now(); const now = Date.now();
cy.edges().forEach((edge) => { const bundles = new Map();
const age = now - edge.data("seenAt");
if (age > LINK_VISIBLE_MS) edge.remove(); for (const [key, link] of links) {
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS)); const age = now - link.seenAt;
}); if (age > LINK_VISIBLE_MS) {
links.delete(key);
continue;
}
const from = representative(link.from);
const to = representative(link.to);
if (!from || !to || from === to) continue;
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
const id = `link:${from}->${to}`;
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
bundle.count += link.count;
bundles.set(id, bundle);
}
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
for (const bundle of bundles.values()) {
const data = {
id: bundle.id,
source: bundle.source,
target: bundle.target,
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
width: Math.min(8, 2 + bundle.count),
label: bundle.count > 1 ? `×${bundle.count}` : ""
};
const edge = cy.getElementById(bundle.id);
if (edge.nonempty()) edge.data(data);
else cy.add({ group: "edges", data });
}
} }
// ─── Summary and legend ─── // ─── Summary and legend ───
@@ -334,32 +621,56 @@ function buildLegend() {
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" }, { klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) => ...Object.entries(STATES).map(([state, s]) =>
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })), erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
erstelleElement("span", { text: `→ ${t("team.legendLink")}` }) erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
); );
} }
// ─── Side panel ─── // ─── Side panel ───
function openPanel(agentId) { function openPanel(target) {
const member = snapshot?.members.find((m) => m.agentId === agentId); if (!panel || !snapshot) return;
if (!member || !panel) return; const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
selectedAgent = agentId; if (!rows) return;
cy?.getElementById(agentId).select();
const state = STATES[member.state] ?? STATES.Idle; selected = target;
const rows = [ cy?.elements().unselect();
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" }, cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
erstelleElement("h2", { text: member.displayName || member.agentId }),
panel.replaceChildren(...rows);
panel.hidden = false;
}
function panelHead(title) {
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: title }),
erstelleElement("button", { erstelleElement("button", {
klasse: "schaltflaeche flach klein", klasse: "schaltflaeche flach klein",
text: "×", text: "×",
title: t("common.close"), title: t("common.close"),
onclick: closePanel onclick: closePanel
})), }));
}
function agentRows(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
const state = STATES[member.state] ?? STATES.Idle;
const unit = unitsById.get(member.unitId);
const rows = [
panelHead(member.displayName || member.agentId),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }), erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone) erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
]; ];
if (unit) {
rows.push(erstelleElement("p", { klasse: "text-klein" },
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
unitButton(unit)));
}
// Top layer: what is happening now. // Top layer: what is happening now.
if (member.runs.length) { if (member.runs.length) {
rows.push(erstelleElement("h3", { text: t("team.running") })); rows.push(erstelleElement("h3", { text: t("team.running") }));
@@ -408,9 +719,78 @@ function openPanel(agentId) {
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns")) link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
); );
rows.push(actions); rows.push(actions);
return rows;
}
panel.replaceChildren(...rows); function unitRows(unitId) {
panel.hidden = false; const unit = unitsById.get(unitId);
if (!unit) return null;
const worst = STATES[unit.worst] ?? STATES.Idle;
const rows = [
panelHead(unit.name),
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
];
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
if (unit.pendingApprovals > 0) {
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
}
// Direct members, the lead first; what is further down is one click on a sub-unit away.
rows.push(erstelleElement("h3", { text: t("team.members") }));
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
for (const member of members) {
const state = STATES[member.state] ?? STATES.Idle;
rows.push(erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
onclick: () => openPanel({ agent: member.agentId })
}));
}
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
if (children.length) {
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
}
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
const open = expanded.has(unit.id);
actions.append(
erstelleElement("button", {
type: "button",
klasse: "primaer klein",
text: open ? t("team.collapse") : t("team.expand"),
onclick: () => toggleUnit(unit.id, !open)
}),
link("#/organisation", t("team.openOrganisation"))
);
rows.push(actions);
return rows;
}
/** A unit as a link in the panel, with its most urgent state. */
function unitButton(unit) {
const worst = STATES[unit.worst] ?? STATES.Idle;
return erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
onclick: () => {
// Show it in the graph too: open everything above it.
for (const above of chain(unit.parentId)) expanded.add(above.id);
saveExpanded();
applySnapshot();
openPanel({ unit: unit.id });
}
});
} }
function link(href, text, style = "flach") { function link(href, text, style = "flach") {
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
} }
function closePanel() { function closePanel() {
selectedAgent = null; selected = null;
cy?.elements().unselect(); cy?.elements().unselect();
if (panel) panel.hidden = true; if (panel) panel.hidden = true;
} }
+26 -3
View File
@@ -125,8 +125,29 @@ public sealed record TaskDto(
/// </summary> /// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver); public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary> /// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc); public sealed record TeamSnapshotDto(
IReadOnlyList<TeamMemberDto> Members,
IReadOnlyList<AgentLinkDto> Links,
DateTime AtUtc,
IReadOnlyList<TeamUnitDto> Units);
/// <summary>
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
/// most urgent state inside.
/// </summary>
public sealed record TeamUnitDto(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<string, int> States,
int PendingApprovals,
string Worst);
/// <summary> /// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck, /// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
string? LastRunStatus, string? LastRunStatus,
DateTime? LastRunAtUtc, DateTime? LastRunAtUtc,
string? LastRunResult, string? LastRunResult,
long TypicalRunMs); long TypicalRunMs,
string? UnitId,
bool IsLead);
/// <summary>#711: A run in progress.</summary> /// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc); public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
+7 -2
View File
@@ -74,9 +74,14 @@ internal static class Mapping
snapshot.Members.Select(m => new TeamMemberDto( snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(), m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(), m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(), m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
m.UnitId, m.IsLead)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(), snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc); snapshot.AtUtc,
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
u.PendingApprovals, u.Worst.ToString())).ToList());
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run) public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc); => new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
@@ -1,5 +1,7 @@
using ClawdDotNet.App.Services; using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity; using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly; using Shouldly;
namespace ClawdDotNet.App.Tests.Services; namespace ClawdDotNet.App.Tests.Services;
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
public void A_cancelled_run_is_not_a_problem() public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault. // Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle); => TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
// ═══════════════════ stage 1: units roll up ═══════════════════
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
private static OrgChart Shop() => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ceo" },
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
[Fact]
public void A_problem_deep_down_shows_on_every_container_above_it()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("ceo", null, TeamState.Working),
Member("marketing-lead", "marketing", TeamState.Working),
Member("amazon-lead", "amazon", TeamState.Idle),
Member("ads", "amazon", TeamState.Problem),
Member("bookkeeper", "accounting", TeamState.Idle)
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
marketing.Worst.ShouldBe(TeamState.Problem);
marketing.States[TeamState.Working].ShouldBe(1);
marketing.States[TeamState.Problem].ShouldBe(1);
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Open_approvals_are_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
]);
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
}
[Fact]
public void A_unit_knows_its_path_and_its_lead()
{
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
amazon.Path.ShouldBe("Marketing / Amazon");
amazon.LeaderAgentId.ShouldBe("amazon-lead");
amazon.Agents.ShouldBe(0);
amazon.Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Stuck_is_the_most_urgent_and_idle_the_least()
{
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
byUrgency.First().ShouldBe(TeamState.Stuck);
byUrgency.Last().ShouldBe(TeamState.Idle);
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
}
[Fact]
public void Without_units_there_is_nothing_to_roll_up()
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
.ShouldBeEmpty();
} }