From d0e45230bc3e50acb589f93a41a75151cb2b8cf9 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 25 Jun 2026 23:35:28 +0200 Subject: [PATCH] feat(server): add static Trello-like task board at GET /board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/board.ts | 280 +++++++++++++++++++++++++++++++++++++++++++ src/server/routes.ts | 7 ++ tests/server.test.ts | 18 +++ 3 files changed, 305 insertions(+) create mode 100644 src/server/board.ts diff --git a/src/server/board.ts b/src/server/board.ts new file mode 100644 index 0000000..9ae1e07 --- /dev/null +++ b/src/server/board.ts @@ -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) => `
+
+ ${c.label} + 0 +
+
+
`, + ).join('\n'); +} + +export function renderBoardHtml(): string { + return ` + + + + + + AgentHub Board + + + +
+ +

AgentHub Board

+ + loading… +
+ +
+${columnSkeleton()} +
+ +
+
+

Handoffs

+
none
+
+
+

Decisions

+
none
+
+
+ + + + +`; +} diff --git a/src/server/routes.ts b/src/server/routes.ts index 8fad2fa..b8b98dc 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -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 { + // 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) })); diff --git a/tests/server.test.ts b/tests/server.test.ts index 6a71c35..634294e 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -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('AgentHub Board'); + // 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'); + }); });