From caa009b66a269c360659bce35b198dbb40224dc8 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 29 Jun 2026 05:43:25 +0200 Subject: [PATCH] feat(board): lean board + /activity page (TSK-0032 rework, realtime folded in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex reworked the board per the user's "keep it simple": /board now shows only the compact KPI cards + the kanban (half-donut removed), with realtime SSE folded in (TSK-0030). Recent Activity + Done Archive moved to a new /activity page; nav is Board · Team · Activity · Decisions. team.ts / ui-shared.ts touched for nav + a tier data-role hook. Tests green (133). The /team + /activity 500s seen on review were a local better-sqlite3 ABI mismatch (native module rebuilt for Node 24), not a code bug — all pages 200 after rebuild. Co-Authored-By: Claude Opus 4.8 --- src/core/templates.ts | 3 +- src/server/activity.ts | 140 ++++++++++++++++++ src/server/archive.ts | 1 + src/server/board.ts | 303 +-------------------------------------- src/server/decisions.ts | 1 + src/server/routes.ts | 6 + src/server/taskDetail.ts | 1 + src/server/team.ts | 2 +- src/server/ui-shared.ts | 4 +- tests/server.test.ts | 22 +++ 10 files changed, 184 insertions(+), 299 deletions(-) create mode 100644 src/server/activity.ts diff --git a/src/core/templates.ts b/src/core/templates.ts index 8bf72f6..3175cfd 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -55,7 +55,8 @@ claims it, and returns the task + its handoff. Then: what you did with **agenthub_memory_add** \`{ title: " result", content: "…" }\`. 3. Call **agenthub_work** again. Loop: work → implement → review → work. -**CLI equivalent** (if MCP isn't set up): \`agenthub work --agent ${agentName} --role implementer\`, +**CLI equivalent** (if MCP isn't set up): start with \`agenthub start --agent ${agentName} --role implementer\`, +then continue with \`agenthub work --agent ${agentName} --role implementer\`, then \`agenthub task review \` + \`agenthub memory add …\`, then \`agenthub work\` again (run it in the background so the wait doesn't tie up your turn). diff --git a/src/server/activity.ts b/src/server/activity.ts new file mode 100644 index 0000000..7cd412a --- /dev/null +++ b/src/server/activity.ts @@ -0,0 +1,140 @@ +import { loadConfig } from '../core/config.js'; +import { listMessages } from '../core/services/messageService.js'; +import { listTasks } from '../core/services/taskService.js'; +import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js'; +import type { IndexEntry } from '../core/index.js'; + +function compactDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h`; + return `${Math.floor(h / 24)}d`; +} + +function ago(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return ''; + return `${compactDuration(Date.now() - t)} ago`; +} + +function totalTime(t: IndexEntry): string { + const start = Date.parse(t.createdAt); + const end = Date.parse(t.updatedAt); + if (Number.isNaN(start) || Number.isNaN(end)) return ''; + return compactDuration(end - start); +} + +function snippet(text: string, max = 120): string { + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length <= max) return clean; + return `${clean.slice(0, max - 1)}...`; +} + +export function renderActivityHtml(cwd: string): string { + const config = loadConfig(cwd); + const tasks = listTasks(cwd); + const messages = listMessages(cwd); + const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + + const activityRows = [ + ...messages.map((m) => ({ + at: m.createdAt, + html: `
+ message +
${escapeHtml(m.id)} ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}
+ ${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : 'unread'} +
`, + })), + ...tasks.map((t) => ({ + at: t.updatedAt || t.createdAt, + html: ` + task +
${escapeHtml(t.id)} ${escapeHtml(snippet(t.title))}
+ ${escapeHtml(t.status ?? 'open')} +
`, + })), + ].sort((a, b) => b.at.localeCompare(a.at)); + + const activity = activityRows.length + ? activityRows.slice(0, 40).map((r) => r.html).join('') + : '
No activity yet.
'; + + const archive = done.length + ? done + .map( + (t) => ` + ${escapeHtml(t.id)} + ${escapeHtml(t.title)} + ${escapeHtml(totalTime(t))} + ${agentAvatar(t.assignedTo, { size: 24 })} +`, + ) + .join('') + : '
No done tasks yet.
'; + + return ` + + + + + + AgentHub Activity + + + +
+ AgentHub + ${escapeHtml(config.projectName)} + + +
+
+
+

Recent Activity

${activityRows.length} events
+
${activity}
+
+
+

Done Archive

${done.length} done
+
${archive}
+
+
+ +`; +} diff --git a/src/server/archive.ts b/src/server/archive.ts index ed90600..9288c6c 100644 --- a/src/server/archive.ts +++ b/src/server/archive.ts @@ -76,6 +76,7 @@ export function renderArchiveHtml(cwd: string): string { diff --git a/src/server/board.ts b/src/server/board.ts index 3d46be8..e7c3e7c 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -3,8 +3,8 @@ * * Constraints: * - One static HTML page: inline CSS + JS, no framework and no runtime deps. - * - Reads only same-origin endpoints (`/tasks`, `/handoffs`, `/decisions`, - * `/tasks/:id/activity`, `/events` and `/status`). + * - Reads only same-origin endpoints (`/tasks`, `/tasks/:id/activity`, + * `/events` and `/status`). * - Never mutates state. */ @@ -23,7 +23,6 @@ export const BOARD_COLUMNS: BoardColumn[] = [ ]; const ACTIVE_COLUMNS = BOARD_COLUMNS.filter((c) => ['open', 'in_progress', 'review'].includes(c.key)); -const DONE_PREVIEW_LIMIT = 8; function columnSkeleton(): string { return ACTIVE_COLUMNS.map( @@ -201,10 +200,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { gap: 12px; min-width: 0; } - .metric-card, - .donut-card, - .activity-card, - .done-panel { + .metric-card { min-width: 0; background: var(--surface); border: 1px solid var(--border); @@ -248,116 +244,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border-radius: 50%; background: currentColor; } - .dashboard-side { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); - gap: 12px; - min-width: 0; - } - .donut-card, - .activity-card, - .done-panel { - padding: 12px 14px; - } - .section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; - } - .section-title { - margin: 0; - font-size: 13px; - font-weight: 700; - } - .section-link { - color: var(--accent); - font: 12px/1.4 var(--mono); - white-space: nowrap; - } - .section-link:hover { text-decoration: underline; } - .donut-wrap { - display: grid; - grid-template-columns: 170px minmax(0, 1fr); - gap: 12px; - align-items: end; - } - .donut-svg { - width: 170px; - height: 100px; - overflow: visible; - } - .donut-total { - font: 700 24px/1 var(--mono); - fill: var(--text); - text-anchor: middle; - } - .donut-label { - font: 11px/1 var(--mono); - fill: var(--muted); - text-anchor: middle; - } - .legend { - display: grid; - gap: 6px; - min-width: 0; - } - .legend-row { - display: grid; - grid-template-columns: 10px minmax(0, 1fr) auto; - gap: 7px; - align-items: center; - color: var(--muted); - font: 12px/1.3 var(--mono); - } - .legend-swatch { - width: 10px; - height: 10px; - border-radius: 3px; - } - .activity-list, - .done-list { - display: grid; - gap: 7px; - min-width: 0; - } - .activity-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 8px; - align-items: start; - padding-top: 7px; - border-top: 1px solid rgba(48, 54, 61, .65); - } - .activity-row:first-child { border-top: 0; padding-top: 0; } - .activity-kind { - font: 10px/1.4 var(--mono); - border-radius: 999px; - padding: 1px 6px; - border: 1px solid var(--border); - color: var(--muted); - white-space: nowrap; - } - .activity-kind-message { color: var(--accent); border-color: rgba(88, 166, 255, .32); background: rgba(88, 166, 255, .08); } - .activity-kind-task { color: var(--review); border-color: rgba(210, 153, 34, .32); background: rgba(210, 153, 34, .08); } - .activity-main { min-width: 0; overflow-wrap: anywhere; font-size: 12px; } - .read-state { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; } - .unread-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - margin-right: 4px; - } - .work-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 12px; - align-items: start; - margin-top: 12px; - } .board { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -400,33 +286,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .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); } - .done-summary { - color: var(--muted); - font: 12px/1.4 var(--mono); - white-space: nowrap; - } - .done-card { - display: grid; - gap: 4px; - padding: 8px 0; - border-top: 1px solid rgba(48, 54, 61, .65); - } - .done-card:first-child { border-top: 0; padding-top: 0; } - .done-title { - color: var(--text); - font-size: 12px; - font-weight: 650; - overflow-wrap: anywhere; - } - .done-meta { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - color: var(--muted); - font: 11px/1.3 var(--mono); - } - .cards { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .card { min-width: 0; @@ -594,7 +453,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { @media (max-width: 1180px) { .dashboard { grid-template-columns: 1fr; } - .work-grid { grid-template-columns: 1fr; } } @media (max-width: 860px) { body { padding-left: 14px; padding-right: 14px; } @@ -611,7 +469,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .brand-copy { flex-direction: column; gap: 0; } .project-name { max-width: calc(100vw - 96px); } .metrics { grid-template-columns: 1fr; } - .donut-wrap { grid-template-columns: 1fr; } .board { grid-template-columns: 1fr; } .nav { flex: 1; } .nav-link { flex: 1; justify-content: center; } @@ -645,6 +502,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { @@ -663,43 +521,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
Done total
0
Agents busy
0/0
-
-
-
-

Status Distribution

- 0 total -
-
- - - 0 - tasks - -
-
-
-
-
-

Recent Activity

- 0 events -
-
none
-
-
-
-
+
${columnSkeleton()} -
-
@@ -708,10 +533,8 @@ ${columnSkeleton()} var TIMER_MS = 1000; var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))}; var ACTIVE_COLUMNS = ${JSON.stringify(ACTIVE_COLUMNS.map((c) => c.key))}; - var DONE_PREVIEW_LIMIT = ${DONE_PREVIEW_LIMIT}; var PROJECT_NAME = ${initialProjectName}; var lastTasks = []; - var lastMessages = []; function esc(s) { return String(s == null ? '' : s) @@ -813,60 +636,6 @@ ${columnSkeleton()} }); return counts; } - function colorForStatus(status) { - var map = { - open: '#8B949E', - in_progress: '#58A6FF', - review: '#D29922', - done: '#22C55E', - cancelled: '#6E7681' - }; - return map[status] || '#8B949E'; - } - function describeStatus(status) { - var labels = { open: 'Open', in_progress: 'In Progress', review: 'Review', done: 'Done', cancelled: 'Cancelled' }; - return labels[status] || statusLabel(status); - } - function polar(cx, cy, r, angle) { - var rad = (angle - 180) * Math.PI / 180; - return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; - } - function arcPath(cx, cy, r, start, end) { - var s = polar(cx, cy, r, start); - var e = polar(cx, cy, r, end); - var large = end - start > 180 ? 1 : 0; - return 'M ' + s.x.toFixed(3) + ' ' + s.y.toFixed(3) + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x.toFixed(3) + ' ' + e.y.toFixed(3); - } - function renderDonut(counts) { - var order = ['open', 'in_progress', 'review', 'done']; - var total = order.reduce(function(sum, k) { return sum + (counts[k] || 0); }, 0); - var arcs = document.getElementById('donutArcs'); - var totalEl = document.getElementById('donutTotal'); - var totalText = document.getElementById('totalTasks'); - var legend = document.getElementById('donutLegend'); - if (totalEl) totalEl.textContent = String(total); - if (totalText) totalText.textContent = total + ' total'; - if (!arcs || !legend) return; - if (!total) { - arcs.innerHTML = ''; - legend.innerHTML = '
none
'; - return; - } - var angle = 0; - arcs.innerHTML = order.map(function(k) { - var value = counts[k] || 0; - if (!value) return ''; - var next = angle + (value / total) * 180; - var path = ''; - angle = next; - return path; - }).join(''); - legend.innerHTML = order.map(function(k) { - var value = counts[k] || 0; - var pct = total ? Math.round((value / total) * 100) : 0; - return '
' + esc(describeStatus(k)) + '' + value + ' / ' + pct + '%
'; - }).join(''); - } function renderMetrics(tasks) { var counts = statusCounts(tasks); var active = (counts.open || 0) + (counts.in_progress || 0) + (counts.review || 0); @@ -883,7 +652,6 @@ ${columnSkeleton()} setMetric('review', counts.review || 0); setMetric('done', counts.done || 0); setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length); - renderDonut(counts); } function renderTimelineItems(items) { @@ -986,27 +754,8 @@ ${columnSkeleton()} } }); }); - renderDonePreview(lastTasks); updateTimers(); } - function renderDonePreview(tasks) { - var done = (tasks || []) - .filter(function(t) { return t.status === 'done'; }) - .sort(function(a, b) { return String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')); }); - var summary = document.getElementById('doneSummary'); - var list = document.getElementById('donePreview'); - if (summary) summary.textContent = done.length + ' done, showing last ' + Math.min(DONE_PREVIEW_LIMIT, done.length); - if (!list) return; - if (!done.length) { list.innerHTML = '
none
'; return; } - list.innerHTML = done.slice(0, DONE_PREVIEW_LIMIT).map(function(t) { - return '' + - '' + esc(t.title) + '' + - '' + esc(t.id) + '' + - '' + esc(timerLabel('done', t.createdAt, t.updatedAt)) + '' + - (t.doneBy || t.assignedTo ? agentAvatar(t.doneBy || t.assignedTo, t.role) : '') + - ''; - }).join(''); - } function handoffRoute(h) { var from = '' + esc(h.fromRole || '?') + ''; var to = '' + esc(h.toRole || '?') + ''; @@ -1038,42 +787,6 @@ ${columnSkeleton()} ''; }).join(''); } - function snippet(text, max) { - var s = String(text || '').replace(/\\s+/g, ' ').trim(); - if (s.length <= max) return s; - return s.slice(0, max - 1) + '...'; - } - function renderRecentActivity(tasks, messages) { - var rows = []; - (messages || []).forEach(function(m) { - rows.push({ - type: 'message', - at: m.createdAt || m.updatedAt, - html: '
' + - 'message' + - '' + esc(m.id) + ' ' + esc(m.from || '?') + ' to ' + esc(m.to || '?') + ': ' + esc(snippet(m.text, 96)) + '' + - '' + (m.status === 'read' ? '✓ read by ' + esc(m.to || '?') : 'unread') + '' + - '
' - }); - }); - (tasks || []).forEach(function(t) { - rows.push({ - type: 'task', - at: t.updatedAt || t.createdAt, - html: '' + - 'task' + - '' + esc(t.id) + ' ' + esc(snippet(t.title, 104)) + '' + - '' + esc(statusLabel(t.status)) + '' + - '' - }); - }); - rows.sort(function(a, b) { return String(b.at || '').localeCompare(String(a.at || '')); }); - var el = document.getElementById('recentActivity'); - var count = document.getElementById('activityCount'); - if (count) count.textContent = rows.length + ' events'; - if (!el) return; - el.innerHTML = rows.length ? rows.slice(0, 10).map(function(r) { return r.html; }).join('') : '
none
'; - } function setConn(state, label) { var el = document.getElementById('sseStatus'); var text = document.getElementById('sseLabel'); @@ -1096,10 +809,8 @@ ${columnSkeleton()} } async function refresh() { try { - var r = await Promise.all([getJSON('/tasks'), getJSON('/messages')]); - lastMessages = r[1] || []; - renderBoard(r[0]); - renderRecentActivity(lastTasks, lastMessages); + var tasks = await getJSON('/tasks'); + renderBoard(tasks); setConn('ok', eventSourceReady ? 'connected' : 'polling'); } catch (_) { setConn('down', 'offline'); diff --git a/src/server/decisions.ts b/src/server/decisions.ts index 47f6906..97a7719 100644 --- a/src/server/decisions.ts +++ b/src/server/decisions.ts @@ -94,6 +94,7 @@ export function renderDecisionsHtml(cwd: string): string { diff --git a/src/server/routes.ts b/src/server/routes.ts index 1b02d14..09e5a6f 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -8,6 +8,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 { renderActivityHtml } from './activity.js'; import { renderBoardHtml } from './board.js'; import { renderTeamHtml } from './team.js'; import { renderArchiveHtml } from './archive.js'; @@ -48,6 +49,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise return reply.type('text/html; charset=utf-8').send(archiveHtml); }); + app.get('/activity', async (_request, reply) => { + const activityHtml = renderActivityHtml(cwd); + return reply.type('text/html; charset=utf-8').send(activityHtml); + }); + // ─── Server-Sent Events ────────────────────────────────────────────────── // GET /events?role= // diff --git a/src/server/taskDetail.ts b/src/server/taskDetail.ts index 134da4e..211da42 100644 --- a/src/server/taskDetail.ts +++ b/src/server/taskDetail.ts @@ -112,6 +112,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { diff --git a/src/server/team.ts b/src/server/team.ts index 014eef5..5fc282a 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -127,7 +127,7 @@ function providerGroup(kind: string, agents: RosterAgent[]): string { } function tierLabel(text: string): string { - return `
${escapeHtml(text)}
`; + return `
${escapeHtml(text)}
`; } export function renderTeamHtml(cwd: string): string { diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts index ec1ee36..e978b1e 100644 --- a/src/server/ui-shared.ts +++ b/src/server/ui-shared.ts @@ -240,7 +240,7 @@ export function liveTimerJs(): string { } /** Common page header markup with AgentHub mark, project name and nav. */ -export function pageHeader(projectName: string, current: 'board' | 'team'): string { +export function pageHeader(projectName: string, current: 'board' | 'team' | 'activity' | 'decisions'): string { const mark = ``; const navItem = (label: string, path: string, active: boolean) => ` ${navItem('Board', '/board', current === 'board')} ${navItem('Team', '/team', current === 'team')} + ${navItem('Activity', '/activity', current === 'activity')} + ${navItem('Decisions', '/decisions', current === 'decisions')} `; diff --git a/tests/server.test.ts b/tests/server.test.ts index 92e3dac..08d49b2 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -206,6 +206,28 @@ describe('server routes', () => { expect(html).toContain('msToHuman'); }); + it('serves the activity page with recent activity and done archive', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } }); + await app.inject({ + method: 'PATCH', + url: '/tasks/TSK-0001', + payload: { status: 'done', doneBy: 'codex' }, + }); + await app.inject({ + method: 'POST', + url: '/messages', + payload: { from: 'claude', to: 'codex', text: 'Please check the board', taskId: 'TSK-0001' }, + }); + + const res = await app.inject({ method: 'GET', url: '/activity' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.payload).toContain('Recent Activity'); + expect(res.payload).toContain('Done Archive'); + expect(res.payload).toContain('MSG-0001'); + expect(res.payload).toContain('TSK-0001'); + }); + it('GET /handoffs returns fromRole and toRole fields', async () => { await app.inject({ method: 'POST',