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(); }