feat(team): event-driven delegation pulse (meaningful, not decorative)

The green pulse now visualises real task flow instead of looping randomly:
- delegation (task → in_progress/reassigned) fires a green pulse architect→agent
  and the agent card turns 'active' (green ring + badge);
- review submission (→ review) fires an amber pulse agent→architect and the
  card enters the 'reviewing' state.
Pulses are one-shot, created per transition (SSE-driven, deduped against a
baseline snapshot) and removed on arrival, which lights the destination node.
At rest only the connectors breathe faintly. Active state recoloured green to
match the pulse. Verified: delegation→down+active, review→up+reviewing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 04:19:36 +02:00
parent 49bc39d42d
commit ef957bc2f6
2 changed files with 163 additions and 113 deletions

View File

@ -1,12 +1,14 @@
/** /**
* Team org-chart page, served at `GET /team`. * Team org-chart page, served at `GET /team`.
* *
* Roster-driven: roles, model + provider come from `config.agents` (the named * Roster-driven org chart: the architect at the root, every worker below, with
* team roster). Rendered as a real org chart the architect at the top, every * an SVG connector layer. The green "traveling pulse" is EVENT-DRIVEN it is
* worker below with an SVG connector layer whose lines carry a Paperclip-style * not a decorative loop. It visualises real task flow:
* "traveling pulse": a glowing green capsule flows from the architect down each * delegation (a task goes to an agent) pulse flows architect agent,
* edge and lights up the node it reaches (onArrive active), looping. Free/busy * and the agent's card turns "active" (green ring + badge);
* is live via SSE; a busy agent stays lit and its edge pulses brighter. * review (an agent submits work) amber pulse flows agent architect.
* State (active / reviewing / free) is live via SSE. At rest the chart only
* breathes faintly motion means something happened.
*/ */
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
@ -22,7 +24,8 @@ interface RosterAgent {
model?: string; model?: string;
kind?: string; kind?: string;
description?: string; description?: string;
busyTask?: TaskRow; state?: 'active' | 'reviewing';
stateTask?: TaskRow;
} }
const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
@ -42,56 +45,72 @@ function gatherRoster(cwd: string): RosterAgent[] {
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
} }
function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] { /** Attach live state: in_progress → active, else a submitted-for-review task → reviewing. */
function attachState(cwd: string, roster: RosterAgent[]): RosterAgent[] {
const tasks = listTasks(cwd) as TaskRow[]; const tasks = listTasks(cwd) as TaskRow[];
const busyByAgent = new Map<string, TaskRow>(); const active = new Map<string, TaskRow>();
const review = new Map<string, TaskRow>();
for (const t of tasks) { for (const t of tasks) {
if (t.status === 'in_progress' && t.assignedTo) { if (!t.assignedTo) continue;
const existing = busyByAgent.get(t.assignedTo); if (t.status === 'in_progress') {
if (!existing || t.updatedAt > existing.updatedAt) busyByAgent.set(t.assignedTo, t); 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);
} }
} }
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) })); return roster.map((a) => {
if (active.has(a.name)) return { ...a, state: 'active', stateTask: active.get(a.name) };
if (review.has(a.name)) return { ...a, state: 'reviewing', stateTask: review.get(a.name) };
return a;
});
} }
/** Monogram fallback when there is no provider logo for the node icon. */
function monogram(name: string): string { function monogram(name: string): string {
const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim(); const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim();
const initials = clean return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?');
? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() }
: '?';
return escapeHtml(initials); function statusMarkup(a: RosterAgent): string {
if (a.state === 'active' && a.stateTask) {
return `<span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(a.stateTask.id)}">${escapeHtml(a.stateTask.id)} &middot; active</a>`;
}
if (a.state === 'reviewing' && a.stateTask) {
return `<span class="st-dot review"></span><a class="st-link review" href="/tasks/${escapeHtml(a.stateTask.id)}">${escapeHtml(a.stateTask.id)} &middot; in review</a>`;
}
return `<span class="st-dot"></span><span class="st-free">free</span>`;
} }
/** One org-chart node (kept class `agent-card` + `data-agent` for the live sync). */
function node(a: RosterAgent, isArchitect = false): string { function node(a: RosterAgent, isArchitect = false): string {
const meta = providerMeta(a.kind); const meta = providerMeta(a.kind);
const logo = a.kind ? providerLogo(a.kind, 18) : ''; const icon = (a.kind ? providerLogo(a.kind, 18) : '') || `<span class="mono">${monogram(a.name)}</span>`;
const icon = logo || `<span class="mono">${monogram(a.name)}</span>`;
const busy = !!a.busyTask;
const status = busy
? `<span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(a.busyTask!.id)}">${escapeHtml(a.busyTask!.id)} &middot; claimed &hellip;</a>`
: `<span class="st-dot"></span><span class="st-free">free</span>`;
const modelColor = meta ? meta.color : 'var(--muted)'; const modelColor = meta ? meta.color : 'var(--muted)';
return `<div class="node agent-card${isArchitect ? ' architect' : ''}${busy ? ' busy' : ''}" data-agent="${escapeHtml(a.name)}" data-role="${escapeHtml(a.role)}"> const badge =
a.state === 'active'
? '<span class="node-badge active">active</span>'
: a.state === 'reviewing'
? '<span class="node-badge review">review</span>'
: '<span class="node-badge"></span>';
const cls = `node agent-card${isArchitect ? ' architect' : ''}${a.state ? ` ${a.state}` : ''}`;
return `<div class="${cls}" data-agent="${escapeHtml(a.name)}" data-role="${escapeHtml(a.role)}">
<div class="node-top"> <div class="node-top">
<span class="node-icon" style="--pv:${modelColor}">${icon}</span> <span class="node-icon" style="--pv:${modelColor}">${icon}</span>
<span class="node-id"> <span class="node-id">
<span class="node-name">${escapeHtml(a.name)}</span> <span class="node-name">${escapeHtml(a.name)}</span>
<span class="node-role">${escapeHtml(a.role)}${a.model ? ` &middot; <span class="node-model" style="color:${modelColor}">${escapeHtml(a.model)}</span>` : ''}</span> <span class="node-role">${escapeHtml(a.role)}${a.model ? ` &middot; <span class="node-model" style="color:${modelColor}">${escapeHtml(a.model)}</span>` : ''}</span>
</span> </span>
${badge}
</div> </div>
<div class="agent-status">${status}</div> <div class="agent-status">${statusMarkup(a)}</div>
</div>`; </div>`;
} }
export function renderTeamHtml(cwd: string): string { export function renderTeamHtml(cwd: string): string {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const roster = attachBusy(cwd, gatherRoster(cwd)); const roster = attachState(cwd, gatherRoster(cwd));
const architects = roster const architects = roster.filter((a) => a.role === 'architect').sort((a, b) => a.name.localeCompare(b.name));
.filter((a) => a.role === 'architect')
.sort((a, b) => a.name.localeCompare(b.name));
const workers = roster const workers = roster
.filter((a) => a.role !== 'architect') .filter((a) => a.role !== 'architect')
.sort( .sort(
@ -100,15 +119,9 @@ export function renderTeamHtml(cwd: string): string {
(PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) || (PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) ||
a.name.localeCompare(b.name), a.name.localeCompare(b.name),
); );
// If no architect is configured, promote the first roster entry so the tree
// still has a root to pulse from.
const root = architects.length ? architects : workers.slice(0, 1); const root = architects.length ? architects : workers.slice(0, 1);
const rest = architects.length ? workers : workers.slice(1); const rest = architects.length ? workers : workers.slice(1);
const archRow = root.map((a) => node(a, true)).join('');
const workerRow = rest.map((a) => node(a)).join('');
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -126,12 +139,12 @@ export function renderTeamHtml(cwd: string): string {
.node { .node {
position: relative; position: relative;
min-width: 210px; max-width: 260px; min-width: 214px; max-width: 264px;
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface); background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
padding: 12px 14px; padding: 12px 14px;
transition: border-color 180ms ease, transform 180ms ease, box-shadow 260ms ease, background 260ms ease; 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; animation: nodeEnter 300ms cubic-bezier(.2,.7,.2,1) both;
} }
@keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } @keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@ -141,10 +154,7 @@ export function renderTeamHtml(cwd: string): string {
.node-icon { .node-icon {
width: 34px; height: 34px; flex: 0 0 auto; width: 34px; height: 34px; flex: 0 0 auto;
display: inline-grid; place-items: center; display: inline-grid; place-items: center;
border-radius: 10px; border-radius: 10px; background: var(--raised); border: 1px solid var(--border);
background: var(--raised);
border: 1px solid var(--border);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.02);
} }
.node-icon .mono { font: 800 12px/1 var(--font-mono); color: var(--pv, var(--muted)); } .node-icon .mono { font: 800 12px/1 var(--font-mono); color: var(--pv, var(--muted)); }
.node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
@ -152,28 +162,37 @@ export function renderTeamHtml(cwd: string): string {
.node-role { font: 11px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; text-transform: uppercase; letter-spacing: .04em; } .node-role { font: 11px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; text-transform: uppercase; letter-spacing: .04em; }
.node-model { text-transform: none; letter-spacing: 0; } .node-model { text-transform: none; letter-spacing: 0; }
.node-badge { margin-left: auto; align-self: flex-start; font: 700 9.5px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; padding: 3px 7px; border-radius: 999px; }
.node-badge:empty { display: none; }
.node-badge.active { color: #adf2c7; background: rgba(34,197,94,.16); border: 1px solid rgba(34,197,94,.4); }
.node-badge.review { color: #f2d59b; background: rgba(210,153,34,.16); border: 1px solid rgba(210,153,34,.4); }
.agent-status { margin-top: 10px; display: flex; align-items: center; gap: 8px; font-size: 12px; } .agent-status { margin-top: 10px; 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 { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
.st-dot.busy { background: var(--status-in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: dotPulse 1.5s ease-in-out infinite; } .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; } } @keyframes dotPulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.st-free { color: var(--muted); } .st-free { color: var(--muted); }
.st-link { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 12px; } .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; } .st-link:hover { text-decoration: underline; }
/* Node lights up when a pulse arrives (onArrive) and while busy. */ /* Live states + pulse-arrival pop. */
.node.arrive { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.5), 0 0 26px rgba(34,197,94,.16); } .node.active { border-color: rgba(34,197,94,.6); box-shadow: 0 0 0 1px rgba(34,197,94,.5), 0 0 24px rgba(34,197,94,.15); }
.node.busy { border-color: rgba(88,166,255,.55); animation: nodeEnter 300ms both, busyGlow 2.6s ease-in-out infinite .3s; } .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); }
@keyframes busyGlow { .node.arrive { animation: arrivePop 560ms ease; }
0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.18); } @keyframes arrivePop { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.6); } 100% { box-shadow: 0 0 0 15px rgba(34,197,94,0); } }
50% { box-shadow: 0 0 0 1px rgba(88,166,255,.44), 0 6px 26px rgba(88,166,255,.14); }
}
/* Connector paths + the traveling pulse. */ /* Connectors: static faint lines that breathe subtly at rest. */
.edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; opacity: .8; } .edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; }
.edge-pulse { fill: none; stroke: var(--green); stroke-width: 3; stroke-linecap: round; filter: url(#pulseGlow); } @keyframes edgeBreath { 0%,100% { opacity: .38; } 50% { opacity: .7; } }
/* The event pulse — created on demand, removed when it arrives. */
.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 (max-width: 600px) { .node { min-width: 0; width: 100%; max-width: none; } .tier.arch { margin-bottom: 40px; } } @media (max-width: 600px) { .node { min-width: 0; width: 100%; max-width: none; } .tier.arch { margin-bottom: 40px; } }
@media (prefers-reduced-motion: reduce) { .edge-pulse { display: none; } } @media (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } }
</style> </style>
</head> </head>
<body> <body>
@ -182,107 +201,138 @@ export function renderTeamHtml(cwd: string): string {
<svg class="edges" id="edges" aria-hidden="true"> <svg class="edges" id="edges" aria-hidden="true">
<defs> <defs>
<filter id="pulseGlow" x="-60%" y="-60%" width="220%" height="220%"> <filter id="pulseGlow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="2.4" result="b" /> <feGaussianBlur stdDeviation="2.6" result="b" />
<feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge> <feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter> </filter>
</defs> </defs>
</svg> </svg>
<div class="tier arch" id="archTier">${archRow}</div> <div class="tier arch" id="archTier">${root.map((a) => node(a, true)).join('')}</div>
<div class="tier workers" id="workerTier">${workerRow}</div> <div class="tier workers" id="workerTier">${rest.map((a) => node(a)).join('')}</div>
</main> </main>
<script> <script>
(function() { (function() {
var DUR = 1700, STAGGER = 260, ARRIVE_MS = 520; var NS = 'http://www.w3.org/2000/svg';
var connDot = document.getElementById('conn-dot'); var connDot = document.getElementById('conn-dot');
var arriveTimers = []; var edgeMap = {}; // agent name -> { fwd, rev } path data
var taskState = {}; // task id -> { status, assignedTo }
var baseline = false; // suppress pulses for the initial snapshot
function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } } 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 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 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 nodeOf(name) { return document.querySelector('.node[data-agent="' + (name||'').replace(/"/g,'') + '"]'); }
function architectNode() { return document.querySelector('.node.architect') || document.querySelector('.node'); }
function flash(el) { if (!el) return; el.classList.remove('arrive'); void el.offsetWidth; el.classList.add('arrive'); setTimeout(function(){ el.classList.remove('arrive'); }, 560); }
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; } 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 centerBottom(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.bottom - box.top }; }
function flash(node) { node.classList.add('arrive'); setTimeout(function(){ node.classList.remove('arrive'); }, ARRIVE_MS); }
function layoutEdges() { function layoutEdges() {
var svg = document.getElementById('edges'); var svg = document.getElementById('edges');
var chart = document.getElementById('chart'); var chart = document.getElementById('chart');
var arch = document.querySelector('.node.architect') || document.querySelector('.node'); var arch = architectNode();
if (!svg || !chart || !arch) return; if (!svg || !chart || !arch) return;
arriveTimers.forEach(function(t){ clearInterval(t.i); clearTimeout(t.s); });
arriveTimers = [];
var box = chart.getBoundingClientRect(); var box = chart.getBoundingClientRect();
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height); svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
var start = centerBottom(arch, box); var start = centerBottom(arch, box);
var nodes = Array.prototype.slice.call(document.querySelectorAll('.tier.workers .node'));
var defs = svg.querySelector('defs'); var defs = svg.querySelector('defs');
svg.innerHTML = ''; svg.innerHTML = '';
if (defs) svg.appendChild(defs); if (defs) svg.appendChild(defs);
var NS = 'http://www.w3.org/2000/svg'; edgeMap = {};
Array.prototype.slice.call(document.querySelectorAll('.tier.workers .node')).forEach(function(n) {
nodes.forEach(function(n, i) {
var end = centerTop(n, box); var end = centerTop(n, box);
var midY = start.y + (end.y - start.y) * 0.55; var midY = start.y + (end.y - start.y) * 0.55;
var d = 'M ' + start.x + ' ' + start.y + ' C ' + start.x + ' ' + midY + ', ' + end.x + ' ' + midY + ', ' + end.x + ' ' + end.y; var fwd = 'M ' + start.x + ' ' + start.y + ' C ' + start.x + ' ' + midY + ', ' + end.x + ' ' + midY + ', ' + end.x + ' ' + end.y;
var rev = 'M ' + end.x + ' ' + end.y + ' C ' + end.x + ' ' + midY + ', ' + start.x + ' ' + midY + ', ' + start.x + ' ' + start.y;
edgeMap[n.getAttribute('data-agent')] = { fwd: fwd, rev: rev };
var base = document.createElementNS(NS, 'path'); var base = document.createElementNS(NS, 'path');
base.setAttribute('class', 'edge-base'); base.setAttribute('d', d); base.setAttribute('class', 'edge-base'); base.setAttribute('d', fwd);
svg.appendChild(base); svg.appendChild(base);
var pulse = document.createElementNS(NS, 'path');
pulse.setAttribute('class', 'edge-pulse'); pulse.setAttribute('d', d);
svg.appendChild(pulse);
var len = pulse.getTotalLength();
pulse.style.strokeDasharray = '16 ' + (len + 16);
var delay = i * STAGGER;
if (pulse.animate) {
pulse.animate([{ strokeDashoffset: len + 16 }, { strokeDashoffset: 0 }],
{ duration: DUR, iterations: Infinity, delay: delay, easing: 'cubic-bezier(.5,0,.5,1)' });
}
// Light the destination node each time the pulse reaches it.
var s = setTimeout(function() {
flash(n);
var iv = setInterval(function(){ flash(n); }, DUR);
arriveTimers.push({ i: iv, s: 0 });
}, delay + DUR);
arriveTimers.push({ i: 0, s: s });
}); });
} }
// One-shot pulse along an agent's edge. dir 'down' = architect→agent
// (delegation, green), 'up' = agent→architect (review, amber).
function firePulse(name, dir) {
var e = edgeMap[name];
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 = '22 ' + (len + 22);
p.style.strokeDashoffset = String(len + 22);
var done = function() {
try { p.remove(); } catch (_) {}
flash(dir === 'up' ? architectNode() : nodeOf(name));
};
if (p.animate) {
var a = p.animate([{ strokeDashoffset: len + 22 }, { strokeDashoffset: 0 }], { duration: 1200, easing: 'cubic-bezier(.4,0,.5,1)' });
a.onfinish = done;
} else { setTimeout(done, 1200); }
}
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 active ' + 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 in review</a>';
else st.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
}
}
async function sync() { async function sync() {
var tasks; var tasks;
try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); } try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); }
catch (e) { setConn(false); return; } catch (e) { setConn(false); return; }
setConn(true); setConn(true);
var busy = {};
(tasks || []).forEach(function(t) { // Detect transitions → fire meaningful pulses (skip on the first snapshot).
if (t.status === 'in_progress' && t.assignedTo) { if (baseline) {
if (!busy[t.assignedTo] || t.updatedAt > busy[t.assignedTo].updatedAt) busy[t.assignedTo] = t; 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') firePulse(agent, 'down'); // claimed → active
else if (t.status === 'review' && was !== 'review') firePulse(agent, 'up'); // submitted for review
else if (t.status === 'open' && pv && t.assignedTo && t.assignedTo !== pv.assignedTo) firePulse(agent, 'down'); // (re)assigned
});
} }
var next = {};
tasks.forEach(function(t) { next[t.id] = { status: t.status, assignedTo: t.assignedTo }; });
taskState = next; baseline = true;
// Live node states.
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) { document.querySelectorAll('.node[data-agent]').forEach(function(card) {
var t = busy[card.getAttribute('data-agent')]; var name = card.getAttribute('data-agent');
var statusEl = card.querySelector('.agent-status'); if (active[name]) applyState(card, 'active', active[name]);
if (t) { else if (review[name]) applyState(card, 'reviewing', review[name]);
card.classList.add('busy'); else applyState(card, null, null);
if (statusEl) statusEl.innerHTML = '<span class="st-dot busy"></span><a class="st-link" href="/tasks/' + esc(t.id) + '">' + esc(t.id) + ' \\u00b7 claimed ' + esc(ago(t.updatedAt)) + '</a>';
} else {
card.classList.remove('busy');
if (statusEl) statusEl.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
}
}); });
} }
var rt; var rt;
function relayout() { clearTimeout(rt); rt = setTimeout(layoutEdges, 120); } function relayout() { clearTimeout(rt); rt = setTimeout(layoutEdges, 120); }
window.addEventListener('resize', relayout); window.addEventListener('resize', relayout);
// Fonts/wrapping settle after first paint — lay out once now and once shortly after.
layoutEdges(); setTimeout(layoutEdges, 350); layoutEdges(); setTimeout(layoutEdges, 350);
sync(); sync();
setInterval(sync, 1000); setInterval(sync, 1500);
if ('EventSource' in window) { if ('EventSource' in window) {
var es = new EventSource('/events'); var es = new EventSource('/events');
es.onopen = function(){ setConn(true); }; es.onopen = function(){ setConn(true); };

View File

@ -55,9 +55,9 @@ describe('GET /team', () => {
const res = await app.inject({ method: 'GET', url: '/team' }); const res = await app.inject({ method: 'GET', url: '/team' });
const html = res.payload; const html = res.payload;
expect(html).toContain('TSK-0001'); expect(html).toContain('TSK-0001');
// The busy agent's card is tagged for the live client sync and rendered busy. // The busy agent's card is tagged for the live client sync and rendered active.
expect(html).toContain('data-agent="codex"'); expect(html).toContain('data-agent="codex"');
expect(html).toContain('agent-card busy'); expect(html).toContain('agent-card active');
expect(html).toContain('claimed'); expect(html).toContain('active');
}); });
}); });