agenthub/src/server/team.ts
chahinebrini b097ad71de fix(team): drop redundant 'active'/'in review' word from the status line
The badge chip already says Active/Review, so the status line now shows just
the task id + relative time (e.g. 'TSK-0059 · 2h') instead of repeating it.
2026-07-08 05:45:57 +02:00

538 lines
32 KiB
TypeScript

/**
* Team org-chart page, served at `GET /team`.
*
* Renders `config.org` — an arbitrary-depth hierarchy (CEO → architect →
* product owners → implementers, strategist → compliance, …). Falls back to a
* synthesised 2-level tree (architect → workers) when no org is configured.
*
* The green "traveling pulse" is EVENT-DRIVEN and flows along the real tree
* edges: a delegation (task → an agent) sends a pulse DOWN the path from the
* architect to that agent's node, lighting each node it passes and turning the
* target "active"; a review submission sends an amber pulse back UP. Node names
* are editable inline (persisted via PATCH /org/:id) and each node has an info
* button describing its job. Live state comes over SSE.
*/
import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js';
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js';
import type { Config, OrgNode, Task } from '../core/schema.js';
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>;
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
/** Per-agent live state for the initial server render (in_progress → active). */
function computeStates(cwd: string): Map<string, AgentState> {
const tasks = listTasks(cwd) as TaskRow[];
const active = new Map<string, TaskRow>();
const review = new Map<string, TaskRow>();
for (const t of tasks) {
if (!t.assignedTo) continue;
if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); }
}
const out = new Map<string, AgentState>();
for (const [name, task] of active) out.set(name, { state: 'active', task });
for (const [name, task] of review) if (!out.has(name)) out.set(name, { state: 'reviewing', task });
return out;
}
const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
/** Build the org tree from config, synthesising a flat one if none is set. */
function resolveOrg(config: Config): { nodes: OrgNode[]; rootId: string } {
if (config.org && config.org.length) {
const root = config.org.find((n) => !n.parentId) ?? config.org[0];
return { nodes: config.org, rootId: root.id };
}
// Fallback: architect at the root, everyone else below it.
const roster: Array<{ name: string; role: string; kind?: string }> = config.agents
? Object.entries(config.agents).map(([name, a]) => ({ name, role: a.role, kind: a.kind }))
: Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
const arch = roster.find((r) => r.role === 'architect') ?? roster[0];
const rootId = 'root:' + (arch ? arch.name : 'architect');
const nodes: OrgNode[] = [];
const seen = new Set<string>();
if (arch) { nodes.push({ id: rootId, label: arch.name, agent: arch.name, kind: arch.kind, title: 'architect' }); seen.add(rootId); }
roster
.filter((r) => r !== arch)
.sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || a.name.localeCompare(b.name))
.forEach((r, i) => {
const id = 'n:' + r.name + ':' + i;
nodes.push({ id, label: r.name, agent: r.name, kind: r.kind, parentId: rootId, title: r.role });
});
return { nodes, rootId };
}
function monogram(name: string): string {
const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim();
return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?');
}
export function renderTeamHtml(cwd: string): string {
const config = loadConfig(cwd);
const { nodes, rootId } = resolveOrg(config);
const states = computeStates(cwd);
const byId = new Map<string, OrgNode>();
for (const n of nodes) byId.set(n.id, n);
const childrenOf = new Map<string, OrgNode[]>();
for (const n of nodes) {
if (n.parentId) {
const l = childrenOf.get(n.parentId) ?? [];
l.push(n);
childrenOf.set(n.parentId, l);
}
}
const agentCfg = config.agents ?? {};
function renderNode(n: OrgNode): string {
const linked = n.agent ? agentCfg[n.agent] : undefined;
const kind = n.kind ?? linked?.kind;
const meta = providerMeta(kind);
const icon = (kind ? providerLogo(kind, 18) : '') || `<span class="mono">${monogram(n.label)}</span>`;
const role = n.role ?? linked?.role;
const model = linked?.model;
const modelColor = meta ? meta.color : 'var(--muted)';
const sub = [role ? escapeHtml(role) : '', model ? `<span class="node-model" style="color:${modelColor}">${escapeHtml(model)}</span>` : '']
.filter(Boolean)
.join(' &middot; ');
const title = n.title ?? linked?.description ?? '';
const st = n.agent ? states.get(n.agent) : undefined;
const stateClass = st ? ` ${st.state}` : '';
const badge = st ? `<span class="node-badge ${st.state === 'active' ? 'active' : 'review'}">${st.state === 'active' ? 'active' : 'review'}</span>` : '<span class="node-badge"></span>';
const status = st
? st.state === 'active'
? `<span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(st.task.id)}">${escapeHtml(st.task.id)}</a>`
: `<span class="st-dot review"></span><a class="st-link review" href="/tasks/${escapeHtml(st.task.id)}">${escapeHtml(st.task.id)}</a>`
: `<span class="st-dot"></span><span class="st-free">free</span>`;
return `<div class="node agent-card${stateClass}" data-node-id="${escapeHtml(n.id)}" data-parent="${escapeHtml(n.parentId ?? '')}"${n.agent ? ` data-agent="${escapeHtml(n.agent)}"` : ''}>
<div class="node-top">
<span class="node-icon" style="--pv:${modelColor}">${icon}</span>
<span class="node-id">
<span class="node-name" title="Click the pencil (or double-click) to rename">${escapeHtml(n.label)}</span>
${sub ? `<span class="node-role">${sub}</span>` : ''}
</span>
</div>
<div class="node-actions">
<button class="node-edit" type="button" title="Rename" aria-label="Rename">&#9998;</button>
${title ? `<button class="node-info-btn" type="button" aria-label="Info">i</button>` : ''}
</div>
${badge}
${title ? `<div class="node-info" hidden>${escapeHtml(title)}</div>` : ''}
<div class="agent-status">${status}</div>
</div>`;
}
function renderSubtree(id: string): string {
const n = byId.get(id);
if (!n) return '';
const kids = (childrenOf.get(id) ?? []).slice().sort((a, b) => {
const ra = a.agent ? agentCfg[a.agent]?.role : undefined;
const rb = b.agent ? agentCfg[b.agent]?.role : undefined;
return (ROLE_ORDER[ra ?? ''] ?? 5) - (ROLE_ORDER[rb ?? ''] ?? 5) || a.label.localeCompare(b.label);
});
// Wrap a wide row of leaf children into a compact grid so a broad subtree
// doesn't spread the whole tree — keeps upper levels close together.
const allLeaves = kids.every((k) => !(childrenOf.get(k.id)?.length));
const wrap = allLeaves && kids.length > 4;
const cols = wrap ? Math.min(4, Math.ceil(Math.sqrt(kids.length))) : 0;
const childrenHtml = kids.length
? `<div class="children${wrap ? ' wrap' : ''}"${wrap ? ` style="--cols:${cols}"` : ''}>${kids.map((k) => renderSubtree(k.id)).join('')}</div>`
: '';
return `<div class="subtree">${renderNode(n)}${childrenHtml}</div>`;
}
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Team</title>
<style>
${designTokensCss()}
body { overflow-x: hidden; }
.scroll { overflow-x: auto; padding-bottom: 12px; }
.chart { position: relative; display: inline-flex; min-width: 100%; justify-content: center; padding: 12px 24px 40px; }
.edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: visible; }
.subtree { position: relative; z-index: 1; display: inline-flex; flex-direction: column; align-items: center; }
.children { display: flex; flex-direction: row; align-items: flex-start; justify-content: center; gap: 11px; margin-top: 42px; }
/* Wide leaf rows collapse into a compact grid (connectors drop from a fixed bus). */
.children.wrap { display: grid; grid-template-columns: repeat(var(--cols, 3), 182px); justify-content: center; column-gap: 11px; row-gap: 46px; }
.children.wrap .subtree { width: 182px; }
.team-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 6px 8px; }
.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.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 {
position: relative;
width: 182px;
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 10px 12px;
transition: border-color 220ms ease, transform 180ms ease, box-shadow 320ms ease, background 320ms ease;
animation: nodeEnter 300ms cubic-bezier(.2,.7,.2,1) both;
}
@keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.node:hover { border-color: rgba(88,166,255,.5); }
.node-top { display: flex; align-items: center; gap: 10px; padding-right: 34px; }
.node-actions { position: absolute; top: 9px; right: 10px; display: flex; align-items: center; gap: 5px; z-index: 4; }
.node-icon { width: 32px; height: 32px; flex: 0 0 auto; display: inline-grid; place-items: center; border-radius: 9px; background: var(--raised); border: 1px solid var(--border); }
.node-icon .mono { font: 800 11px/1 var(--font-mono); color: var(--pv, var(--muted)); }
.node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.node-name { font-weight: 700; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; outline: none; border-radius: 4px; }
.node-edit { flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font: 10px/1 var(--font-sans); cursor: pointer; align-self: flex-start; opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms; }
.node:hover .node-edit { opacity: 1; }
.node-edit:hover { color: var(--text); border-color: var(--accent); }
.node-name[contenteditable="true"] { background: rgba(88,166,255,.12); box-shadow: 0 0 0 1px rgba(88,166,255,.5); padding: 0 4px; cursor: text; }
.node-role { display: block; max-width: 100%; font: 10.5px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .04em; }
.node-model { text-transform: none; letter-spacing: 0; }
/* Floating status chip that straddles the top-right edge (Paperclip style). */
.node-badge { position: absolute; top: -9px; right: 12px; z-index: 3; font: 700 9.5px/1 var(--font-mono); text-transform: capitalize; letter-spacing: .02em; padding: 4px 9px; border-radius: 999px; }
.node-badge:empty { display: none; }
.node-badge.active { color: #15803d; background: #ffffff; box-shadow: 0 2px 12px rgba(34,197,94,.45); }
.node-badge.review { color: #7a5a12; background: #ffffff; box-shadow: 0 2px 12px rgba(210,153,34,.4); }
.node-info-btn { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font: 700 11px/1 var(--font-mono); cursor: pointer; align-self: flex-start; transition: color 150ms, border-color 150ms; }
.node-info-btn:hover { color: var(--text); border-color: var(--accent); }
.node-info { margin-top: 9px; padding: 8px 10px; font-size: 11.5px; line-height: 1.45; color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
.agent-status { margin-top: 9px; display: flex; align-items: center; gap: 8px; font-size: 12px; }
.st-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
.st-dot.busy { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: dotPulse 1.5s ease-in-out infinite; }
.st-dot.review { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
@keyframes dotPulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.st-free { color: var(--muted); }
.st-link { color: var(--green); text-decoration: none; font-family: var(--font-mono); font-size: 12px; }
.st-link.review { color: var(--status-review); }
.st-link:hover { text-decoration: underline; }
/* Active card: a green highlight sweeps along the border L→R while the task runs. */
.node::before {
content: ''; position: absolute; inset: 0; border-radius: 14px; padding: 1.6px;
background: linear-gradient(90deg, rgba(34,197,94,0) 32%, #22c55e 50%, rgba(34,197,94,0) 68%);
background-size: 260% 100%; background-position: -50% 0;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0; transition: opacity 340ms ease; pointer-events: none; z-index: 2;
}
.node.active { border-color: rgba(34,197,94,.4); box-shadow: 0 0 20px rgba(34,197,94,.10); }
.node.active::before { opacity: 1; animation: borderSweep 2.6s linear infinite; }
@keyframes borderSweep { 0% { background-position: -50% 0; } 100% { background-position: 150% 0; } }
.node.reviewing { border-color: rgba(210,153,34,.55); box-shadow: 0 0 0 1px rgba(210,153,34,.45), 0 0 20px rgba(210,153,34,.12); }
/* Gentle, non-jerky glow fades for pulse-arrival and task completion. */
.node.arrive { animation: arriveGlow 700ms ease; }
@keyframes arriveGlow { 0% { box-shadow: 0 0 0 1px rgba(34,197,94,.55), 0 0 18px rgba(34,197,94,.28); } 100% { box-shadow: 0 0 0 1px rgba(34,197,94,0), 0 0 0 rgba(34,197,94,0); } }
.node.done-flash { animation: doneFade 900ms ease; }
@keyframes doneFade { 0% { box-shadow: 0 0 0 1px rgba(34,197,94,.5); } 45% { box-shadow: 0 0 0 1px rgba(34,197,94,.4), 0 0 22px rgba(34,197,94,.22); } 100% { box-shadow: 0 0 0 1px rgba(34,197,94,0); } }
.edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; }
@keyframes edgeBreath { 0%,100% { opacity: .35; } 50% { opacity: .62; } }
.edge-pulse { fill: none; stroke-width: 3.5; stroke-linecap: round; filter: url(#pulseGlow); }
.edge-pulse.down { stroke: var(--green); }
.edge-pulse.up { stroke: var(--status-review); }
@media (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } }
</style>
</head>
<body>
${pageHeader(config.projectName, 'team')}
<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>
</div>
<div class="scroll">
<main class="chart" id="chart">
<svg class="edges" id="edges" aria-hidden="true">
<defs>
<filter id="pulseGlow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="2.6" result="b" />
<feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
</svg>
${renderSubtree(rootId)}
</main>
</div>
<script>
(function() {
var NS = 'http://www.w3.org/2000/svg';
var SEG = 680, EDGE_DUR = 1500;
var connDot = document.getElementById('conn-dot');
var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent
var parentOf = {}; // nodeId -> parent nodeId
var taskState = {}, baseline = false;
function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } }
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/1000)); if (s<60) return s+'s'; var m=Math.floor(s/60); if (m<60) return m+'m'; var h=Math.floor(m/60); if (h<24) return h+'h'; return Math.floor(h/24)+'d'; }
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
function centerBottom(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.bottom - box.top }; }
function nodeById(id) { return document.querySelector('.node[data-node-id="' + (id||'').replace(/"/g,'') + '"]'); }
function flash(el) { if (!el) return; el.classList.remove('arrive'); void el.offsetWidth; el.classList.add('arrive'); setTimeout(function(){ el.classList.remove('arrive'); }, 700); }
function doneFlashAgent(agent) {
document.querySelectorAll('.node[data-agent="' + (agent || '').replace(/"/g, '') + '"]').forEach(function(n) {
n.classList.remove('done-flash'); void n.offsetWidth; n.classList.add('done-flash');
setTimeout(function(){ n.classList.remove('done-flash'); }, 900);
});
}
function layoutEdges() {
var svg = document.getElementById('edges');
var chart = document.getElementById('chart');
if (!svg || !chart) return;
var box = chart.getBoundingClientRect();
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
var defs = svg.querySelector('defs'); svg.innerHTML = ''; if (defs) svg.appendChild(defs);
edgeMap = {}; parentOf = {};
Array.prototype.slice.call(document.querySelectorAll('.node[data-node-id]')).forEach(function(n) {
var pid = n.getAttribute('data-parent');
if (!pid) return;
var parent = nodeById(pid);
if (!parent) return;
parentOf[n.getAttribute('data-node-id')] = pid;
var s = centerBottom(parent, box), e = centerTop(n, box);
// Sharp org-chart elbow: down from the parent to the shared bus, across, down to the child.
// Siblings share the same top Y, so their bus Y matches → one clean horizontal bus.
var busY = Math.round(s.y + 20);
var sx = Math.round(s.x), ex = Math.round(e.x);
var fwd = 'M ' + sx + ' ' + Math.round(s.y) + ' L ' + sx + ' ' + busY + ' L ' + ex + ' ' + busY + ' L ' + ex + ' ' + Math.round(e.y);
var rev = 'M ' + ex + ' ' + Math.round(e.y) + ' L ' + ex + ' ' + busY + ' L ' + sx + ' ' + busY + ' L ' + sx + ' ' + Math.round(s.y);
edgeMap[n.getAttribute('data-node-id')] = { fwd: fwd, rev: rev };
var base = document.createElementNS(NS, 'path');
base.setAttribute('class', 'edge-base'); base.setAttribute('d', fwd);
svg.appendChild(base);
});
}
function firePulseEdge(nodeId, dir) {
var e = edgeMap[nodeId]; var svg = document.getElementById('edges');
if (!e || !svg) return;
var p = document.createElementNS(NS, 'path');
p.setAttribute('class', 'edge-pulse ' + dir);
p.setAttribute('d', dir === 'up' ? e.rev : e.fwd);
svg.appendChild(p);
var len = p.getTotalLength();
p.style.strokeDasharray = '20 ' + (len + 20);
p.style.strokeDashoffset = String(len + 20);
var done = function() {
try { p.remove(); } catch (_) {}
flash(dir === 'up' ? nodeById(parentOf[nodeId]) : nodeById(nodeId));
};
if (p.animate) { p.animate([{ strokeDashoffset: len + 20 }, { strokeDashoffset: 0 }], { duration: EDGE_DUR, easing: 'cubic-bezier(.4,0,.5,1)' }).onfinish = done; }
else setTimeout(done, EDGE_DUR);
}
// A pulse that travels the whole path root→node (down) or node→root (up).
function pulsePath(nodeId, dir) {
var chain = []; var cur = nodeId;
while (cur && edgeMap[cur]) { chain.push(cur); cur = parentOf[cur]; } // node → topChild
if (dir === 'down') chain.reverse(); // topChild → node
chain.forEach(function(id, i) { setTimeout(function() { firePulseEdge(id, dir); }, i * SEG); });
}
function pulseToAgent(agent, dir) {
document.querySelectorAll('.node[data-agent="' + (agent||'').replace(/"/g,'') + '"]').forEach(function(n) {
pulsePath(n.getAttribute('data-node-id'), dir);
});
}
function applyState(card, state, task) {
card.classList.toggle('active', state === 'active');
card.classList.toggle('reviewing', state === 'reviewing');
var badge = card.querySelector('.node-badge');
if (badge) { badge.className = 'node-badge' + (state ? ' ' + (state === 'active' ? 'active' : 'review') : ''); badge.textContent = state === 'active' ? 'active' : state === 'reviewing' ? 'review' : ''; }
var st = card.querySelector('.agent-status');
if (st) {
if (state === 'active' && task) st.innerHTML = '<span class="st-dot busy"></span><a class="st-link" href="/tasks/' + esc(task.id) + '">' + esc(task.id) + ' \\u00b7 ' + esc(ago(task.updatedAt)) + '</a>';
else if (state === 'reviewing' && task) st.innerHTML = '<span class="st-dot review"></span><a class="st-link review" href="/tasks/' + esc(task.id) + '">' + esc(task.id) + ' \\u00b7 ' + esc(ago(task.updatedAt)) + '</a>';
else st.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
}
}
async function sync() {
var tasks;
try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); }
catch (e) { setConn(false); return; }
setConn(true);
if (baseline) {
tasks.forEach(function(t) {
var pv = taskState[t.id]; var agent = t.assignedTo || (pv && pv.assignedTo); if (!agent) return;
var was = pv ? pv.status : null;
if (t.status === 'in_progress' && was !== 'in_progress') pulseToAgent(agent, 'down');
else if (t.status === 'review' && was !== 'review') pulseToAgent(agent, 'up');
else if (t.status === 'open' && pv && t.assignedTo && t.assignedTo !== pv.assignedTo) pulseToAgent(agent, 'down');
else if ((t.status === 'done' || t.status === 'cancelled') && (was === 'in_progress' || was === 'review')) doneFlashAgent(agent);
});
}
var next = {}; tasks.forEach(function(t) { next[t.id] = { status: t.status, assignedTo: t.assignedTo }; });
taskState = next; baseline = true;
var active = {}, review = {};
tasks.forEach(function(t) {
if (!t.assignedTo) return;
if (t.status === 'in_progress') { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
else if (t.status === 'review') { if (!review[t.assignedTo] || t.updatedAt > review[t.assignedTo].updatedAt) review[t.assignedTo] = t; }
});
document.querySelectorAll('.node[data-agent]').forEach(function(card) {
var name = card.getAttribute('data-agent');
if (active[name]) applyState(card, 'active', active[name]);
else if (review[name]) applyState(card, 'reviewing', review[name]);
else applyState(card, null, null);
});
}
// Info toggle.
document.addEventListener('click', function(e) {
var btn = e.target.closest && e.target.closest('.node-info-btn');
if (!btn) return;
var info = btn.closest('.node').querySelector('.node-info');
if (info) { info.hidden = !info.hidden; setTimeout(layoutEdges, 30); }
});
// Inline rename → PATCH /org/:id (pencil click OR double-click the name).
function startEdit(name) {
if (!name) return;
name.setAttribute('contenteditable', 'true'); name.focus();
try { document.getSelection().selectAllChildren(name); } catch (_) {}
}
document.addEventListener('click', function(e) {
var btn = e.target.closest && e.target.closest('.node-edit');
if (btn) startEdit(btn.closest('.node').querySelector('.node-name'));
});
document.addEventListener('dblclick', function(e) {
var name = e.target.closest && e.target.closest('.node-name');
if (name) startEdit(name);
});
function commitRename(name) {
var card = name.closest('.node'); if (!card) return;
var id = card.getAttribute('data-node-id');
var label = name.textContent.trim().slice(0, 60);
name.removeAttribute('contenteditable');
if (!label) { name.textContent = name.getAttribute('data-prev') || label; return; }
fetch('/org/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: label }) })
.then(function(r){ if (!r.ok) throw 0; return r.json(); })
.then(function(res){ name.textContent = res.label; })
.catch(function(){ /* keep the typed value optimistically */ });
}
document.addEventListener('keydown', function(e) {
var name = e.target.closest && e.target.closest('.node-name[contenteditable="true"]');
if (!name) return;
if (e.key === 'Enter') { e.preventDefault(); name.blur(); }
if (e.key === 'Escape') { name.textContent = name.getAttribute('data-prev') || name.textContent; name.blur(); }
});
document.addEventListener('focusin', function(e) { var n = e.target.closest && e.target.closest('.node-name'); if (n) n.setAttribute('data-prev', n.textContent.trim()); });
document.addEventListener('blur', function(e) { var n = e.target.closest && e.target.closest('.node-name[contenteditable="true"]'); if (n) commitRename(n); }, true);
function centerScroll() {
var sc = document.querySelector('.scroll');
if (sc) sc.scrollLeft = Math.max(0, (sc.scrollWidth - sc.clientWidth) / 2);
}
// Fit-to-screen: CSS zoom reflows (so scrollWidth shrinks), unlike transform.
var fitted = false;
function toggleFit() {
var sc = document.querySelector('.scroll'), chart = document.getElementById('chart'), btn = document.getElementById('fitBtn');
if (!sc || !chart) return;
if (!fitted) {
chart.style.zoom = '1';
var natural = chart.scrollWidth || chart.getBoundingClientRect().width;
var k = Math.min(1, (sc.clientWidth - 10) / natural);
chart.style.zoom = String(k);
fitted = true; if (btn) btn.textContent = '100%';
setTimeout(layoutEdges, 40);
} else {
chart.style.zoom = '1'; fitted = false; if (btn) btn.textContent = 'Fit';
setTimeout(function(){ layoutEdges(); centerScroll(); }, 40);
}
}
var fb = document.getElementById('fitBtn');
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 + 850);
}
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); }
window.addEventListener('resize', relayout);
layoutEdges(); centerScroll();
setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
sync(); setInterval(sync, 1500);
if ('EventSource' in window) {
var es = new EventSource('/events');
es.onopen = function(){ setConn(true); };
es.onmessage = function(){ sync(); };
es.onerror = function(){ setConn(false); };
}
})();
</script>
</body>
</html>`;
}