diff --git a/src/server/routes.ts b/src/server/routes.ts index 67246f3..91506f4 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -8,6 +8,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { loadConfig } from '../core/config.js'; import { renderBoardHtml } from './board.js'; +import { renderTeamHtml } from './team.js'; import { eventBus, emitChange } from './events.js'; import type { AgentHubEvent } from './events.js'; import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; @@ -27,6 +28,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise const boardHtml = renderBoardHtml(); app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml)); + // Team hierarchy page: roles tree with per-agent free/busy state. + app.get('/team', async (_request, reply) => { + const teamHtml = renderTeamHtml(cwd); + return reply.type('text/html; charset=utf-8').send(teamHtml); + }); + // ─── Server-Sent Events ────────────────────────────────────────────────── // GET /events?role= // diff --git a/src/server/team.ts b/src/server/team.ts new file mode 100644 index 0000000..98cf74d --- /dev/null +++ b/src/server/team.ts @@ -0,0 +1,258 @@ +/** + * Team hierarchy page, served at `GET /team`. + * + * Renders a role tree (architect on top, implementer/tester below) with one + * card per configured agent. Free/busy state is derived from in-progress tasks. + */ + +import { loadConfig } from '../core/config.js'; +import { listTasks } from '../core/services/taskService.js'; +import { + agentAvatar, + designTokensCss, + escapeHtml, + liveTimerJs, + pageHeader, + statusPill, +} from './ui-shared.js'; +import type { Task } from '../core/schema.js'; +type TaskStatus = Task['status']; + +interface AgentView { + name: string; + role: string; + isArchitect: boolean; + busyTask?: Task; +} + +const ROLE_ORDER: Record = { + architect: 0, + implementer: 1, + reviewer: 2, + tester: 3, +}; + +function sortRoles(roles: string[]): string[] { + return [...roles].sort((a, b) => { + const oa = ROLE_ORDER[a] ?? 99; + const ob = ROLE_ORDER[b] ?? 99; + return oa - ob; + }); +} + +function gatherAgents(cwd: string): AgentView[] { + const config = loadConfig(cwd); + const byRole = new Map(); + + for (const [role, cfg] of Object.entries(config.roles)) { + const agents = byRole.get(role) ?? []; + if (!agents.includes(cfg.preferredAgent)) { + agents.push(cfg.preferredAgent); + } + byRole.set(role, agents); + } + + // Also surface agents that currently have tasks assigned, even if not in config. + const tasks = listTasks(cwd) as Array<{ + id: string; + status: TaskStatus; + assignedTo?: string; + role?: string; + }>; + for (const t of tasks) { + if (!t.assignedTo || !t.role) continue; + const agents = byRole.get(t.role) ?? []; + if (!agents.includes(t.assignedTo)) { + agents.push(t.assignedTo); + byRole.set(t.role, agents); + } + } + + const agents: AgentView[] = []; + for (const role of sortRoles(Array.from(byRole.keys()))) { + const names = byRole.get(role) ?? []; + for (const name of names.sort((a, b) => a.localeCompare(b))) { + agents.push({ name, role, isArchitect: role === 'architect' }); + } + } + + return agents; +} + +function attachBusyTasks(cwd: string, agents: AgentView[]): AgentView[] { + const tasks = listTasks(cwd) as Array<{ + id: string; + title: string; + status: TaskStatus; + assignedTo?: string; + createdAt: string; + updatedAt: string; + }>; + + const busyByAgent = new Map(); + for (const t of tasks) { + if (t.status === 'in_progress' && t.assignedTo) { + const existing = busyByAgent.get(t.assignedTo); + if (!existing || t.updatedAt > existing.updatedAt) { + busyByAgent.set(t.assignedTo, t as Task); + } + } + } + + return agents.map((a) => ({ + ...a, + busyTask: busyByAgent.get(a.name), + })); +} + +function renderAgentCard(agent: AgentView): string { + const busy = agent.busyTask; + const statusDot = busy + ? ` busy` + : ` free`; + + const busyLine = busy + ? `
+ ${statusPill('in_progress')} + claimed moments ago +
` + : `
+ ${statusDot} +
`; + + return ` +
+
+ ${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })} +
+
${escapeHtml(agent.name)}
+
${escapeHtml(agent.role)}
+
+
+ ${busyLine} +
`; +} + +function renderRoleGroup(role: string, agents: AgentView[]): string { + const isArchitect = role === 'architect'; + return ` +
+
${escapeHtml(role)}
+
+ ${agents.map(renderAgentCard).join('')} +
+
`; +} + +export function renderTeamHtml(cwd: string): string { + const config = loadConfig(cwd); + const agents = attachBusyTasks(cwd, gatherAgents(cwd)); + + const byRole = new Map(); + for (const a of agents) { + const list = byRole.get(a.role) ?? []; + list.push(a); + byRole.set(a.role, list); + } + + const orderedRoles = sortRoles(Array.from(byRole.keys())); + const architectGroup = orderedRoles.includes('architect') + ? renderRoleGroup('architect', byRole.get('architect')!) + : ''; + const workerGroups = orderedRoles + .filter((r) => r !== 'architect') + .map((r) => renderRoleGroup(r, byRole.get(r)!)) + .join(''); + + return ` + + + + + + AgentHub Team + + + + ${pageHeader(config.projectName, 'team')} + +
+ ${architectGroup} + ${workerGroups ? `
${workerGroups}
` : ''} +
+ + + +`; +} diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts new file mode 100644 index 0000000..837f603 --- /dev/null +++ b/src/server/ui-shared.ts @@ -0,0 +1,243 @@ +/** + * Shared UI primitives for AgentHub HTML pages. + * + * Constraints: + * - Dependency-free: only string/template helpers, no npm UI libs. + * - No emojis: inline SVG/CSS only. + * - Self-contained: pages import this and inline the returned CSS/JS. + */ + +import { TaskStatus as TaskStatusSchema } from '../core/schema.js'; +type TaskStatus = 'open' | 'in_progress' | 'review' | 'done' | 'cancelled'; + +/** CSS variables block matching the AgentHub dark design spec. */ +export function designTokensCss(): string { + return ` +:root { + --bg: #0F172A; + --surface: #161B22; + --raised: #1E293B; + --border: #30363D; + --text: #F8FAFC; + --muted: #94A3B8; + --accent: #58A6FF; + --green: #22C55E; + + --status-open: #8B949E; + --status-in_progress: #58A6FF; + --status-review: #D29922; + --status-done: #22C55E; + --status-cancelled: #6E7681; + + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); + color: var(--text); + font: 14px/1.5 var(--font-sans); + padding: 16px 20px 32px; +} + +@media (prefers-reduced-motion: reduce) { + * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } +} +`; +} + +const AGENT_PALETTE: Record = { + claude: { color: '#D97757', initial: 'C' }, + codex: { color: '#10A37F', initial: 'Cx' }, + kimi: { color: '#7C3AED', initial: 'K' }, + 'windows-claude': { color: '#2563EB', initial: 'W' }, + backyard: { color: '#64748B', initial: 'B' }, +}; + +function hashString(str: string): number { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (h << 5) - h + str.charCodeAt(i); + h |= 0; + } + return Math.abs(h); +} + +function deterministicColor(name: string): string { + const colors = ['#DC2626', '#EA580C', '#D97706', '#65A30D', '#0891B2', '#2563EB', '#7C3AED', '#DB2777']; + return colors[hashString(name) % colors.length]; +} + +export interface AgentAvatarOptions { + /** Render an extra ring for the architect role. */ + architectRing?: boolean; + size?: number; +} + +/** + * Render a round agent avatar chip with per-agent accent color and initials. + * No external images, no emojis. + */ +export function agentAvatar( + name: string | undefined, + options: AgentAvatarOptions = {}, +): string { + const resolvedName = name?.toLowerCase() ?? ''; + const spec = AGENT_PALETTE[resolvedName] ?? { + color: deterministicColor(resolvedName || 'unknown'), + initial: (name ?? '?').slice(0, 1).toUpperCase(), + }; + + const { architectRing = false, size = 22 } = options; + const fontSize = Math.round(size * 0.45); + const ring = architectRing + ? `box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px ${spec.color};` + : ''; + + return `${escapeHtml(spec.initial)}`; +} + +/** Inline status pill for a task status. */ +export function statusPill(status: TaskStatus): string { + const labels: Record = { + open: 'Open', + in_progress: 'In Progress', + review: 'Review', + done: 'Done', + cancelled: 'Cancelled', + }; + const colorVar = `--status-${status}`; + return `${escapeHtml(labels[status])}`; +} + +/** + * CSS snippet that injects RGB versions of status colors so statusPill can use + * rgba() backgrounds/borders. Include this once in the page