diff --git a/src/server/team.ts b/src/server/team.ts index a5ed60b..b4cc040 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -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; interface RosterAgent { @@ -39,7 +28,6 @@ interface RosterAgent { const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; const PROVIDER_ORDER: Record = { 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 ``; - } - return `
free
`; +/** 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 - ? `${providerLogo(a.kind, 19)}` - : agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 }); - - const model = a.model - ? `${escapeHtml(a.model)}` - : ''; - - return ` -
-
- ${avatar} -
-
${escapeHtml(a.name)}
- ${model} + const logo = a.kind ? providerLogo(a.kind, 18) : ''; + const icon = logo || `${monogram(a.name)}`; + const busy = !!a.busyTask; + const status = busy + ? `${escapeHtml(a.busyTask!.id)} · claimed …` + : `free`; + const modelColor = meta ? meta.color : 'var(--muted)'; + return `
+
+ ${icon} + + ${escapeHtml(a.name)} + ${escapeHtml(a.role)}${a.model ? ` · ${escapeHtml(a.model)}` : ''} +
-
- ${a.description ? `
${escapeHtml(a.description)}
` : ''} - ${statusLine(a)} -
`; -} - -/** 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 ` -
-
- ${providerLogo(kind, 18)} - ${meta ? escapeHtml(meta.name) : escapeHtml(kind)} - - ${busy}/${agents.length} busy -
- ${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')} -
`; -} - -function tierLabel(text: string): string { - return `
${escapeHtml(text)}
`; +
${status}
+
`; } export function renderTeamHtml(cwd: string): string { const config = loadConfig(cwd); const roster = attachBusy(cwd, gatherRoster(cwd)); - const byRole = new Map(); - 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(); - 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 - ? `
${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}
${tierLabel('Architect')}
` - : ''; - - const implementerTier = providerKeys.length - ? `
${tierLabel('Implementers')}
${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}
` - : ''; - - const testerTier = testers.length - ? `
${tierLabel('Testers')}
${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}
` - : ''; - - const connector = ''; - 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 ` @@ -180,104 +119,143 @@ export function renderTeamHtml(cwd: string): string { ${pageHeader(config.projectName, 'team')} -
- ${tiers} +
+ +
${archRow}
+
${workerRow}