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:
parent
9d18022076
commit
d0e45230bc
280
src/server/board.ts
Normal file
280
src/server/board.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@ -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) }));
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user