/** * Team org-chart page, served at `GET /team`. * * Renders `config.org` — an arbitrary-depth hierarchy (CEO → architect → * product owners → implementers, strategist → compliance, …). Falls back to a * synthesised 2-level tree (architect → workers) when no org is configured. * * The green "traveling pulse" is EVENT-DRIVEN and flows along the real tree * edges: a delegation (task → an agent) sends a pulse DOWN the path from the * architect to that agent's node, lighting each node it passes and turning the * target "active"; a review submission sends an amber pulse back UP. Node names * are editable inline (persisted via PATCH /org/:id) and each node has an info * button describing its job. Live state comes over SSE. */ import { loadConfig } from '../core/config.js'; import { listTasks } from '../core/services/taskService.js'; import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js'; import type { Config, OrgNode, Task } from '../core/schema.js'; type TaskRow = Pick; interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; } /** Per-agent live state for the initial server render (in_progress → active). */ function computeStates(cwd: string): Map { const tasks = listTasks(cwd) as TaskRow[]; const active = new Map(); const review = new Map(); for (const t of tasks) { if (!t.assignedTo) continue; if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); } else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); } } const out = new Map(); for (const [name, task] of active) out.set(name, { state: 'active', task }); for (const [name, task] of review) if (!out.has(name)) out.set(name, { state: 'reviewing', task }); return out; } const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; /** Build the org tree from config, synthesising a flat one if none is set. */ function resolveOrg(config: Config): { nodes: OrgNode[]; rootId: string } { if (config.org && config.org.length) { const root = config.org.find((n) => !n.parentId) ?? config.org[0]; return { nodes: config.org, rootId: root.id }; } // Fallback: architect at the root, everyone else below it. const roster: Array<{ name: string; role: string; kind?: string }> = config.agents ? Object.entries(config.agents).map(([name, a]) => ({ name, role: a.role, kind: a.kind })) : Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); const arch = roster.find((r) => r.role === 'architect') ?? roster[0]; const rootId = 'root:' + (arch ? arch.name : 'architect'); const nodes: OrgNode[] = []; const seen = new Set(); if (arch) { nodes.push({ id: rootId, label: arch.name, agent: arch.name, kind: arch.kind, title: 'architect' }); seen.add(rootId); } roster .filter((r) => r !== arch) .sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || a.name.localeCompare(b.name)) .forEach((r, i) => { const id = 'n:' + r.name + ':' + i; nodes.push({ id, label: r.name, agent: r.name, kind: r.kind, parentId: rootId, title: r.role }); }); return { nodes, rootId }; } function monogram(name: string): string { const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim(); return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?'); } export function renderTeamHtml(cwd: string): string { const config = loadConfig(cwd); const { nodes, rootId } = resolveOrg(config); const states = computeStates(cwd); const byId = new Map(); for (const n of nodes) byId.set(n.id, n); const childrenOf = new Map(); for (const n of nodes) { if (n.parentId) { const l = childrenOf.get(n.parentId) ?? []; l.push(n); childrenOf.set(n.parentId, l); } } const agentCfg = config.agents ?? {}; function renderNode(n: OrgNode): string { const linked = n.agent ? agentCfg[n.agent] : undefined; const kind = n.kind ?? linked?.kind; const meta = providerMeta(kind); const icon = (kind ? providerLogo(kind, 18) : '') || `${monogram(n.label)}`; const role = n.role ?? linked?.role; const model = linked?.model; const modelColor = meta ? meta.color : 'var(--muted)'; const sub = [role ? escapeHtml(role) : '', model ? `${escapeHtml(model)}` : ''] .filter(Boolean) .join(' · '); const title = n.title ?? linked?.description ?? ''; const st = n.agent ? states.get(n.agent) : undefined; const stateClass = st ? ` ${st.state}` : ''; const badge = st ? `${st.state === 'active' ? 'active' : 'review'}` : ''; const status = st ? st.state === 'active' ? `${escapeHtml(st.task.id)}` : `${escapeHtml(st.task.id)}` : `free`; return `
${icon} ${escapeHtml(n.label)} ${sub ? `${sub}` : ''}
${title ? `` : ''}
${badge} ${title ? `` : ''}
${status}
`; } function renderSubtree(id: string): string { const n = byId.get(id); if (!n) return ''; const kids = (childrenOf.get(id) ?? []).slice().sort((a, b) => { const ra = a.agent ? agentCfg[a.agent]?.role : undefined; const rb = b.agent ? agentCfg[b.agent]?.role : undefined; return (ROLE_ORDER[ra ?? ''] ?? 5) - (ROLE_ORDER[rb ?? ''] ?? 5) || a.label.localeCompare(b.label); }); // Wrap a wide row of leaf children into a compact grid so a broad subtree // doesn't spread the whole tree — keeps upper levels close together. const allLeaves = kids.every((k) => !(childrenOf.get(k.id)?.length)); const wrap = allLeaves && kids.length > 4; const cols = wrap ? Math.min(4, Math.ceil(Math.sqrt(kids.length))) : 0; const childrenHtml = kids.length ? `
${kids.map((k) => renderSubtree(k.id)).join('')}
` : ''; return `
${renderNode(n)}${childrenHtml}
`; } return ` AgentHub Team ${pageHeader(config.projectName, 'team')}
${renderSubtree(rootId)}
`; }