feat(team): multi-level org chart from config.org + info + inline rename

Replace the flat 2-tier team view with an arbitrary-depth org chart driven by
a new config.org hierarchy (OrgNodeSchema: id/label/title/agent/parentId).
Nodes render recursively (nested subtrees); SVG connectors are measured
parent→child; the event-driven pulse now travels the full path root→agent
(down = delegation/green, up = review/amber), lighting each node it passes.
Org-only nodes (a human CEO, product owners) sit in the tree without a linked
agent. Each node has an info button (its job description) and an inline-
editable name persisted via PATCH /org/:id. Live active/reviewing state is
rendered server-side and kept fresh over SSE. Compact layout + auto-centered
horizontal scroll. Falls back to a synthesised architect→workers tree when no
org is configured (keeps existing behaviour + tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 04:59:09 +02:00
parent ef957bc2f6
commit ab35389f9c
3 changed files with 273 additions and 187 deletions

View File

@ -128,6 +128,27 @@ export const AgentConfigSchema = z.object({
budgetEur: z.number().nonnegative().optional(), budgetEur: z.number().nonnegative().optional(),
}); });
/**
* One node of the team org chart. Supports arbitrary depth via `parentId`, and
* decouples the DISPLAY (label/title/editable) from the linked roster agent, so
* org-only nodes (a human CEO, a Product Owner) can sit in the tree without a
* running agent. Live state (active/reviewing) is derived from `agent`.
*/
export const OrgNodeSchema = z.object({
id: z.string().min(1),
/** Editable display name. */
label: z.string().min(1),
/** One-line "what this node does" — shown behind the info button. */
title: z.string().optional(),
/** Linked roster agent name (for model/provider/logo + live task state). */
agent: z.string().optional(),
/** Parent node id; omit/undefined for the root. */
parentId: z.string().optional(),
/** Provider override for the icon when there is no linked agent. */
kind: z.string().optional(),
});
export type OrgNode = z.infer<typeof OrgNodeSchema>;
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({
version: z.literal('1'), version: z.literal('1'),
projectName: z.string().min(1), projectName: z.string().min(1),
@ -135,6 +156,8 @@ export const ConfigSchema = z.object({
roles: z.record(z.string(), RoleConfigSchema), roles: z.record(z.string(), RoleConfigSchema),
/** Named team roster: agent name → role + model + provider. */ /** Named team roster: agent name → role + model + provider. */
agents: z.record(z.string(), AgentConfigSchema).optional(), agents: z.record(z.string(), AgentConfigSchema).optional(),
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
org: z.array(OrgNodeSchema).optional(),
serverUrl: z.string().url().optional(), serverUrl: z.string().url().optional(),
}); });

View File

@ -9,7 +9,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
import { computeBudget } from '../core/services/budgetService.js'; import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js'; import { getRoster } from '../core/services/rosterService.js';
import { loadConfig } from '../core/config.js'; import { loadConfig, saveConfig } from '../core/config.js';
import { renderActivityHtml } from './activity.js'; import { renderActivityHtml } from './activity.js';
import { renderBoardHtml } from './board.js'; import { renderBoardHtml } from './board.js';
import { renderTeamHtml } from './team.js'; import { renderTeamHtml } from './team.js';
@ -143,6 +143,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Team roster (JSON) — used by the board's agent rail / drop targets. // Team roster (JSON) — used by the board's agent rail / drop targets.
app.get('/agents', async () => getRoster(cwd)); app.get('/agents', async () => getRoster(cwd));
// Rename an org-chart node's display label (inline edit on /team).
app.patch('/org/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const { label } = (request.body ?? {}) as { label?: string };
if (!label || typeof label !== 'string' || !label.trim()) return badRequest(reply, 'label is required');
const config = loadConfig(cwd);
const node = config.org?.find((n) => n.id === id);
if (!node) return notFound(reply, 'Org node');
node.label = label.trim().slice(0, 60);
saveConfig(cwd, config);
return { ok: true, id, label: node.label };
});
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged). // Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
app.get('/budget', async () => computeBudget(cwd)); app.get('/budget', async () => computeBudget(cwd));

View File

