TSK-0028: AgentHub UI foundation + /team page

- Add src/server/ui-shared.ts: design tokens, agentAvatar(), statusPill(),
  escapeHtml(), liveTimerJs(), pageHeader(). No emojis, no deps.
- Add src/server/team.ts: role hierarchy renderer (architect -> implementer/
  reviewer/tester) with per-agent free/busy state derived from in-progress tasks.
- Wire GET /team route in src/server/routes.ts.
- Add tests/team.test.ts and tests/ui-shared.test.ts.

Build: npm run build clean. Tests: 132 passed (was 122).
This commit is contained in:
chahinebrini 2026-06-29 00:24:39 +02:00
parent b5f75c6c65
commit 986639a5f3
5 changed files with 619 additions and 0 deletions

View File

@ -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=<role>
//

258
src/server/team.ts Normal file
View File

@ -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<string, number> = {
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<string, string[]>();
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<string, Task>();
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
? `<span style="width:8px;height:8px;border-radius:50%;background:var(--status-in_progress);"></span> busy`
: `<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free`;
const busyLine = busy
? `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${statusPill('in_progress')}
<a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed moments ago</a>
</div>`
: `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;">
${statusDot}
</div>`;
return `
<div class="agent-card" style="
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
min-width: 220px;
position: relative;
">
<div style="display:flex;align-items:center;gap:10px;">
${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })}
<div style="min-width:0;">
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(agent.name)}</div>
<div style="color:var(--muted);font-size:11px;text-transform:capitalize;">${escapeHtml(agent.role)}</div>
</div>
</div>
${busyLine}
</div>`;
}
function renderRoleGroup(role: string, agents: AgentView[]): string {
const isArchitect = role === 'architect';
return `
<div class="role-group" data-role="${escapeHtml(role)}" style="
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
position: relative;
">
<div class="role-label" style="
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
background: var(--bg);
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 999px;
z-index: 2;
">${escapeHtml(role)}</div>
<div class="agent-row" style="
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
position: relative;
${isArchitect ? '' : 'padding-top: 18px; border-top: 1px solid var(--border); margin-top: -11px; width: 100%;'}
">
${agents.map(renderAgentCard).join('')}
</div>
</div>`;
}
export function renderTeamHtml(cwd: string): string {
const config = loadConfig(cwd);
const agents = attachBusyTasks(cwd, gatherAgents(cwd));
const byRole = new Map<string, AgentView[]>();
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Team</title>
<style>
${designTokensCss()}
.tree {
display: flex;
flex-direction: column;
align-items: center;
gap: 36px;
padding: 8px 0 32px;
}
.worker-tier {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 36px;
width: 100%;
position: relative;
}
/* Connector line from architect tier down to worker tier */
.connector-down {
width: 1px;
height: 24px;
background: var(--border);
}
.agent-card {
transition: border-color 150ms ease, transform 150ms ease;
}
.agent-card:hover {
border-color: rgba(88, 166, 255, 0.5);
transform: translateY(-1px);
}
@media (max-width: 640px) {
.worker-tier { flex-direction: column; align-items: center; gap: 24px; }
}
</style>
</head>
<body>
${pageHeader(config.projectName, 'team')}
<main class="tree" id="tree">
${architectGroup}
${workerGroups ? `<div class="connector-down" aria-hidden="true"></div><div class="worker-tier">${workerGroups}</div>` : ''}
</main>
<script>
${liveTimerJs()}
</script>
</body>
</html>`;
}

243
src/server/ui-shared.ts Normal file
View File

@ -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<string, { color: string; initial: string }> = {
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 `<span class="agent-avatar" title="${escapeHtml(name ?? 'unknown')}" style="
display: inline-flex;
align-items: center;
justify-content: center;
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${spec.color};
color: #fff;
font-family: var(--font-mono);
font-size: ${fontSize}px;
font-weight: 700;
line-height: 1;
flex: 0 0 auto;
${ring}
">${escapeHtml(spec.initial)}</span>`;
}
/** Inline status pill for a task status. */
export function statusPill(status: TaskStatus): string {
const labels: Record<TaskStatus, string> = {
open: 'Open',
in_progress: 'In Progress',
review: 'Review',
done: 'Done',
cancelled: 'Cancelled',
};
const colorVar = `--status-${status}`;
return `<span class="status-pill" data-status="${status}" style="
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
color: var(${colorVar});
border: 1px solid var(${colorVar});
background: transparent;
"><span style="width:6px;height:6px;border-radius:50%;background:var(${colorVar});"></span>${escapeHtml(labels[status])}</span>`;
}
/**
* CSS snippet that injects RGB versions of status colors so statusPill can use
* rgba() backgrounds/borders. Include this once in the page <style>.
*/
export function statusRgbCss(): string {
return `
.status-pill[data-status="open"] { --status-rgb: 139, 148, 158; }
.status-pill[data-status="in_progress"] { --status-rgb: 88, 166, 255; }
.status-pill[data-status="review"] { --status-rgb: 210, 153, 34; }
.status-pill[data-status="done"] { --status-rgb: 34, 197, 94; }
.status-pill[data-status="cancelled"] { --status-rgb: 110, 118, 129; }
`;
}
/** Escape HTML entities in a string. */
export function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Client-side live timer snippet.
*
* Expects elements with `data-live-timer` and ISO timestamps in
* `data-timer-at`. The optional `data-timer-mode` controls the label prefix:
* - open -> "created"
* - in_progress -> "claimed"
* - review -> "review"
* - done -> "done"
*
* Updates every second.
*/
export function liveTimerJs(): string {
return `
(function() {
function formatAgo(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 update() {
document.querySelectorAll('[data-live-timer]').forEach(function(el) {
var at = el.getAttribute('data-timer-at');
var mode = el.getAttribute('data-timer-mode') || 'open';
var prefix = { open: 'created', in_progress: 'claimed', review: 'review', done: 'done', cancelled: 'cancelled' }[mode] || 'updated';
el.textContent = prefix + ' ' + formatAgo(at) + ' ago';
});
}
update();
setInterval(update, 1000);
})();
`;
}
/** Common page header markup with AgentHub mark, project name and nav. */
export function pageHeader(projectName: string, current: 'board' | 'team'): string {
const mark = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><circle cx="12" cy="12" r="10" stroke="var(--accent)" stroke-width="2.5"/><circle cx="12" cy="12" r="4" fill="var(--accent)"/></svg>`;
const navItem = (label: string, path: string, active: boolean) =>
`<a href="${path}" style="
text-decoration: none;
color: ${active ? 'var(--text)' : 'var(--muted)'};
font-weight: ${active ? '600' : '400'};
padding: 4px 8px;
border-radius: 6px;
border: 1px solid ${active ? 'var(--border)' : 'transparent'};
background: ${active ? 'var(--surface)' : 'transparent'};
">${label}</a>`;
return `
<header style="
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
">
${mark}
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
<span style="color:var(--muted);font-size:12px;">${escapeHtml(projectName)}</span>
<span style="flex:1;"></span>
<nav style="display:flex;gap:8px;align-items:center;">
${navItem('Board', '/board', current === 'board')}
${navItem('Team', '/team', current === 'team')}
</nav>
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
</header>`;
}

