feat(team): Paperclip-style org chart with traveling-pulse connectors

Rebuild /team as a real org chart: architect at the root, workers below,
with an SVG connector layer whose edges carry a traveling green pulse
(getTotalLength + WAAPI stroke-dash motion) that flows from the architect
down each edge and lights up the node it reaches (onArrive → .arrive glow),
looping. Live busy state (SSE) keeps working agents lit blue; edges + nodes
re-layout on resize. Node cards restyled (icon box + name + role·model +
status). Kept data-agent / agent-card / claimed markers for the live sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 03:58:02 +02:00
parent 8dbbd0d10c
commit 49bc39d42d

View File

@ -1,30 +1,19 @@
/** /**
* Team hierarchy 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: roles, model + provider come from `config.agents` (the named
* team roster) NOT inferred from whichever tasks an agent touched. So each * team roster). Rendered as a real org chart the architect at the top, every
* agent has exactly one role (no more "codex in three roles"), demo/throwaway * worker below with an SVG connector layer whose lines carry a Paperclip-style
* agents that aren't in the roster never show, and the model behind each agent * "traveling pulse": a glowing green capsule flows from the architect down each
* is explicit. Implementers are grouped by provider (Anthropic / OpenAI / * edge and lights up the node it reaches (onArrive active), looping. Free/busy
* Moonshot) with the official brand logo. Free/busy is derived from in-progress * is live via SSE; a busy agent stays lit and its edge pulses brighter.
* tasks. Falls back to the role config for projects without a roster.
*/ */
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js'; import { listTasks } from '../core/services/taskService.js';
import { import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js';
agentAvatar,
designTokensCss,
escapeHtml,
liveTimerJs,
pageHeader,
providerLogo,
providerMeta,
statusPill,
} from './ui-shared.js';
import type { Task } from '../core/schema.js'; import type { Task } from '../core/schema.js';
/** The task fields the index actually carries + that this page needs. */
type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>; type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>;
interface RosterAgent { interface RosterAgent {
@ -39,7 +28,6 @@ interface RosterAgent {
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 };
const PROVIDER_ORDER: Record<string, number> = { anthropic: 0, openai: 1, moonshot: 2 }; const PROVIDER_ORDER: Record<string, number> = { anthropic: 0, openai: 1, moonshot: 2 };
/** Build the roster from config.agents, or fall back to the role config. */
function gatherRoster(cwd: string): RosterAgent[] { function gatherRoster(cwd: string): RosterAgent[] {
const config = loadConfig(cwd); const config = loadConfig(cwd);
if (config.agents && Object.keys(config.agents).length > 0) { if (config.agents && Object.keys(config.agents).length > 0) {
@ -51,7 +39,6 @@ function gatherRoster(cwd: string): RosterAgent[] {
description: a.description, description: a.description,
})); }));
} }
// Fallback: no roster configured — derive one agent per role from config.roles.
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
} }
@ -67,108 +54,60 @@ function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) })); return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) }));
} }
function statusLine(a: RosterAgent): string { /** Monogram fallback when there is no provider logo for the node icon. */
const busy = a.busyTask; function monogram(name: string): string {
if (busy) { const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim();
return `<div class="agent-status"><span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(busy.id)}">${escapeHtml(busy.id)} &middot; claimed &hellip;</a></div>`; const initials = clean
} ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()
return `<div class="agent-status"><span class="st-dot"></span><span class="st-free">free</span></div>`; : '?';
return escapeHtml(initials);
} }
/** /** One org-chart node (kept class `agent-card` + `data-agent` for the live sync). */
* Render one agent card. `avatar:'logo'` shows the provider brand mark (for function node(a: RosterAgent, isArchitect = false): string {
* standalone tiers); `avatar:'monogram'` shows the per-agent initials chip (used
* inside a provider group whose header already carries the brand logo).
*/
function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architectRing?: boolean } = { avatar: 'monogram' }): string {
const meta = providerMeta(a.kind); const meta = providerMeta(a.kind);
const avatar = const logo = a.kind ? providerLogo(a.kind, 18) : '';
opts.avatar === 'logo' && meta const icon = logo || `<span class="mono">${monogram(a.name)}</span>`;
? `<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:var(--raised);border:1px solid var(--border);${opts.architectRing ? `box-shadow:0 0 0 2px var(--bg),0 0 0 4px ${meta.color};` : ''}">${providerLogo(a.kind, 19)}</span>` const busy = !!a.busyTask;
: agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 }); 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>`
const model = a.model : `<span class="st-dot"></span><span class="st-free">free</span>`;
? `<span style="font-family:var(--font-mono);font-size:11px;color:${meta ? meta.color : 'var(--muted)'};">${escapeHtml(a.model)}</span>` 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)}">
<div class="node-top">
return ` <span class="node-icon" style="--pv:${modelColor}">${icon}</span>
<div class="agent-card${a.busyTask ? ' busy' : ''}" data-agent="${escapeHtml(a.name)}" data-role="${escapeHtml(a.role)}" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;"> <span class="node-id">
<div style="display:flex;align-items:center;gap:10px;"> <span class="node-name">${escapeHtml(a.name)}</span>
${avatar} <span class="node-role">${escapeHtml(a.role)}${a.model ? ` &middot; <span class="node-model" style="color:${modelColor}">${escapeHtml(a.model)}</span>` : ''}</span>
<div style="min-width:0;"> </span>
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(a.name)}</div>
${model}
</div> </div>
</div> <div class="agent-status">${status}</div>
${a.description ? `<div style="margin-top:8px;color:var(--muted);font-size:11px;line-height:1.4;">${escapeHtml(a.description)}</div>` : ''} </div>`;
${statusLine(a)}
</div>`;
}
/** A provider column: brand-logo header + the agents running on that provider. */
function providerGroup(kind: string, agents: RosterAgent[]): string {
const meta = providerMeta(kind);
const busy = agents.filter((a) => a.busyTask).length;
return `
<div class="provider-group" style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;align-items:center;gap:8px;padding-bottom:4px;">
${providerLogo(kind, 18)}
<span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span>
<span style="flex:1;"></span>
<span class="pg-busy" data-group="${escapeHtml(kind)}" data-total="${agents.length}" style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span>
</div>
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
</div>`;
}
function tierLabel(text: string): string {
return `<div data-role="${escapeHtml(text.toLowerCase().replace(/s$/, ''))}" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</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 = attachBusy(cwd, gatherRoster(cwd));
const byRole = new Map<string, RosterAgent[]>(); const architects = roster
for (const a of roster) { .filter((a) => a.role === 'architect')
const list = byRole.get(a.role) ?? []; .sort((a, b) => a.name.localeCompare(b.name));
list.push(a); const workers = roster
byRole.set(a.role, list); .filter((a) => a.role !== 'architect')
} .sort(
(a, b) =>
(ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) ||
(PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) ||
a.name.localeCompare(b.name),
);
const architects = (byRole.get('architect') ?? []).sort((a, b) => a.name.localeCompare(b.name)); // If no architect is configured, promote the first roster entry so the tree
const implementers = byRole.get('implementer') ?? []; // still has a root to pulse from.
const testers = (byRole.get('tester') ?? []).sort((a, b) => a.name.localeCompare(b.name)); const root = architects.length ? architects : workers.slice(0, 1);
const rest = architects.length ? workers : workers.slice(1);
// Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest. const archRow = root.map((a) => node(a, true)).join('');
const byProvider = new Map<string, RosterAgent[]>(); const workerRow = rest.map((a) => node(a)).join('');
for (const a of implementers) {
const key = a.kind ?? 'other';
const list = byProvider.get(key) ?? [];
list.push(a);
byProvider.set(key, list);
}
const providerKeys = Array.from(byProvider.keys()).sort(
(a, b) => (PROVIDER_ORDER[a] ?? 99) - (PROVIDER_ORDER[b] ?? 99) || a.localeCompare(b),
);
for (const k of byProvider.keys()) {
byProvider.get(k)!.sort((a, b) => a.name.localeCompare(b.name));
}
const architectTier = architects.length
? `<div class="tier"><div class="tier-row">${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}</div>${tierLabel('Architect')}</div>`
: '';
const implementerTier = providerKeys.length
? `<div class="tier">${tierLabel('Implementers')}<div class="provider-row">${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}</div></div>`
: '';
const testerTier = testers.length
? `<div class="tier">${tierLabel('Testers')}<div class="tier-row">${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}</div></div>`
: '';
const connector = '<div class="connector-down" aria-hidden="true"></div>';
const tiers = [architectTier, implementerTier, testerTier].filter(Boolean).join(connector);
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
@ -180,104 +119,143 @@ export function renderTeamHtml(cwd: string): string {
<style> <style>
${designTokensCss()} ${designTokensCss()}
.tree { display:flex; flex-direction:column; align-items:center; gap:0; padding:8px 0 32px; } .chart { position: relative; padding: 12px 8px 40px; }
.tier { display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; } .edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: visible; }
.tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; } .tier { position: relative; z-index: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 18px; }
.provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; } .tier.arch { margin-bottom: 64px; }
.provider-group { min-width:240px; max-width:300px; }
/* Connectors always carry a subtle ambient shimmer, so the tree looks alive
even at rest; when an agent is working the flow brightens and speeds up. */
.connector-down {
width:2px; height:28px; flex:0 0 auto; border-radius:2px;
background: linear-gradient(180deg, var(--border) 0%, rgba(88,166,255,.30) 50%, var(--border) 100%);
background-size: 100% 240%;
animation: flowDown 4.2s linear infinite;
}
.tree.live .connector-down {
background-image: linear-gradient(180deg, var(--border) 0%, var(--accent) 45%, var(--border) 90%);
animation-duration: 1.8s;
}
@keyframes flowDown { from { background-position: 0 130%; } to { background-position: 0 -130%; } }
.agent-card { .node {
transition: border-color 150ms ease, transform 150ms ease, box-shadow 220ms ease, background 220ms ease; position: relative;
animation: cardEnter 260ms cubic-bezier(.2,.7,.2,1) both, idleGlow 5.5s ease-in-out infinite .3s; min-width: 210px; max-width: 260px;
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 12px 14px;
transition: border-color 180ms ease, transform 180ms ease, box-shadow 260ms ease, background 260ms ease;
animation: nodeEnter 300ms cubic-bezier(.2,.7,.2,1) both;
} }
@keyframes cardEnter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } @keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
/* Faint neutral breathing at rest — alive, but clearly not "busy" (which is accent-blue). */ .node:hover { border-color: rgba(88,166,255,.5); transform: translateY(-1px); }
@keyframes idleGlow { .node.architect { border-color: rgba(217,119,87,.5); }
0%,100% { box-shadow: 0 0 0 1px rgba(148,163,184,.04); } .node-top { display: flex; align-items: center; gap: 11px; }
50% { box-shadow: 0 0 0 1px rgba(148,163,184,.12), 0 4px 16px rgba(0,0,0,.16); } .node-icon {
} width: 34px; height: 34px; flex: 0 0 auto;
.agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); } display: inline-grid; place-items: center;
/* Busy agent: soft breathing glow + accent border (overrides idle breathing). */ border-radius: 10px;
.agent-card.busy { border-color: rgba(88,166,255,.5); animation: cardBusy 2.6s ease-in-out infinite; } background: var(--raised);
@keyframes cardBusy { border: 1px solid var(--border);
0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.16); } box-shadow: inset 0 0 0 1px rgba(255,255,255,.02);
50% { box-shadow: 0 0 0 1px rgba(88,166,255,.42), 0 6px 22px rgba(88,166,255,.12); }
}
.agent-card.ping { animation: cardPing 720ms ease; }
@keyframes cardPing {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,.5); }
100% { box-shadow: 0 0 0 16px rgba(34,197,94,0); }
} }
.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-name { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
/* Status line */ .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(--status-in_progress); box-shadow:0 0 0 3px rgba(88,166,255,.16); animation: dotPulse 1.5s ease-in-out infinite; } @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(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 12px; }
.st-link:hover { text-decoration: underline; } .st-link:hover { text-decoration: underline; }
@media (max-width: 640px) { /* Node lights up when a pulse arrives (onArrive) and while busy. */
.provider-row { flex-direction:column; align-items:center; gap:14px; } .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); }
.provider-group { max-width:none; width:100%; } .node.busy { border-color: rgba(88,166,255,.55); animation: nodeEnter 300ms both, busyGlow 2.6s ease-in-out infinite .3s; }
@keyframes busyGlow {
0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.18); }
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. */
.edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; opacity: .8; }
.edge-pulse { fill: none; stroke: var(--green); stroke-width: 3; stroke-linecap: round; filter: url(#pulseGlow); }
@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; } }
</style> </style>
</head> </head>
<body> <body>
${pageHeader(config.projectName, 'team')} ${pageHeader(config.projectName, 'team')}
<main class="tree" id="tree"> <main class="chart" id="chart">
${tiers} <svg class="edges" id="edges" aria-hidden="true">
<defs>
<filter id="pulseGlow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="2.4" result="b" />
<feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
</svg>
<div class="tier arch" id="archTier">${archRow}</div>
<div class="tier workers" id="workerTier">${workerRow}</div>
</main> </main>
<script> <script>
(function() { (function() {
var DUR = 1700, STAGGER = 260, ARRIVE_MS = 520;
var connDot = document.getElementById('conn-dot'); var connDot = document.getElementById('conn-dot');
function setConn(ok) { var arriveTimers = [];
if (!connDot) return; function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } }
connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
connDot.title = ok ? 'connected' : 'reconnecting'; 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 esc(s) { function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
return String(s == null ? '' : s) function centerBottom(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.bottom - box.top }; }
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} function flash(node) { node.classList.add('arrive'); setTimeout(function(){ node.classList.remove('arrive'); }, ARRIVE_MS); }
function ago(iso) {
var t = Date.parse(iso); function layoutEdges() {
if (isNaN(t)) return ''; var svg = document.getElementById('edges');
var s = Math.max(0, Math.floor((Date.now() - t) / 1000)); var chart = document.getElementById('chart');
if (s < 60) return s + 's'; var arch = document.querySelector('.node.architect') || document.querySelector('.node');
var m = Math.floor(s / 60); if (!svg || !chart || !arch) return;
if (m < 60) return m + 'm'; arriveTimers.forEach(function(t){ clearInterval(t.i); clearTimeout(t.s); });
var h = Math.floor(m / 60); arriveTimers = [];
if (h < 24) return h + 'h';
return Math.floor(h / 24) + 'd'; var box = chart.getBoundingClientRect();
} svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
function updateGroupCounts() { var start = centerBottom(arch, box);
document.querySelectorAll('.pg-busy').forEach(function(el) { var nodes = Array.prototype.slice.call(document.querySelectorAll('.tier.workers .node'));
var group = el.closest('.provider-group');
var total = el.getAttribute('data-total') || '0'; var defs = svg.querySelector('defs');
var busy = group ? group.querySelectorAll('.agent-card.busy').length : 0; svg.innerHTML = '';
el.textContent = busy + '/' + total + ' busy'; if (defs) svg.appendChild(defs);
var NS = 'http://www.w3.org/2000/svg';
nodes.forEach(function(n, i) {
var end = centerTop(n, box);
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 base = document.createElementNS(NS, 'path');
base.setAttribute('class', 'edge-base'); base.setAttribute('d', d);
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 });
}); });
} }
async function sync() { async function sync() {
var tasks; var tasks;
try { try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); }
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 = {}; var busy = {};
(tasks || []).forEach(function(t) { (tasks || []).forEach(function(t) {
@ -285,37 +263,31 @@ export function renderTeamHtml(cwd: string): string {
if (!busy[t.assignedTo] || t.updatedAt > busy[t.assignedTo].updatedAt) busy[t.assignedTo] = t; if (!busy[t.assignedTo] || t.updatedAt > busy[t.assignedTo].updatedAt) busy[t.assignedTo] = t;
} }
}); });
var anyBusy = false; document.querySelectorAll('.node[data-agent]').forEach(function(card) {
document.querySelectorAll('.agent-card[data-agent]').forEach(function(card) { var t = busy[card.getAttribute('data-agent')];
var name = card.getAttribute('data-agent');
var t = busy[name];
var statusEl = card.querySelector('.agent-status'); var statusEl = card.querySelector('.agent-status');
if (t) { if (t) {
anyBusy = true; card.classList.add('busy');
if (!card.classList.contains('busy')) { 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>';
card.classList.add('busy');
card.classList.remove('ping'); void card.offsetWidth; card.classList.add('ping');
}
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 { } else {
card.classList.remove('busy'); card.classList.remove('busy');
if (statusEl) statusEl.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>'; if (statusEl) statusEl.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
} }
}); });
var tree = document.getElementById('tree');
if (tree) tree.classList.toggle('live', anyBusy);
updateGroupCounts();
} }
var rt;
function relayout() { clearTimeout(rt); rt = setTimeout(layoutEdges, 120); }
window.addEventListener('resize', relayout);
// Fonts/wrapping settle after first paint — lay out once now and once shortly after.
layoutEdges(); setTimeout(layoutEdges, 350);
sync(); sync();
setInterval(sync, 1000); // keep "claimed Xs" ticking + reflect changes even without SSE setInterval(sync, 1000);
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); };
es.onmessage = function() { sync(); }; es.onmessage = function(){ sync(); };
es.onerror = function() { setConn(false); }; es.onerror = function(){ setConn(false); };
} }
})(); })();
</script> </script>