@ -1,70 +1,67 @@
/** /**
* Team org-chart page, served at `GET /team`. * Team org-chart page, served at `GET /team`.
* *
* Roster-driven org chart: the architect at the root, every worker below, with * Renders `config.org` an arbitrary-depth hierarchy (CEO architect
* an SVG connector layer. The green "traveling pulse" is EVENT-DRIVEN it is * product owners implementers, strategist compliance, ). Falls back to a
* not a decorative loop. It visualises real task flow: * synthesised 2-level tree (architect workers) when no org is configured.
* delegation (a task goes to an agent) pulse flows architect agent, *
* and the agent's card turns "active" (green ring + badge); * The green "traveling pulse" is EVENT-DRIVEN and flows along the real tree
* review (an agent submits work) amber pulse flows agent architect. * edges: a delegation (task an agent) sends a pulse DOWN the path from the
* State (active / reviewing / free) is live via SSE. At rest the chart only * architect to that agent's node, lighting each node it passes and turning the
* breathes faintly motion means something happened. * 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 { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js'; import { listTasks } from '../core/services/taskService.js';
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js'; import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js';
import type { Task } from '../core/schema.js'; import type { Config, OrgNode, Task } from '../core/schema.js';
type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>; type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>;
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
interface RosterAgent { /** Per-agent live state for the initial server render (in_progress → active). */
name: string; function computeStates(cwd: string): Map<string, AgentState> {
role: string;
model?: string;
kind?: string;
description?: string;
state?: 'active' | 'reviewing';
stateTask?: TaskRow;
}
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 };
function gatherRoster(cwd: string): RosterAgent[] {
const config = loadConfig(cwd);
if (config.agents && Object.keys(config.agents).length > 0) {
return Object.entries(config.agents).map(([name, a]) => ({
name,
role: a.role,
model: a.model,
kind: a.kind,
description: a.description,
}));
}
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
}
/** 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 active = new Map<string, TaskRow>(); const active = new Map<string, TaskRow>();
const review = new Map<string, TaskRow>(); const review = new Map<string, TaskRow>();
for (const t of tasks) { for (const t of tasks) {
if (!t.assignedTo) continue; if (!t.assignedTo) continue;
if (t.status === 'in_progress') { if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
const e = active.get(t.assignedTo); else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); }
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;
} }
return roster.map((a) => {
if (active.has(a.name)) return { ...a, state: 'active', stateTask: active.get(a.name) }; const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
if (review.has(a.name)) return { ...a, state: 'reviewing', stateTask: review.get(a.name) };
return a; /** 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 { function monogram(name: string): string {
@ -72,55 +69,72 @@ function monogram(name: string): string {
return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?'); return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?');
} }
function statusMarkup(a: RosterAgent): string { export function renderTeamHtml(cwd: string): string {
if (a.state === 'active' && a.stateTask) { const config = loadConfig(cwd);
return `<span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(a.stateTask.id)}">${escapeHtml(a.stateTask.id)} &middot; active</a>`; 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);
} }
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>`;
} }
function node(a: RosterAgent, isArchitect = false): string { const agentCfg = config.agents ?? {};
const meta = providerMeta(a.kind);
const icon = (a.kind ? providerLogo(a.kind, 18) : '') || `<span class="mono">${monogram(a.name)}</span>`; 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 = linked?.role;
const model = linked?.model;
const modelColor = meta ? meta.color : 'var(--muted)'; const modelColor = meta ? meta.color : 'var(--muted)';
const badge = const sub = [role ? escapeHtml(role) : '', model ? `<span class="node-model" style="color:${modelColor}">${escapeHtml(model)}</span>` : '']
a.state === 'active' .filter(Boolean)
? '<span class="node-badge active">active</span>' .join(' &middot; ');
: a.state === 'reviewing' const title = n.title ?? linked?.description ?? '';
? '<span class="node-badge review">review</span>' const st = n.agent ? states.get(n.agent) : undefined;
: '<span class="node-badge"></span>'; const stateClass = st ? ` ${st.state}` : '';
const cls = `node agent-card${isArchitect ? ' architect' : ''}${a.state ? ` ${a.state}` : ''}`; const badge = st ? `<span class="node-badge ${st.state === 'active' ? 'active' : 'review'}">${st.state === 'active' ? 'active' : 'review'}</span>` : '<span class="node-badge"></span>';
return `<div class="${cls}" data-agent="${escapeHtml(a.name)}" data-role="${escapeHtml(a.role)}"> 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)} &middot; active</a>`
: `<span class="st-dot review"></span><a class="st-link review" href="/tasks/${escapeHtml(st.task.id)}">${escapeHtml(st.task.id)} &middot; in review</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"> <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" title="double-click to rename">${escapeHtml(n.label)}</span>
<span class="node-role">${escapeHtml(a.role)}${a.model ? ` &middot; <span class="node-model" style="color:${modelColor}">${escapeHtml(a.model)}</span>` : ''}</span> ${sub ? `<span class="node-role">${sub}</span>` : ''}
</span> </span>
${badge} ${badge}
${title ? `<button class="node-info-btn" type="button" aria-label="Info">i</button>` : ''}
</div> </div>
<div class="agent-status">${statusMarkup(a)}</div> ${title ? `<div class="node-info" hidden>${escapeHtml(title)}</div>` : ''}
<div class="agent-status">${status}</div>
</div>`; </div>`;
} }
export function renderTeamHtml(cwd: string): string { function renderSubtree(id: string): string {
const config = loadConfig(cwd); const n = byId.get(id);
const roster = attachState(cwd, gatherRoster(cwd)); if (!n) return '';
const kids = (childrenOf.get(id) ?? []).slice().sort((a, b) => {
const architects = roster.filter((a) => a.role === 'architect').sort((a, b) => a.name.localeCompare(b.name)); const ra = a.agent ? agentCfg[a.agent]?.role : undefined;
const workers = roster const rb = b.agent ? agentCfg[b.agent]?.role : undefined;
.filter((a) => a.role !== 'architect') return (ROLE_ORDER[ra ?? ''] ?? 5) - (ROLE_ORDER[rb ?? ''] ?? 5) || a.label.localeCompare(b.label);
.sort( });
(a, b) => const childrenHtml = kids.length
(ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || ? `<div class="children">${kids.map((k) => renderSubtree(k.id)).join('')}</div>`
(PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) || : '';
a.name.localeCompare(b.name), return `<div class="subtree">${renderNode(n)}${childrenHtml}</div>`;
); }
const root = architects.length ? architects : workers.slice(0, 1);
const rest = architects.length ? workers : workers.slice(1);
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
@ -131,43 +145,45 @@ export function renderTeamHtml(cwd: string): string {
<title>AgentHub Team</title> <title>AgentHub Team</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { overflow-x: hidden; }
.chart { position: relative; padding: 12px 8px 40px; } .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; } .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; } .subtree { position: relative; z-index: 1; display: inline-flex; flex-direction: column; align-items: center; }
.tier.arch { margin-bottom: 64px; } .children { display: flex; flex-direction: row; align-items: flex-start; justify-content: center; gap: 13px; margin-top: 40px; }
.node { .node {
position: relative; position: relative;
min-width: 214px; max-width: 264px; width: 176px;
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: 14px;
padding: 12px 14px; padding: 10px 12px;
transition: border-color 220ms ease, transform 180ms ease, box-shadow 320ms ease, background 320ms 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; } }
.node:hover { border-color: rgba(88,166,255,.5); transform: translateY(-1px); } .node:hover { border-color: rgba(88,166,255,.5); }
.node.architect { border-color: rgba(217,119,87,.5); } .node-top { display: flex; align-items: center; gap: 10px; }
.node-top { display: flex; align-items: center; gap: 11px; } .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 { .node-icon .mono { font: 800 11px/1 var(--font-mono); color: var(--pv, var(--muted)); }
width: 34px; height: 34px; flex: 0 0 auto; .node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
display: inline-grid; place-items: center; .node-name { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; outline: none; border-radius: 4px; }
border-radius: 10px; background: var(--raised); border: 1px solid var(--border); .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 { font: 10.5px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; text-transform: uppercase; letter-spacing: .04em; }
.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; } .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 { margin-left: auto; align-self: flex-start; font: 700 9px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; padding: 3px 6px; border-radius: 999px; }
.node-badge:empty { display: none; } .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.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); } .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; } .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 { 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.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); } .st-dot.review { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
@ -177,26 +193,23 @@ export function renderTeamHtml(cwd: string): string {
.st-link.review { color: var(--status-review); } .st-link.review { color: var(--status-review); }
.st-link:hover { text-decoration: underline; } .st-link:hover { text-decoration: underline; }
/* Live states + pulse-arrival pop. */
.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.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.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); } .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); }
.node.arrive { animation: arrivePop 560ms ease; } .node.arrive { animation: arrivePop 560ms ease; }
@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); } } @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); } }
/* Connectors: static faint lines that breathe subtly at rest. */
.edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; } .edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; }
@keyframes edgeBreath { 0%,100% { opacity: .38; } 50% { opacity: .7; } } @keyframes edgeBreath { 0%,100% { opacity: .35; } 50% { opacity: .62; } }
/* 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 { fill: none; stroke-width: 3.5; stroke-linecap: round; filter: url(#pulseGlow); }
.edge-pulse.down { stroke: var(--green); } .edge-pulse.down { stroke: var(--green); }
.edge-pulse.up { stroke: var(--status-review); } .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 (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } } @media (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } }
</style> </style>
</head> </head>
<body> <body>
${pageHeader(config.projectName, 'team')} ${pageHeader(config.projectName, 'team')}
<div class="scroll">
<main class="chart" id="chart"> <main class="chart" id="chart">
<svg class="edges" id="edges" aria-hidden="true"> <svg class="edges" id="edges" aria-hidden="true">
<defs> <defs>
@ -206,82 +219,87 @@ export function renderTeamHtml(cwd: string): string {
</filter> </filter>
</defs> </defs>
</svg> </svg>
<div class="tier arch" id="archTier">${root.map((a) => node(a, true)).join('')}</div> ${renderSubtree(rootId)}
<div class="tier workers" id="workerTier">${rest.map((a) => node(a)).join('')}</div>
</main> </main>
</div>
<script> <script>
(function() { (function() {
var NS = 'http://www.w3.org/2000/svg'; var NS = 'http://www.w3.org/2000/svg';
var SEG = 420, EDGE_DUR = 900;
var connDot = document.getElementById('conn-dot'); var connDot = document.getElementById('conn-dot');
var edgeMap = {}; // agent name -> { fwd, rev } path data var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent
var taskState = {}; // task id -> { status, assignedTo } var parentOf = {}; // nodeId -> parent nodeId
var baseline = false; // suppress pulses for the initial snapshot var taskState = {}, baseline = false;
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 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'); }, 560); }
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 = architectNode(); if (!svg || !chart) return;
if (!svg || !chart || !arch) return;
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 defs = svg.querySelector('defs'); svg.innerHTML = ''; if (defs) svg.appendChild(defs);
var defs = svg.querySelector('defs'); edgeMap = {}; parentOf = {};
svg.innerHTML = ''; Array.prototype.slice.call(document.querySelectorAll('.node[data-node-id]')).forEach(function(n) {
if (defs) svg.appendChild(defs); var pid = n.getAttribute('data-parent');
edgeMap = {}; if (!pid) return;
Array.prototype.slice.call(document.querySelectorAll('.tier.workers .node')).forEach(function(n) { var parent = nodeById(pid);
var end = centerTop(n, box); if (!parent) return;
var midY = start.y + (end.y - start.y) * 0.55; parentOf[n.getAttribute('data-node-id')] = pid;
var fwd = 'M ' + start.x + ' ' + start.y + ' C ' + start.x + ' ' + midY + ', ' + end.x + ' ' + midY + ', ' + end.x + ' ' + end.y; var s = centerBottom(parent, box), e = centerTop(n, box);
var rev = 'M ' + end.x + ' ' + end.y + ' C ' + end.x + ' ' + midY + ', ' + start.x + ' ' + midY + ', ' + start.x + ' ' + start.y; var midY = s.y + (e.y - s.y) * 0.5;
edgeMap[n.getAttribute('data-agent')] = { fwd: fwd, rev: rev }; var fwd = 'M ' + s.x + ' ' + s.y + ' C ' + s.x + ' ' + midY + ', ' + e.x + ' ' + midY + ', ' + e.x + ' ' + e.y;
var rev = 'M ' + e.x + ' ' + e.y + ' C ' + e.x + ' ' + midY + ', ' + s.x + ' ' + midY + ', ' + s.x + ' ' + s.y;
edgeMap[n.getAttribute('data-node-id')] = { fwd: fwd, rev: rev };
var base = document.createElementNS(NS, 'path'); var base = document.createElementNS(NS, 'path');
base.setAttribute('class', 'edge-base'); base.setAttribute('d', fwd); base.setAttribute('class', 'edge-base'); base.setAttribute('d', fwd);
svg.appendChild(base); svg.appendChild(base);
}); });
} }
// One-shot pulse along an agent's edge. dir 'down' = architect→agent function firePulseEdge(nodeId, dir) {
// (delegation, green), 'up' = agent→architect (review, amber). var e = edgeMap[nodeId]; var svg = document.getElementById('edges');
function firePulse(name, dir) {
var e = edgeMap[name];
var svg = document.getElementById('edges');
if (!e || !svg) return; if (!e || !svg) return;
var p = document.createElementNS(NS, 'path'); var p = document.createElementNS(NS, 'path');
p.setAttribute('class', 'edge-pulse ' + dir); p.setAttribute('class', 'edge-pulse ' + dir);
p.setAttribute('d', dir === 'up' ? e.rev : e.fwd); p.setAttribute('d', dir === 'up' ? e.rev : e.fwd);
svg.appendChild(p); svg.appendChild(p);
var len = p.getTotalLength(); var len = p.getTotalLength();
p.style.strokeDasharray = '22 ' + (len + 22); p.style.strokeDasharray = '20 ' + (len + 20);
p.style.strokeDashoffset = String(len + 22); p.style.strokeDashoffset = String(len + 20);
var done = function() { var done = function() {
try { p.remove(); } catch (_) {} try { p.remove(); } catch (_) {}
flash(dir === 'up' ? architectNode() : nodeOf(name)); flash(dir === 'up' ? nodeById(parentOf[nodeId]) : nodeById(nodeId));
}; };
if (p.animate) { if (p.animate) { p.animate([{ strokeDashoffset: len + 20 }, { strokeDashoffset: 0 }], { duration: EDGE_DUR, easing: 'cubic-bezier(.4,0,.5,1)' }).onfinish = done; }
var a = p.animate([{ strokeDashoffset: len + 22 }, { strokeDashoffset: 0 }], { duration: 1200, easing: 'cubic-bezier(.4,0,.5,1)' }); else setTimeout(done, EDGE_DUR);
a.onfinish = done; }
} else { setTimeout(done, 1200); }
// 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) { function applyState(card, state, task) {
card.classList.toggle('active', state === 'active'); card.classList.toggle('active', state === 'active');
card.classList.toggle('reviewing', state === 'reviewing'); card.classList.toggle('reviewing', state === 'reviewing');
var badge = card.querySelector('.node-badge'); var badge = card.querySelector('.node-badge');
if (badge) { if (badge) { badge.className = 'node-badge' + (state ? ' ' + (state === 'active' ? 'active' : 'review') : ''); badge.textContent = state === 'active' ? 'active' : state === 'reviewing' ? 'review' : ''; }
badge.className = 'node-badge' + (state ? ' ' + (state === 'active' ? 'active' : 'review') : '');
badge.textContent = state === 'active' ? 'active' : state === 'reviewing' ? 'review' : '';
}
var st = card.querySelector('.agent-status'); var st = card.querySelector('.agent-status');
if (st) { 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>'; 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>';
@ -295,24 +313,18 @@ export function renderTeamHtml(cwd: string): string {
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);
// Detect transitions → fire meaningful pulses (skip on the first snapshot).
if (baseline) { if (baseline) {
tasks.forEach(function(t) { tasks.forEach(function(t) {
var pv = taskState[t.id]; var pv = taskState[t.id]; var agent = t.assignedTo || (pv && pv.assignedTo); if (!agent) return;
var agent = t.assignedTo || (pv && pv.assignedTo);
if (!agent) return;
var was = pv ? pv.status : null; var was = pv ? pv.status : null;
if (t.status === 'in_progress' && was !== 'in_progress') firePulse(agent, 'down'); // claimed → active if (t.status === 'in_progress' && was !== 'in_progress') pulseToAgent(agent, 'down');
else if (t.status === 'review' && was !== 'review') firePulse(agent, 'up'); // submitted for review else if (t.status === 'review' && was !== 'review') pulseToAgent(agent, 'up');
else if (t.status === 'open' && pv && t.assignedTo && t.assignedTo !== pv.assignedTo) firePulse(agent, 'down'); // (re)assigned else if (t.status === 'open' && pv && t.assignedTo && t.assignedTo !== pv.assignedTo) pulseToAgent(agent, 'down');
}); });
} }
var next = {}; var next = {}; tasks.forEach(function(t) { next[t.id] = { status: t.status, assignedTo: t.assignedTo }; });
tasks.forEach(function(t) { next[t.id] = { status: t.status, assignedTo: t.assignedTo }; });
taskState = next; baseline = true; taskState = next; baseline = true;
// Live node states.
var active = {}, review = {}; var active = {}, review = {};
tasks.forEach(function(t) { tasks.forEach(function(t) {
if (!t.assignedTo) return; if (!t.assignedTo) return;
@ -327,12 +339,50 @@ export function renderTeamHtml(cwd: string): string {
}); });
} }
var rt; // Info toggle.
function relayout() { clearTimeout(rt); rt = setTimeout(layoutEdges, 120); } 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.
document.addEventListener('dblclick', function(e) {
var name = e.target.closest && e.target.closest('.node-name');
if (!name) return;
name.setAttribute('contenteditable', 'true'); name.focus();
document.getSelection().selectAllChildren(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);
}
var rt; function relayout() { clearTimeout(rt); rt = setTimeout(layoutEdges, 120); }
window.addEventListener('resize', relayout); window.addEventListener('resize', relayout);
layoutEdges(); setTimeout(layoutEdges, 350); layoutEdges(); centerScroll();
sync(); setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
setInterval(sync, 1500); sync(); 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); };