61
tests/team.test.ts Normal file
View File

@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
describe('GET /team', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-team-'));
init(cwd, { projectName: 'team-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('serves the team page as HTML', async () => {
const res = await app.inject({ method: 'GET', url: '/team' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
const html = res.payload;
expect(html).toContain('<title>AgentHub Team</title>');
expect(html).toContain('team-test');
expect(html).toContain('/board');
expect(html).toContain('/team');
});
it('renders configured agents by role', async () => {
const res = await app.inject({ method: 'GET', url: '/team' });
const html = res.payload;
expect(html).toContain('architect');
expect(html).toContain('implementer');
expect(html).toContain('claude');
expect(html).toContain('codex');
});
it('marks an agent as busy when they have an in-progress task', async () => {
await app.inject({
method: 'POST',
url: '/tasks',
payload: { title: 'Busy work', role: 'implementer' },
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
const res = await app.inject({ method: 'GET', url: '/team' });
const html = res.payload;
expect(html).toContain('TSK-0001');
expect(html).toContain('data-live-timer');
expect(html).toContain('claimed');
});
});

50
tests/ui-shared.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill } from '../src/server/ui-shared.js';
describe('ui-shared helpers', () => {
it('designTokensCss returns the dark palette variables', () => {
const css = designTokensCss();
expect(css).toContain('--bg: #0F172A');
expect(css).toContain('--status-in_progress: #58A6FF');
expect(css).toContain('prefers-reduced-motion');
});
it('agentAvatar renders initials without emojis', () => {
const html = agentAvatar('kimi');
expect(html).toContain('K');
expect(html).toContain('#7C3AED');
expect(html).not.toContain('emoji');
});
it('agentAvatar renders deterministic fallback for unknown agents', () => {
const html = agentAvatar('robo-agent-42');
expect(html).toContain('R');
expect(html).toContain('agent-avatar');
});
it('agentAvatar adds architect ring when requested', () => {
const html = agentAvatar('claude', { architectRing: true });
expect(html).toContain('box-shadow');
});
it('statusPill renders each known status', () => {
for (const status of ['open', 'in_progress', 'review', 'done', 'cancelled'] as const) {
const html = statusPill(status);
expect(html).toContain(`data-status="${status}"`);
expect(html).toContain('status-pill');
}
});
it('escapeHtml escapes dangerous characters', () => {
expect(escapeHtml('<script>alert("x")</script>')).toBe(
'&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;',
);
});
it('liveTimerJs contains the setInterval updater', () => {
const js = liveTimerJs();
expect(js).toContain('setInterval');
expect(js).toContain('data-live-timer');
expect(js).toContain('claimed');
});
});