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:
parent
8dbbd0d10c
commit
49bc39d42d
@ -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
|
||||
* team roster) — NOT inferred from whichever tasks an agent touched. So each
|
||||
* agent has exactly one role (no more "codex in three roles"), demo/throwaway
|
||||
* agents that aren't in the roster never show, and the model behind each agent
|
||||
* is explicit. Implementers are grouped by provider (Anthropic / OpenAI /
|
||||
* Moonshot) with the official brand logo. Free/busy is derived from in-progress
|
||||
* tasks. Falls back to the role config for projects without a roster.
|
||||
* team roster). Rendered as a real org chart — the architect at the top, every
|
||||
* worker below — with an SVG connector layer whose lines carry a Paperclip-style
|
||||
* "traveling pulse": a glowing green capsule flows from the architect down each
|
||||
* edge and lights up the node it reaches (onArrive → active), looping. Free/busy
|
||||
* is live via SSE; a busy agent stays lit and its edge pulses brighter.
|
||||
*/
|
||||
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { listTasks } from '../core/services/taskService.js';
|
||||
import {
|
||||
agentAvatar,
|
||||
designTokensCss,
|
||||
escapeHtml,
|
||||
liveTimerJs,
|
||||
pageHeader,
|
||||
providerLogo,
|
||||
providerMeta,
|
||||
statusPill,
|
||||
} from './ui-shared.js';
|
||||
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.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'>;
|
||||
|
||||
interface RosterAgent {
|
||||
@ -39,7 +28,6 @@ interface RosterAgent {
|
||||
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 };
|
||||
|
||||
/** Build the roster from config.agents, or fall back to the role config. */
|
||||
function gatherRoster(cwd: string): RosterAgent[] {
|
||||
const config = loadConfig(cwd);
|
||||
if (config.agents && Object.keys(config.agents).length > 0) {
|
||||
@ -51,7 +39,6 @@ function gatherRoster(cwd: string): RosterAgent[] {
|
||||
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 }));
|
||||
}
|
||||
|
||||
@ -67,108 +54,60 @@ function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
|
||||
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) }));
|
||||
}
|
||||
|
||||
function statusLine(a: RosterAgent): string {
|
||||
const busy = a.busyTask;
|
||||
if (busy) {
|
||||
return `<div class="agent-status"><span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(busy.id)}">${escapeHtml(busy.id)} · claimed …</a></div>`;
|
||||
}
|
||||
return `<div class="agent-status"><span class="st-dot"></span><span class="st-free">free</span></div>`;
|
||||
/** Monogram fallback when there is no provider logo for the node icon. */
|
||||
function monogram(name: string): string {
|
||||
const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim();
|
||||
const initials = clean
|
||||
? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()
|
||||
: '?';
|
||||
return escapeHtml(initials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one agent card. `avatar:'logo'` shows the provider brand mark (for
|
||||
* 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 {
|
||||
/** One org-chart node (kept class `agent-card` + `data-agent` for the live sync). */
|
||||
function node(a: RosterAgent, isArchitect = false): string {
|
||||
const meta = providerMeta(a.kind);
|
||||
const avatar =
|
||||
opts.avatar === 'logo' && meta
|
||||
? `<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>`
|
||||
: agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 });
|
||||
|
||||
const model = a.model
|
||||
? `<span style="font-family:var(--font-mono);font-size:11px;color:${meta ? meta.color : 'var(--muted)'};">${escapeHtml(a.model)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<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;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
${avatar}
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(a.name)}</div>
|
||||
${model}
|
||||
const logo = a.kind ? providerLogo(a.kind, 18) : '';
|
||||
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)} · claimed …</a>`
|
||||
: `<span class="st-dot"></span><span class="st-free">free</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">
|
||||
<span class="node-icon" style="--pv:${modelColor}">${icon}</span>
|
||||
<span class="node-id">
|
||||
<span class="node-name">${escapeHtml(a.name)}</span>
|
||||
<span class="node-role">${escapeHtml(a.role)}${a.model ? ` · <span class="node-model" style="color:${modelColor}">${escapeHtml(a.model)}</span>` : ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
${a.description ? `<div style="margin-top:8px;color:var(--muted);font-size:11px;line-height:1.4;">${escapeHtml(a.description)}</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>`;
|
||||
<div class="agent-status">${status}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderTeamHtml(cwd: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const roster = attachBusy(cwd, gatherRoster(cwd));
|
||||
|
||||
const byRole = new Map<string, RosterAgent[]>();
|
||||
for (const a of roster) {
|
||||
const list = byRole.get(a.role) ?? [];
|
||||
list.push(a);
|
||||
byRole.set(a.role, list);
|
||||
}
|
||||
const architects = roster
|
||||
.filter((a) => a.role === 'architect')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const workers = roster
|
||||
.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));
|
||||
const implementers = byRole.get('implementer') ?? [];
|
||||
const testers = (byRole.get('tester') ?? []).sort((a, b) => 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 rest = architects.length ? workers : workers.slice(1);
|
||||
|
||||
// Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest.
|
||||
const byProvider = new Map<string, RosterAgent[]>();
|
||||
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);
|
||||
const archRow = root.map((a) => node(a, true)).join('');
|
||||
const workerRow = rest.map((a) => node(a)).join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
@ -180,104 +119,143 @@ export function renderTeamHtml(cwd: string): string {
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
|
||||
.tree { display:flex; flex-direction:column; align-items:center; gap:0; padding:8px 0 32px; }
|
||||
.tier { display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; }
|
||||
.tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; }
|
||||
.provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; }
|
||||
.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%; } }
|
||||
.chart { position: relative; padding: 12px 8px 40px; }
|
||||
.edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: visible; }
|
||||
.tier { position: relative; z-index: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 18px; }
|
||||
.tier.arch { margin-bottom: 64px; }
|
||||
|
||||
.agent-card {
|
||||
transition: border-color 150ms ease, transform 150ms ease, box-shadow 220ms ease, background 220ms ease;
|
||||
animation: cardEnter 260ms cubic-bezier(.2,.7,.2,1) both, idleGlow 5.5s ease-in-out infinite .3s;
|
||||
.node {
|
||||
position: relative;
|
||||
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; } }
|
||||
/* Faint neutral breathing at rest — alive, but clearly not "busy" (which is accent-blue). */
|
||||
@keyframes idleGlow {
|
||||
0%,100% { box-shadow: 0 0 0 1px rgba(148,163,184,.04); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(148,163,184,.12), 0 4px 16px rgba(0,0,0,.16); }
|
||||
}
|
||||
.agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); }
|
||||
/* Busy agent: soft breathing glow + accent border (overrides idle breathing). */
|
||||
.agent-card.busy { border-color: rgba(88,166,255,.5); animation: cardBusy 2.6s ease-in-out infinite; }
|
||||
@keyframes cardBusy {
|
||||
0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.16); }
|
||||
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); }
|
||||
@keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.node:hover { border-color: rgba(88,166,255,.5); transform: translateY(-1px); }
|
||||
.node.architect { border-color: rgba(217,119,87,.5); }
|
||||
.node-top { display: flex; align-items: center; gap: 11px; }
|
||||
.node-icon {
|
||||
width: 34px; height: 34px; flex: 0 0 auto;
|
||||
display: inline-grid; place-items: center;
|
||||
border-radius: 10px;
|
||||
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-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; }
|
||||
.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; }
|
||||
@keyframes dotPulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||
.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.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; } }
|
||||
.st-free { color: var(--muted); }
|
||||
.st-link { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 12px; }
|
||||
.st-link:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.provider-row { flex-direction:column; align-items:center; gap:14px; }
|
||||
.provider-group { max-width:none; width:100%; }
|
||||
/* Node lights up when a pulse arrives (onArrive) and while busy. */
|
||||
.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.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>
|
||||
</head>
|
||||
<body>
|
||||
${pageHeader(config.projectName, 'team')}
|
||||
<main class="tree" id="tree">
|
||||
${tiers}
|
||||
<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.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>
|
||||
<script>
|
||||
(function() {
|
||||
var DUR = 1700, STAGGER = 260, ARRIVE_MS = 520;
|
||||
var connDot = document.getElementById('conn-dot');
|
||||
function setConn(ok) {
|
||||
if (!connDot) return;
|
||||
connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)';
|
||||
connDot.title = ok ? 'connected' : 'reconnecting';
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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 updateGroupCounts() {
|
||||
document.querySelectorAll('.pg-busy').forEach(function(el) {
|
||||
var group = el.closest('.provider-group');
|
||||
var total = el.getAttribute('data-total') || '0';
|
||||
var busy = group ? group.querySelectorAll('.agent-card.busy').length : 0;
|
||||
el.textContent = busy + '/' + total + ' busy';
|
||||
var arriveTimers = [];
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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 flash(node) { node.classList.add('arrive'); setTimeout(function(){ node.classList.remove('arrive'); }, ARRIVE_MS); }
|
||||
|
||||
function layoutEdges() {
|
||||
var svg = document.getElementById('edges');
|
||||
var chart = document.getElementById('chart');
|
||||
var arch = document.querySelector('.node.architect') || document.querySelector('.node');
|
||||
if (!svg || !chart || !arch) return;
|
||||
arriveTimers.forEach(function(t){ clearInterval(t.i); clearTimeout(t.s); });
|
||||
arriveTimers = [];
|
||||
|
||||
var box = chart.getBoundingClientRect();
|
||||
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
|
||||
var start = centerBottom(arch, box);
|
||||
var nodes = Array.prototype.slice.call(document.querySelectorAll('.tier.workers .node'));
|
||||
|
||||
var defs = svg.querySelector('defs');
|
||||
svg.innerHTML = '';
|
||||
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() {
|
||||
var tasks;
|
||||
try {
|
||||
tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r) { return r.json(); });
|
||||
} catch (e) { setConn(false); return; }
|
||||
try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); }
|
||||
catch (e) { setConn(false); return; }
|
||||
setConn(true);
|
||||
var busy = {};
|
||||
(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;
|
||||
}
|
||||
});
|
||||
var anyBusy = false;
|
||||
document.querySelectorAll('.agent-card[data-agent]').forEach(function(card) {
|
||||
var name = card.getAttribute('data-agent');
|
||||
var t = busy[name];
|
||||
document.querySelectorAll('.node[data-agent]').forEach(function(card) {
|
||||
var t = busy[card.getAttribute('data-agent')];
|
||||
var statusEl = card.querySelector('.agent-status');
|
||||
if (t) {
|
||||
anyBusy = true;
|
||||
if (!card.classList.contains('busy')) {
|
||||
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>';
|
||||
}
|
||||
card.classList.add('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>';
|
||||
} else {
|
||||
card.classList.remove('busy');
|
||||
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();
|
||||
setInterval(sync, 1000); // keep "claimed Xs" ticking + reflect changes even without SSE
|
||||
setInterval(sync, 1000);
|
||||
if ('EventSource' in window) {
|
||||
var es = new EventSource('/events');
|
||||
es.onopen = function() { setConn(true); };
|
||||
es.onmessage = function() { sync(); };
|
||||
es.onerror = function() { setConn(false); };
|
||||
es.onopen = function(){ setConn(true); };
|
||||
es.onmessage = function(){ sync(); };
|
||||
es.onerror = function(){ setConn(false); };
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user