feat(team): flow-demo playback + org-node role label (CEO/PO)

- OrgNode gains an optional 'role' field so org-only nodes (CEO, Product
  Owners) show a role under their name; agents still fall back to their roster
  role. Fixes the missing CEO label on the root.
- New 'Flow' button plays a choreographed delegation lifecycle as directional
  pulses with step captions: claude defines → PO Native → agent → back to PO →
  review at architect → CI/CD → ahmed test → back to architect (approve/push),
  plus architect→strategist for some tasks. pulseBetween() walks any two nodes
  via their lowest common ancestor, colouring each edge by its real direction
  (down=green delegation, up=amber return), so the animation always shows the
  direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 05:28:32 +02:00
parent 0d79e8fbd6
commit 2140a293ed
2 changed files with 71 additions and 3 deletions

View File

@ -138,6 +138,9 @@ export const OrgNodeSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
/** Editable display name. */ /** Editable display name. */
label: z.string().min(1), label: z.string().min(1),
/** Display role/title shown under the name (e.g. "CEO", "Product Owner").
* For linked agents it falls back to the agent's roster role. */
role: z.string().optional(),
/** One-line "what this node does" — shown behind the info button. */ /** One-line "what this node does" — shown behind the info button. */
title: z.string().optional(), title: z.string().optional(),
/** Linked roster agent name (for model/provider/logo + live task state). */ /** Linked roster agent name (for model/provider/logo + live task state). */

View File

