diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index ee6d2c1..9039d34 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// #711: The team overview. Stateless, like .
- 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 OpenRouterStatusService? Status { get; private init; }
diff --git a/src/ClawdDotNet.App/Services/TeamOverview.cs b/src/ClawdDotNet.App/Services/TeamOverview.cs
index 929ba6d..44d9531 100644
--- a/src/ClawdDotNet.App/Services/TeamOverview.cs
+++ b/src/ClawdDotNet.App/Services/TeamOverview.cs
@@ -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);
-/// The whole picture at one moment: every agent, and who reached whom recently.
-public sealed record TeamSnapshot(IReadOnlyList Members, IReadOnlyList 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.
+///
+public sealed record TeamUnit(
+ string Id,
+ string Name,
+ string? ParentId,
+ string Path,
+ string? LeaderAgentId,
+ string? Goal,
+ int Agents,
+ IReadOnlyDictionary States,
+ int PendingApprovals,
+ TeamState Worst);
+
+/// The whole picture at one moment: every agent and unit, and who reached whom recently.
+public sealed record TeamSnapshot(
+ IReadOnlyList Members,
+ IReadOnlyList RecentLinks,
+ DateTime AtUtc,
+ IReadOnlyList? Units = null);
///
/// #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> agents,
- TimeProvider? clock = null)
+ TimeProvider? clock = null,
+ Func? organisation = null)
{
/// How far back the last receipt of each agent is looked for.
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();
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));
+ }
+
+ ///
+ /// 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.
+ ///
+ public static int Urgency(TeamState state) => state switch
+ {
+ TeamState.Stuck => 5,
+ TeamState.Problem => 4,
+ TeamState.WaitingForApproval => 3,
+ TeamState.BudgetExhausted => 2,
+ TeamState.Working => 1,
+ _ => 0
+ };
+
+ ///
+ /// Every unit with the states of all agents in it and below. Pure, like
+ /// : the page draws the numbers, it does not add them up.
+ ///
+ public static IReadOnlyList RollUp(OrgChart org, IReadOnlyList members)
+ {
+ var result = new List();
+ 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;
}
///
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
index 2137930..820f13f 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
@@ -1791,6 +1791,12 @@ td.klein {
gap: 16px;
}
+.team-eintrag {
+ text-align: left;
+ justify-content: flex-start;
+ width: 100%;
+}
+
@media (max-width: 800px) {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 15194c8..3f281ab 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -128,7 +128,7 @@
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"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.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"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.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 1e90287..ab33333 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -128,7 +128,7 @@
"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.",
"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.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"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.group.analytics": "Analytics",
"nav.usage": "Token Usage",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js
index c332f81..568ccfc 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js
@@ -1,12 +1,14 @@
// ClawdDotNet — Team overview (#711)
//
// 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
-// live links between them. With units (#683) they become collapsible containers.
+// glance, like looking across an office. Stage 0 drew the agents and the live links between
+// 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):
// - 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.
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. */
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.
const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
@@ -40,7 +51,11 @@ const STATES = {
let librariesLoaded = null;
let cy = 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 summary = null;
let unsubscribe = null;
@@ -49,6 +64,9 @@ let fadeTimer = null;
let refreshPending = 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. */
let layoutWidth = 0;
@@ -114,11 +132,14 @@ export async function renderTeam() {
}
canvas.replaceChildren();
+ indexSnapshot();
+ expanded = loadExpanded();
+ for (const link of snapshot.links ?? []) recordLink(link);
drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity);
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. */
@@ -133,7 +154,84 @@ export function leaveTeam() {
resizeObserver = null;
cy?.destroy();
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 ───
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
container: canvas,
elements: [],
wheelSensitivity: 0.2,
- minZoom: 0.3,
- maxZoom: 2.5,
+ minZoom: 0.2,
+ // 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.
autoungrabify: true,
style: [
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
}
},
{ 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: "edge",
style: {
- width: 3,
+ width: "data(width)",
"line-color": token("--accent"),
"target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle",
"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(); });
- applySnapshot(true);
- for (const link of snapshot.links ?? []) addLink(link);
+ applySnapshot();
observeSize(canvas);
}
@@ -207,58 +354,164 @@ function observeSize(canvas) {
resizeObserver.observe(canvas);
}
-function runLayout() {
+/** Counts layouts, so a layout overtaken by a newer one stops trying. */
+let layoutGeneration = 0;
+
+/** 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({
+ name: "elk",
+ fit: false,
+ // The agents are packed evenly into the shape of the area, inside each container as
+ // on the top level. rectpacking keeps the order they were added in (units by path,
+ // the lead first), so a box does not jump around when a state changes.
+ elk: {
+ algorithm: "rectpacking",
+ "elk.spacing.nodeNode": 40,
+ "elk.aspectRatio": aspectRatio
+ },
+ // 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);
+ });
+ layout.run();
+ });
+}
+
+/**
+ * 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 layout = cy.layout({
- name: "elk",
- fit: false,
- // Stage 0 has no structure yet, so the agents are packed evenly into the shape of
- // the area. rectpacking keeps their order, so a box does not jump around when a
- // state changes. With units (#683) this becomes a layered layout of nested containers.
- elk: {
- algorithm: "rectpacking",
- "elk.spacing.nodeNode": 40,
- "elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
- ? Math.max(0.5, area.clientWidth / area.clientHeight)
- : 2
- }
- });
- // ELK computes asynchronously; fit once the positions are there.
- layout.one("layoutstop", () => cy?.fit(undefined, 30));
- layout.run();
+ 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 nodeLabel(member) {
+function agentLabel(member) {
const state = STATES[member.state] ?? STATES.Idle;
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) {
- if (!cy || !snapshot) return;
+/** "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(" · ");
+}
- const present = new Set();
- for (const member of snapshot.members) {
- present.add(member.agentId);
- const state = STATES[member.state] ?? STATES.Idle;
- const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
- const node = cy.getElementById(member.agentId);
- if (node.nonempty()) {
- node.data(data);
- } else {
- cy.add({ group: "nodes", data });
- relayout = true;
- }
- cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
+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) });
}
- cy.nodes().filter((n) => !present.has(n.id())).remove();
+ 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;
- if (relayout) runLayout();
+ 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;
+
+ const nodes = desiredNodes();
+ const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
+ const data = (n) => ({
+ id: n.id,
+ parent: n.parent,
+ colour: n.colour,
+ label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
+ });
+
+ 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 {
+ for (const n of nodes) {
+ const { parent: _parent, ...rest } = data(n);
+ cy.getElementById(n.id).data(rest);
+ decorate(n);
+ }
+ }
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 ───
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
function onActivity(data) {
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.
clearTimeout(refreshPending);
@@ -282,34 +538,65 @@ async function refresh() {
if (!cy) return;
try {
snapshot = await api("/team");
- applySnapshot(false);
+ indexSnapshot();
+ applySnapshot();
} catch {
// The next event or tick tries again; a failed refresh keeps the last picture.
}
}
-function addLink(link) {
- if (!cy) return;
- const age = Date.now() - new Date(link.atUtc).getTime();
- if (age > LINK_VISIBLE_MS) return;
- if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
-
- // One edge per pair; a new contact renews it.
- const id = `link:${link.from}->${link.to}`;
- 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 recordLink(link) {
+ const seenAt = new Date(link.atUtc).getTime();
+ if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
+ const key = `${link.from}->${link.to}`;
+ const known = links.get(key);
+ // A new contact renews the pair and counts; the snapshot may repeat one already seen.
+ if (known && known.seenAt >= seenAt) return;
+ links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
}
-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;
const now = Date.now();
- cy.edges().forEach((edge) => {
- const age = now - edge.data("seenAt");
- if (age > LINK_VISIBLE_MS) edge.remove();
- else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
- });
+ const bundles = new Map();
+
+ for (const [key, link] of links) {
+ 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 ───
@@ -334,32 +621,56 @@ function buildLegend() {
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) =>
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")}` })
);
}
// ─── Side panel ───
-function openPanel(agentId) {
- const member = snapshot?.members.find((m) => m.agentId === agentId);
- if (!member || !panel) return;
- selectedAgent = agentId;
- cy?.getElementById(agentId).select();
+function openPanel(target) {
+ if (!panel || !snapshot) return;
+ const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
+ if (!rows) return;
+
+ selected = target;
+ cy?.elements().unselect();
+ cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
+
+ panel.replaceChildren(...rows);
+ panel.hidden = false;
+}
+
+function panelHead(title) {
+ return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
+ erstelleElement("h2", { text: title }),
+ erstelleElement("button", {
+ klasse: "schaltflaeche flach klein",
+ text: "×",
+ title: t("common.close"),
+ 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 = [
- erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
- erstelleElement("h2", { text: member.displayName || member.agentId }),
- erstelleElement("button", {
- klasse: "schaltflaeche flach klein",
- text: "×",
- title: t("common.close"),
- onclick: closePanel
- })),
+ panelHead(member.displayName || member.agentId),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
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.
if (member.runs.length) {
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"))
);
rows.push(actions);
+ return rows;
+}
- panel.replaceChildren(...rows);
- panel.hidden = false;
+function unitRows(unitId) {
+ 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") {
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
}
function closePanel() {
- selectedAgent = null;
+ selected = null;
cy?.elements().unselect();
if (panel) panel.hidden = true;
}
diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs
index 9905fe7..71114ec 100644
--- a/src/ClawdDotNet.Web/Contracts/Dtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs
@@ -125,8 +125,29 @@ public sealed record TaskDto(
///
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
-/// #711: The team overview — every agent and who reached whom recently.
-public sealed record TeamSnapshotDto(IReadOnlyList Members, IReadOnlyList Links, DateTime AtUtc);
+/// #711: The team overview — every agent and unit, and who reached whom recently.
+public sealed record TeamSnapshotDto(
+ IReadOnlyList Members,
+ IReadOnlyList Links,
+ DateTime AtUtc,
+ IReadOnlyList Units);
+
+///
+/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
+/// instance. maps a state name to a count; is the
+/// most urgent state inside.
+///
+public sealed record TeamUnitDto(
+ string Id,
+ string Name,
+ string? ParentId,
+ string Path,
+ string? LeaderAgentId,
+ string? Goal,
+ int Agents,
+ IReadOnlyDictionary States,
+ int PendingApprovals,
+ string Worst);
///
/// #711: One agent in the team overview. is one of Idle, Working, Stuck,
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
- long TypicalRunMs);
+ long TypicalRunMs,
+ string? UnitId,
+ bool IsLead);
/// #711: A run in progress.
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs
index cf179e0..cb6e916 100644
--- a/src/ClawdDotNet.Web/Contracts/Mapping.cs
+++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs
@@ -74,9 +74,14 @@ internal static class Mapping
snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
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.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)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
diff --git a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs
index a84e280..0378157 100644
--- a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs
+++ b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs
@@ -1,5 +1,7 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
+
+ // ═══════════════════ stage 1: units roll up ═══════════════════
+
+ /// marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.
+ 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().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();
}