feat(server): add static Trello-like task board at GET /board

Serves a single self-contained HTML page (inline CSS+JS, no build step,
no framework, no npm deps) that polls /tasks, /handoffs and /decisions on
the same origin and renders a column board by task status
(open/in_progress/review/done/cancelled). Each card shows id, title, role
and assignedTo; small handoffs + decisions panels below; auto-refresh
every 4s with a connection indicator. Purely additive and read-only — the
CLI core path is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-25 23:35:28 +02:00
parent 9d18022076
commit d0e45230bc
3 changed files with 305 additions and 0 deletions

280
src/server/board.ts Normal file
View File

@ -0,0 +1,280 @@
/**
* Self-contained Trello-like task board, served at `GET /board`.
*
* Design constraints (MVP maximum simplicity):
* - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps.
* - Reads only the existing same-origin endpoints (`/tasks`, `/handoffs`,
* `/decisions`). It never mutates state purely an observability surface.
* - Auto-refreshes on a small interval via `setInterval` + `fetch`.
*
* The column skeleton is rendered server-side so the markup (and its
* `data-column` markers) exist even before the client JS runs; JS only fills
* the cards.
*/
export interface BoardColumn {
key: string;
label: string;
}
/** Task statuses, in board order. Mirrors `TaskStatus` in core/schema.ts. */
export const BOARD_COLUMNS: BoardColumn[] = [
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'review', label: 'Review' },
{ key: 'done', label: 'Done' },
{ key: 'cancelled', label: 'Cancelled' },
];
function columnSkeleton(): string {
return BOARD_COLUMNS.map(
(c) => ` <section class="column" data-column="${c.key}">
<header class="col-head">
<span class="col-label">${c.label}</span>
<span class="col-count" data-count="${c.key}">0</span>
</header>
<div class="cards" data-cards="${c.key}"></div>
</section>`,
).join('\n');
}
export function renderBoardHtml(): string {
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 Board</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2330;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--open: #8b949e;
--in_progress: #58a6ff;
--review: #d29922;
--done: #3fb950;
--cancelled: #6e7681;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 16px 20px 32px;
}
.topbar {
display: flex; align-items: baseline; gap: 12px;
margin-bottom: 16px; flex-wrap: wrap;
}
.topbar h1 { font-size: 18px; margin: 0; font-weight: 600; }
.topbar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--done); display: inline-block; }
.topbar .dot.stale { background: var(--review); }
.topbar .dot.down { background: #f85149; }
.topbar .meta { color: var(--muted); font-size: 12px; }
.topbar .spacer { flex: 1; }
.board {
display: grid;
grid-template-columns: repeat(5, minmax(180px, 1fr));
gap: 12px;
align-items: start;
}
@media (max-width: 1100px) { .board { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .board { grid-template-columns: 1fr; } }
.column {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
min-height: 80px;
}
.col-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px; padding: 0 2px;
}
.col-label { font-weight: 600; font-size: 13px; letter-spacing: .02em; }
.col-count {
background: var(--panel-2); color: var(--muted);
border-radius: 999px; padding: 1px 8px; font-size: 12px;
}
.column[data-column="open"] .col-label { color: var(--open); }
.column[data-column="in_progress"] .col-label { color: var(--in_progress); }
.column[data-column="review"] .col-label { color: var(--review); }
.column[data-column="done"] .col-label { color: var(--done); }
.column[data-column="cancelled"] .col-label { color: var(--cancelled); }
.cards { display: flex; flex-direction: column; gap: 8px; }
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 8px 10px;
}
.column[data-column="open"] .card { border-left-color: var(--open); }
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
.column[data-column="review"] .card { border-left-color: var(--review); }
.column[data-column="done"] .card { border-left-color: var(--done); }
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
.card .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.card .title { margin: 2px 0 6px; font-weight: 500; }
.card .tags { display: flex; gap: 6px; flex-wrap: wrap; }
.badge {
font-size: 11px; border-radius: 999px; padding: 1px 8px;
background: rgba(88,166,255,.12); color: var(--accent);
border: 1px solid rgba(88,166,255,.25);
}
.badge.role { background: rgba(210,153,34,.12); color: var(--review); border-color: rgba(210,153,34,.25); }
.badge.agent { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); }
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
.panels {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
margin-top: 20px;
}
@media (max-width: 640px) { .panels { grid-template-columns: 1fr; } }
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px;
}
.panel h2 { font-size: 13px; margin: 0 0 10px; font-weight: 600; }
.row {
display: flex; gap: 8px; align-items: baseline;
padding: 6px 0; border-top: 1px solid var(--border);
}
.row:first-of-type { border-top: 0; }
.row .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
.row .what { flex: 1; }
.row .when { color: var(--muted); font-size: 11px; white-space: nowrap; }
</style>
</head>
<body>
<div class="topbar">
<span class="dot" id="conn"></span>
<h1>AgentHub Board</h1>
<span class="spacer"></span>
<span class="meta" id="meta">loading</span>
</div>
<main class="board" id="board">
${columnSkeleton()}
</main>
<div class="panels">
<section class="panel">
<h2>Handoffs</h2>
<div id="handoffs"><div class="empty">none</div></div>
</section>
<section class="panel">
<h2>Decisions</h2>
<div id="decisions"><div class="empty">none</div></div>
</section>
</div>
<script>
var REFRESH_MS = 4000;
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
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, (Date.now() - t) / 1000);
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
async function getJSON(path) {
var res = await fetch(path, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(path + ' -> ' + res.status);
return res.json();
}
function taskCard(t) {
var tags = '';
if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>';
if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>';
return '<div class="card">' +
'<div class="id">' + esc(t.id) + '</div>' +
'<div class="title">' + esc(t.title) + '</div>' +
(tags ? '<div class="tags">' + tags + '</div>' : '') +
'</div>';
}
function renderBoard(tasks) {
var byCol = {};
COLUMNS.forEach(function (k) { byCol[k] = []; });
(tasks || []).forEach(function (t) {
var k = byCol[t.status] ? t.status : 'open';
byCol[k].push(t);
});
COLUMNS.forEach(function (k) {
var list = byCol[k];
var cards = document.querySelector('[data-cards="' + k + '"]');
var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length);
if (!cards) return;
cards.innerHTML = list.length
? list.map(taskCard).join('')
: '<div class="empty">—</div>';
});
}
function renderHandoffs(items) {
var el = document.getElementById('handoffs');
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
el.innerHTML = items.slice(0, 12).map(function (h) {
return '<div class="row">' +
'<span class="id">' + esc(h.id) + '</span>' +
'<span class="what">' + esc(h.title) + '</span>' +
'<span class="when">' + esc(ago(h.createdAt)) + '</span>' +
'</div>';
}).join('');
}
function renderDecisions(items) {
var el = document.getElementById('decisions');
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
el.innerHTML = items.slice(0, 12).map(function (d) {
var st = d.status ? '<span class="badge">' + esc(d.status) + '</span>' : '';
return '<div class="row">' +
'<span class="id">' + esc(d.id) + '</span>' +
'<span class="what">' + esc(d.title) + ' ' + st + '</span>' +
'<span class="when">' + esc(ago(d.createdAt)) + '</span>' +
'</div>';
}).join('');
}
function setConn(state) {
var dot = document.getElementById('conn');
dot.className = 'dot' + (state === 'ok' ? '' : state === 'stale' ? ' stale' : ' down');
}
async function refresh() {
try {
var r = await Promise.all([getJSON('/tasks'), getJSON('/handoffs'), getJSON('/decisions')]);
renderBoard(r[0]);
renderHandoffs(r[1]);
renderDecisions(r[2]);
setConn('ok');
document.getElementById('meta').textContent =
(r[0] || []).length + ' tasks · updated ' + new Date().toLocaleTimeString();
} catch (e) {
setConn('down');
document.getElementById('meta').textContent = 'disconnected — retrying…';
}
}
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>
`;
}

View File

@ -6,6 +6,7 @@ import { addMemory, searchMemory, listMemory } from '../core/services/memoryServ
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 type { Task, Handoff, Decision, Memory } from '../core/schema.js';
function notFound(reply: FastifyReply, resource: string) {
@ -17,6 +18,12 @@ function badRequest(reply: FastifyReply, message: string) {
}
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
// /decisions on the same origin; no build step, no deps. Cached once — the
// markup is constant, only the data it fetches changes.
const boardHtml = renderBoardHtml();
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
app.get('/status', async () => ({ body: getStatus(cwd) }));
app.post('/status/update', async () => ({ body: updateStatus(cwd) }));

View File

@ -60,4 +60,22 @@ describe('server routes', () => {
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toHaveLength(1);
});
it('serves the task board as HTML via GET /board', async () => {
const res = await app.inject({ method: 'GET', url: '/board' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
const html = res.payload;
expect(html).toContain('<title>AgentHub Board</title>');
// All five status columns are present in the static markup.
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
expect(html).toContain(`data-column="${col}"`);
}
// Handoffs + decisions panels and the polling logic are wired in.
expect(html).toContain('Handoffs');
expect(html).toContain('Decisions');
expect(html).toContain("getJSON('/tasks')");
expect(html).toContain('setInterval(refresh');
});
});