@ -92,7 +92,7 @@ export function renderTeamHtml(cwd: string): string {
const kind = n.kind ?? linked?.kind; const kind = n.kind ?? linked?.kind;
const meta = providerMeta(kind); const meta = providerMeta(kind);
const icon = (kind ? providerLogo(kind, 18) : '') || `<span class="mono">${monogram(n.label)}</span>`; const icon = (kind ? providerLogo(kind, 18) : '') || `<span class="mono">${monogram(n.label)}</span>`;
const role = linked?.role; const role = n.role ?? linked?.role;
const model = linked?.model; const model = linked?.model;
const modelColor = meta ? meta.color : 'var(--muted)'; const modelColor = meta ? meta.color : 'var(--muted)';
const sub = [role ? escapeHtml(role) : '', model ? `<span class="node-model" style="color:${modelColor}">${escapeHtml(model)}</span>` : ''] const sub = [role ? escapeHtml(role) : '', model ? `<span class="node-model" style="color:${modelColor}">${escapeHtml(model)}</span>` : '']
@ -161,9 +161,15 @@ export function renderTeamHtml(cwd: string): string {
.children.wrap { display: grid; grid-template-columns: repeat(var(--cols, 3), 168px); justify-content: center; column-gap: 11px; row-gap: 46px; } .children.wrap { display: grid; grid-template-columns: repeat(var(--cols, 3), 168px); justify-content: center; column-gap: 11px; row-gap: 46px; }
.children.wrap .subtree { width: 168px; } .children.wrap .subtree { width: 168px; }
.team-toolbar { display: flex; justify-content: flex-end; gap: 8px; padding: 0 6px 8px; } .team-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 6px 8px; }
.fit-btn { min-height: 30px; padding: 0 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--muted); font: 600 12px/1 var(--font-sans); cursor: pointer; transition: color 150ms, border-color 150ms; } .tb-spacer { flex: 1; }
.fit-btn { min-height: 30px; padding: 0 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--muted); font: 600 12px/1 var(--font-sans); cursor: pointer; transition: color 150ms, border-color 150ms; white-space: nowrap; }
.fit-btn:hover { color: var(--text); border-color: var(--accent); } .fit-btn:hover { color: var(--text); border-color: var(--accent); }
.fit-btn.flow { color: #adf2c7; border-color: rgba(34,197,94,.4); }
.fit-btn.flow:hover { border-color: var(--green); }
.fit-btn.flow[disabled] { opacity: .6; cursor: default; }
.flow-caption { font: 12px/1.3 var(--font-mono); color: var(--text); background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 8px; padding: 6px 11px; max-width: 62%; }
.flow-caption.review { border-left-color: var(--status-review); }
.node { .node {
position: relative; position: relative;
@ -223,6 +229,9 @@ export function renderTeamHtml(cwd: string): string {
<body> <body>
${pageHeader(config.projectName, 'team')} ${pageHeader(config.projectName, 'team')}
<div class="team-toolbar"> <div class="team-toolbar">
<span class="flow-caption" id="flowCaption" hidden></span>
<span class="tb-spacer"></span>
<button class="fit-btn flow" id="flowBtn" type="button" title="Play the delegation-flow animation">&#9654; Flow</button>
<button class="fit-btn" id="fitBtn" type="button" title="Scale the whole chart to fit the screen">Fit</button> <button class="fit-btn" id="fitBtn" type="button" title="Scale the whole chart to fit the screen">Fit</button>
</div> </div>
<div class="scroll"> <div class="scroll">
@ -417,6 +426,62 @@ export function renderTeamHtml(cwd: string): string {
var fb = document.getElementById('fitBtn'); var fb = document.getElementById('fitBtn');
if (fb) fb.addEventListener('click', toggleFit); if (fb) fb.addEventListener('click', toggleFit);
// ── Flow demo: choreographed pulses that walk the delegation lifecycle ──
function sleep(ms) { return new Promise(function(r){ setTimeout(r, ms); }); }
function ancestors(id) { var a = [id], c = parentOf[id]; while (c) { a.push(c); c = parentOf[c]; } return a; }
// Pulse from one node to another along the tree (up to the common ancestor,
// then down). Each edge is coloured by its real direction. Returns duration.
function pulseBetween(fromId, toId) {
if (!edgeMap || (!edgeMap[fromId] && !edgeMap[toId] && fromId !== toId)) { /* still fire best-effort */ }
var A = ancestors(fromId), B = ancestors(toId);
var Bidx = {}; B.forEach(function(id, i) { Bidx[id] = i; });
var lca = null, ai = 0;
for (var i = 0; i < A.length; i++) { if (Bidx[A[i]] != null) { lca = A[i]; ai = i; break; } }
if (lca == null) return EDGE_DUR;
var seq = [];
for (var u = 0; u < ai; u++) seq.push({ nodeId: A[u], dir: 'up' }); // from → LCA
for (var d = Bidx[lca] - 1; d >= 0; d--) seq.push({ nodeId: B[d], dir: 'down' }); // LCA → to
seq.forEach(function(step, k) { setTimeout(function(){ firePulseEdge(step.nodeId, step.dir); }, k * SEG); });
return Math.max(EDGE_DUR, seq.length * SEG + EDGE_DUR);
}
var DEMO = [
{ from: 'claude', to: 'po-native', cap: 'claude definiert den Task und delegiert an PO Native' },
{ from: 'po-native', to: 'backyard', cap: 'PO Native delegiert an den richtigen Agenten (backyard)' },
{ from: 'backyard', to: 'po-native', cap: 'backyard liefert die Arbeit zurück an PO Native', rev: true },
{ from: 'po-native', to: 'claude', cap: 'PO Native validiert → zurück zum Architekt-Review', rev: true },
{ from: 'claude', to: 'cicd', cap: 'Architekt: Review OK → an CI/CD (sonst: reopen an den Agenten)' },
{ from: 'cicd', to: 'ahmed', cap: 'CI/CD gibt an ahmed zum Testen' },
{ from: 'ahmed', to: 'cicd', cap: 'ahmed meldet das Testergebnis zurück an CI/CD', rev: true },
{ from: 'cicd', to: 'claude', cap: 'CI/CD zurück an Architekt → Approve & Push', rev: true },
{ from: 'claude', to: 'strategist', cap: 'Manche Tasks: Architekt zieht den Strategist hinzu' }
];
function showCaption(text, review) {
var el = document.getElementById('flowCaption');
if (!el) return;
if (!text) { el.hidden = true; el.textContent = ''; return; }
el.hidden = false; el.className = 'flow-caption' + (review ? ' review' : ''); el.textContent = text;
}
var demoRunning = false;
async function runDemo() {
if (demoRunning) return;
demoRunning = true;
var btn = document.getElementById('flowBtn');
if (btn) { btn.setAttribute('disabled', 'true'); }
layoutEdges(); // make sure edge geometry is current
for (var i = 0; i < DEMO.length; i++) {
var s = DEMO[i];
showCaption((i + 1) + '/' + DEMO.length + ' · ' + s.cap, s.rev);
var dur = pulseBetween(s.from, s.to);
await sleep(dur + 550);
}
showCaption('');
if (btn) btn.removeAttribute('disabled');
demoRunning = false;
}
var flowBtn = document.getElementById('flowBtn');
if (flowBtn) flowBtn.addEventListener('click', runDemo);
var rt; function relayout() { clearTimeout(rt); rt = setTimeout(function(){ if (fitted) { fitted = false; toggleFit(); } else layoutEdges(); }, 120); } var rt; function relayout() { clearTimeout(rt); rt = setTimeout(function(){ if (fitted) { fitted = false; toggleFit(); } else layoutEdges(); }, 120); }
window.addEventListener('resize', relayout); window.addEventListener('resize', relayout);
layoutEdges(); centerScroll(); layoutEdges(); centerScroll();