feat(board): dashboard — KPI cards + half-donut + activity + /decisions + /archive (TSK-0032)

codex built the dashboard redesign (TSK-0032): KPI metric cards, half-donut status
distribution, recent-activity feed, /decisions page, /archive page, task-detail
handoffs. Architect follow-up: the KPI cards were oversized (stretched to the side
panel height) — reworked to a compact full-width top row (auto-fit, fixed ~84px
height, label over value, small status-colored icon) per the user's reference.

Committed together to lock TSK-0032 in before TSK-0030 (realtime) touches the same files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-29 05:05:02 +02:00
parent d00f2812f9
commit 844db1fd2b
5 changed files with 784 additions and 26 deletions

91
src/server/archive.ts Normal file
View File

@ -0,0 +1,91 @@
import { loadConfig } from '../core/config.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 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);
}
export function renderArchiveHtml(cwd: string): string {
const config = loadConfig(cwd);
const done = listTasks(cwd)
.filter((t) => t.status === 'done')
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
const rows = done.length
? done
.map(
(t) => `<a class="task-row" href="/tasks/${encodeURIComponent(t.id)}">
<span class="id">${escapeHtml(t.id)}</span>
<span class="title">${escapeHtml(t.title)}</span>
<span class="meta">${escapeHtml(totalTime(t))}</span>
<span class="agent">${agentAvatar(t.assignedTo, { size: 24 })}</span>
</a>`,
)
.join('')
: '<div class="empty">No done tasks yet.</div>';
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 Done Archive</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project,.id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:1040px;margin:0 auto; }
.head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
h1 { font-size:18px;margin:0; }
.task-list { display:grid;gap:7px; }
.task-row { display:grid;grid-template-columns:88px minmax(0,1fr) 90px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px; }
.task-row:hover { border-color:rgba(88,166,255,.5); }
.title { min-width:0;overflow-wrap:anywhere;font-weight:600; }
.agent { display:flex;justify-content:flex-end; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
<main>
<div class="head">
<h1>Done Archive</h1>
<span class="meta">${done.length} done tasks</span>
</div>
<div class="task-list">${rows}</div>
</main>
</body>
</html>`;
}

View File

@ -22,8 +22,11 @@ export const BOARD_COLUMNS: BoardColumn[] = [
{ key: 'cancelled', label: 'Cancelled' },
];
const ACTIVE_COLUMNS = BOARD_COLUMNS.filter((c) => ['open', 'in_progress', 'review'].includes(c.key));
const DONE_PREVIEW_LIMIT = 8;
function columnSkeleton(): string {
return BOARD_COLUMNS.map(
return ACTIVE_COLUMNS.map(
(c) => ` <section class="column" data-column="${c.key}">
<header class="col-head">
<span class="col-label">${c.label}</span>
@ -68,6 +71,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
--done: #22C55E;
--cancelled: #6E7681;
--danger: #F85149;
--status-open: var(--open);
--status-in_progress: var(--in_progress);
--status-review: var(--review);
--status-done: var(--done);
--status-cancelled: var(--cancelled);
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@ -181,11 +189,181 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.sse-status.stale .conn-dot { background: var(--review); box-shadow: 0 0 0 3px rgba(210, 153, 34, .14); }
.sse-status.down .conn-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248, 81, 73, .14); }
.board {
.dashboard {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
gap: 12px;
min-width: 0;
}
.metric-card,
.donut-card,
.activity-card,
.done-panel {
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.metric-card {
position: relative;
padding: 14px 16px;
min-height: 84px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.metric-label {
color: var(--muted);
font-size: 12.5px;
overflow-wrap: anywhere;
}
.metric-value {
font: 700 28px/1 var(--mono);
letter-spacing: 0;
}
.metric-icon {
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
width: 34px;
height: 34px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, currentColor 16%, transparent);
}
.metric-icon::after {
content: '';
width: 12px;
height: 12px;
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));
gap: 12px;
align-items: start;
min-width: 0;
}
.column {
min-width: 0;
@ -222,6 +400,32 @@ 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 {
@ -388,10 +592,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.row .who { display: flex; align-items: center; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; }
.row .when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; }
@media (max-width: 1180px) { .board { grid-template-columns: repeat(3, 1fr); } }
@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; }
.app-header { margin-left: -14px; margin-right: -14px; padding-left: 14px; padding-right: 14px; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.board { grid-template-columns: repeat(2, 1fr); }
.panels { grid-template-columns: 1fr; }
.project-name { max-width: 50vw; }
@ -402,6 +610,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.brand { width: 100%; }
.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; }
@ -435,6 +645,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
<nav class="nav" aria-label="Primary">
<a class="nav-link active" href="/board">Board</a>
<a class="nav-link" href="/team">Team</a>
<a class="nav-link" href="/decisions">Decisions</a>
</nav>
<span class="sse-status stale" id="sseStatus">
<span class="conn-dot" aria-hidden="true"></span>
@ -442,26 +653,65 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
</span>
</header>
<main class="board" id="board">
${columnSkeleton()}
</main>
<main>
<section class="dashboard" aria-label="Dashboard metrics">
<div class="metrics" id="metrics">
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" style="color:var(--accent)"></span></div>
<div class="metric-card"><div class="metric-label">Open</div><div class="metric-value" data-metric="open">0</div><span class="metric-icon" style="color:var(--status-open)"></span></div>
<div class="metric-card"><div class="metric-label">In progress</div><div class="metric-value" data-metric="in_progress">0</div><span class="metric-icon" style="color:var(--status-in_progress)"></span></div>
<div class="metric-card"><div class="metric-label">Review</div><div class="metric-value" data-metric="review">0</div><span class="metric-icon" style="color:var(--status-review)"></span></div>
<div class="metric-card"><div class="metric-label">Done total</div><div class="metric-value" data-metric="done">0</div><span class="metric-icon" style="color:var(--status-done)"></span></div>
<div class="metric-card"><div class="metric-label">Agents busy</div><div class="metric-value" data-metric="agents">0/0</div><span class="metric-icon" style="color:var(--green)"></span></div>
</div>
<div class="dashboard-side">
<section class="donut-card">
<div class="section-head">
<h2 class="section-title">Status Distribution</h2>
<span class="done-summary" id="totalTasks">0 total</span>
</div>
<div class="donut-wrap">
<svg class="donut-svg" viewBox="0 0 220 120" role="img" aria-label="Task status distribution">
<g id="donutArcs"></g>
<text class="donut-total" x="110" y="82" id="donutTotal">0</text>
<text class="donut-label" x="110" y="100">tasks</text>
</svg>
<div class="legend" id="donutLegend"></div>
</div>
</section>
<section class="activity-card">
<div class="section-head">
<h2 class="section-title">Recent Activity</h2>
<span class="done-summary" id="activityCount">0 events</span>
</div>
<div class="activity-list" id="recentActivity"><div class="empty">none</div></div>
</section>
</div>
</section>
<div class="panels">
<section class="panel">
<h2>Handoffs</h2>
<div id="handoffs"><div class="empty">none</div></div>
<section class="work-grid" aria-label="Task board">
<div class="board" id="board">
${columnSkeleton()}
</div>
<aside class="done-panel">
<div class="section-head">
<h2 class="section-title">Done Archive</h2>
<a class="section-link" href="/archive">all anzeigen</a>
</div>
<div class="done-summary" id="doneSummary">0 done</div>
<div class="done-list" id="donePreview"><div class="empty">none</div></div>
</aside>
</section>
<section class="panel">
<h2>Decisions</h2>
<div id="decisions"><div class="empty">none</div></div>
</section>
</div>
</main>
<script>
var REFRESH_MS = 6000;
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)
@ -551,6 +801,90 @@ ${columnSkeleton()}
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
});
}
function setMetric(name, value) {
var el = document.querySelector('[data-metric="' + name + '"]');
if (el) el.textContent = String(value);
}
function statusCounts(tasks) {
var counts = { open: 0, in_progress: 0, review: 0, done: 0, cancelled: 0 };
(tasks || []).forEach(function(t) {
var key = byStatus(t.status);
counts[key] = (counts[key] || 0) + 1;
});
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 = '<path d="' + arcPath(110, 104, 86, 0, 180) + '" stroke="rgba(148,163,184,.22)" stroke-width="18" stroke-linecap="round" fill="none"/>';
legend.innerHTML = '<div class="empty">none</div>';
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 = '<path d="' + arcPath(110, 104, 86, angle, next) + '" stroke="' + colorForStatus(k) + '" stroke-width="18" stroke-linecap="butt" fill="none"/>';
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 '<div class="legend-row"><span class="legend-swatch" style="background:' + colorForStatus(k) + '"></span><span>' + esc(describeStatus(k)) + '</span><span>' + value + ' / ' + pct + '%</span></div>';
}).join('');
}
function renderMetrics(tasks) {
var counts = statusCounts(tasks);
var active = (counts.open || 0) + (counts.in_progress || 0) + (counts.review || 0);
var agents = {};
var busy = {};
(tasks || []).forEach(function(t) {
if (!t.assignedTo) return;
agents[t.assignedTo] = true;
if (t.status === 'in_progress') busy[t.assignedTo] = true;
});
setMetric('active', active);
setMetric('open', counts.open || 0);
setMetric('in_progress', counts.in_progress || 0);
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) {
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
@ -620,12 +954,15 @@ ${columnSkeleton()}
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
}
function renderBoard(tasks) {
lastTasks = tasks || [];
renderMetrics(lastTasks);
var byCol = {};
COLUMNS.forEach(function(k) { byCol[k] = []; });
(tasks || []).forEach(function(t) {
byCol[byStatus(t.status)].push(t);
ACTIVE_COLUMNS.forEach(function(k) { byCol[k] = []; });
lastTasks.forEach(function(t) {
var key = byStatus(t.status);
if (byCol[key]) byCol[key].push(t);
});
COLUMNS.forEach(function(k) {
ACTIVE_COLUMNS.forEach(function(k) {
var list = byCol[k];
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
var count = document.querySelector('[data-count="' + k + '"]');
@ -649,14 +986,33 @@ ${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 = '<div class="empty">none</div>'; return; }
list.innerHTML = done.slice(0, DONE_PREVIEW_LIMIT).map(function(t) {
return '<a class="done-card" href="/tasks/' + encodeURIComponent(t.id) + '">' +
'<span class="done-title">' + esc(t.title) + '</span>' +
'<span class="done-meta"><span class="id">' + esc(t.id) + '</span>' +
'<span>' + esc(timerLabel('done', t.createdAt, t.updatedAt)) + '</span>' +
(t.doneBy || t.assignedTo ? agentAvatar(t.doneBy || t.assignedTo, t.role) : '') +
'</span></a>';
}).join('');
}
function handoffRoute(h) {
var from = '<span class="who">' + esc(h.fromRole || '?') + '</span>';
var to = '<span class="who">' + esc(h.toRole || '?') + '</span>';
if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole);
if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole);
return from + '<span class="id">to</span>' + to;
return from + '<span class="id">&rarr;</span>' + to;
}
function renderHandoffs(items) {
var el = document.getElementById('handoffs');
@ -682,6 +1038,42 @@ ${columnSkeleton()}
'</div>';
}).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: '<div class="activity-row">' +
'<span class="activity-kind activity-kind-message">message</span>' +
'<span class="activity-main"><span class="id">' + esc(m.id) + '</span> ' + esc(m.from || '?') + ' to ' + esc(m.to || '?') + ': ' + esc(snippet(m.text, 96)) + '</span>' +
'<span class="read-state">' + (m.status === 'read' ? '✓ read by ' + esc(m.to || '?') : '<span class="unread-dot" aria-hidden="true"></span>unread') + '</span>' +
'</div>'
});
});
(tasks || []).forEach(function(t) {
rows.push({
type: 'task',
at: t.updatedAt || t.createdAt,
html: '<a class="activity-row" href="/tasks/' + encodeURIComponent(t.id) + '">' +
'<span class="activity-kind activity-kind-task">task</span>' +
'<span class="activity-main"><span class="id">' + esc(t.id) + '</span> ' + esc(snippet(t.title, 104)) + '</span>' +
'<span class="read-state">' + esc(statusLabel(t.status)) + '</span>' +
'</a>'
});
});
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('') : '<div class="empty">none</div>';
}
function setConn(state, label) {
var el = document.getElementById('sseStatus');
var text = document.getElementById('sseLabel');
@ -704,41 +1096,54 @@ ${columnSkeleton()}
}
async function refresh() {
try {
var r = await Promise.all([getJSON('/tasks'), getJSON('/handoffs'), getJSON('/decisions')]);
var r = await Promise.all([getJSON('/tasks'), getJSON('/messages')]);
lastMessages = r[1] || [];
renderBoard(r[0]);
renderHandoffs(r[1]);
renderDecisions(r[2]);
renderRecentActivity(lastTasks, lastMessages);
setConn('ok', eventSourceReady ? 'connected' : 'polling');
} catch (_) {
setConn('down', 'offline');
}
}
var eventSourceReady = false;
var fallbackPollTimer = null;
function startFallbackPoll() {
if (fallbackPollTimer) return;
fallbackPollTimer = setInterval(refresh, REFRESH_MS);
}
function stopFallbackPoll() {
if (!fallbackPollTimer) return;
clearInterval(fallbackPollTimer);
fallbackPollTimer = null;
}
function connectEvents() {
if (!('EventSource' in window)) {
setConn('stale', 'polling');
startFallbackPoll();
return;
}
var source = new EventSource('/events');
source.onopen = function() {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
};
source.onmessage = function() {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
refresh();
};
source.onerror = function() {
eventSourceReady = false;
setConn('stale', 'reconnecting');
startFallbackPoll();
};
}
loadStatusMeta();
refresh();
connectEvents();
setInterval(refresh, REFRESH_MS);
setInterval(updateTimers, TIMER_MS);
</script>
</body>

106
src/server/decisions.ts Normal file
View File

@ -0,0 +1,106 @@
import { loadConfig } from '../core/config.js';
import { getDecision, listDecisions } from '../core/services/decisionService.js';
import { designTokensCss, escapeHtml } from './ui-shared.js';
import type { Decision } from '../core/schema.js';
function ago(iso: string): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function renderDecisionsHtml(cwd: string): string {
const config = loadConfig(cwd);
const decisions: Decision[] = listDecisions(cwd).map((entry) => {
try {
return getDecision(cwd, entry.id).decision;
} catch {
return {
id: entry.id,
title: entry.title,
status: 'accepted',
context: '',
decision: entry.content,
consequences: [],
alternatives: [],
relatedDecisions: [],
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
};
}
});
const rows = decisions.length
? decisions
.map(
(d) => `<article class="decision">
<div class="decision-top">
<span class="id">${escapeHtml(d.id)}</span>
<span class="status">${escapeHtml(d.status)}</span>
<span class="when">${escapeHtml(ago(d.updatedAt || d.createdAt))}</span>
</div>
<h2>${escapeHtml(d.title)}</h2>
<p>${escapeHtml(d.decision)}</p>
${
d.consequences?.length
? `<div class="meta"><strong>Consequences</strong>${d.consequences.map((c) => `<span>${escapeHtml(c)}</span>`).join('')}</div>`
: ''
}
</article>`,
)
.join('')
: '<div class="empty">No decisions yet.</div>';
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 Decisions</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project { color:var(--muted);font:12px/1.4 var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a.active,.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:980px;margin:0 auto;display:grid;gap:10px; }
.decision { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.decision-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
.id,.when { color:var(--muted);font:12px/1.4 var(--font-mono); }
.status { color:var(--status-done);border:1px solid var(--status-done);border-radius:999px;padding:1px 8px;font:11px/1.4 var(--font-mono); }
h1 { font-size:18px;margin:0 0 10px; }
h2 { font-size:15px;margin:0 0 8px; }
p { margin:0;color:var(--text);overflow-wrap:anywhere; }
.meta { margin-top:10px;display:grid;gap:5px;color:var(--muted);font-size:12px; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.project{width:100%} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a class="active" href="/decisions">Decisions</a>
</nav>
</header>
<main>
<h1>Decision Log</h1>
${rows}
</main>
</body>
</html>`;
}

View File

@ -10,6 +10,9 @@ import { autoDelegate, suggestDelegation } from '../core/services/delegateServic
import { loadConfig } from '../core/config.js';
import { renderBoardHtml } from './board.js';
import { renderTeamHtml } from './team.js';
import { renderArchiveHtml } from './archive.js';
import { renderDecisionsHtml } from './decisions.js';
import { renderTaskDetailHtml } from './taskDetail.js';
import { eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js';
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
@ -22,6 +25,11 @@ function badRequest(reply: FastifyReply, message: string) {
return reply.status(400).send({ error: message });
}
function wantsHtml(request: { headers: { accept?: string } }): boolean {
const accept = request.headers.accept ?? '';
return accept.includes('text/html') && !accept.includes('application/json');
}
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
@ -35,6 +43,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return reply.type('text/html; charset=utf-8').send(teamHtml);
});
app.get('/archive', async (_request, reply) => {
const archiveHtml = renderArchiveHtml(cwd);
return reply.type('text/html; charset=utf-8').send(archiveHtml);
});
// ─── Server-Sent Events ──────────────────────────────────────────────────
// GET /events?role=<role>
//
@ -136,6 +149,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.get('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
if (wantsHtml(request)) {
return reply.type('text/html; charset=utf-8').send(renderTaskDetailHtml(cwd, id));
}
const { task, body } = getTask(cwd, id);
return { task, body };
} catch {
@ -249,7 +265,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
});
// ─── Decisions ───────────────────────────────────────────────────────────
app.get('/decisions', async () => listDecisions(cwd));
app.get('/decisions', async (request, reply) => {
if (wantsHtml(request)) {
return reply.type('text/html; charset=utf-8').send(renderDecisionsHtml(cwd));
}
return listDecisions(cwd);
});
app.post('/decisions', async (request, reply) => {
let decision: Decision;
try {

135
src/server/taskDetail.ts Normal file
View File

@ -0,0 +1,135 @@
import { loadConfig } from '../core/config.js';
import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill } from './ui-shared.js';
import type { Handoff } from '../core/schema.js';
function ago(iso: string): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function renderTaskDetailHtml(cwd: string, id: string): string {
const config = loadConfig(cwd);
const { task, body } = getTask(cwd, id);
const activity = getTaskActivity(cwd, id);
const handoffs: Handoff[] = listHandoffs(cwd)
.filter((h) => h.taskId === id)
.map((entry) => {
try {
return getHandoff(cwd, entry.id).handoff;
} catch {
return {
id: entry.id,
fromRole: entry.fromRole ?? '?',
toRole: entry.toRole ?? '?',
fromAgent: entry.fromAgent,
toAgent: entry.toAgent,
taskId: entry.taskId,
summary: entry.title,
context: entry.content,
decisions: [],
openQuestions: [],
nextSteps: [],
createdAt: entry.createdAt,
};
}
});
const handoffRows = handoffs.length
? handoffs
.map(
(h) => `<article class="item">
<div class="item-top"><span class="id">${escapeHtml(h.id)}</span><span class="when">${escapeHtml(ago(h.createdAt))}</span></div>
<div class="route">${escapeHtml(h.fromRole)} ${h.fromAgent ? agentAvatar(h.fromAgent, { size: 22 }) : ''}<span>to</span>${escapeHtml(h.toRole)} ${h.toAgent ? agentAvatar(h.toAgent, { size: 22 }) : ''}</div>
<p>${escapeHtml(h.summary)}</p>
</article>`,
)
.join('')
: '<div class="empty">No handoffs for this task.</div>';
const activityRows = activity.length
? activity
.map(
(a) => `<div class="activity-row">
<span class="when">${escapeHtml(ago(a.at))}</span>
<span class="kind">${escapeHtml(a.kind)}</span>
<span class="summary">${escapeHtml(a.summary)}</span>
<span class="actor">${escapeHtml(a.actor)}</span>
</div>`,
)
.join('')
: '<div class="empty">No activity yet.</div>';
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>${escapeHtml(task.id)} · AgentHub</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project,.id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:1040px;margin:0 auto;display:grid;gap:12px; }
.hero,.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.topline,.item-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
h1 { font-size:20px;line-height:1.25;margin:0 0 10px;overflow-wrap:anywhere; }
h2 { font-size:14px;margin:0 0 10px; }
pre { margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font:12px/1.5 var(--font-mono); }
.route { display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:var(--muted);font-size:12px;margin-bottom:6px; }
.item { border-top:1px solid var(--border);padding:10px 0; }
.item:first-of-type { border-top:0;padding-top:0; }
.item p { margin:0;overflow-wrap:anywhere; }
.activity-row { display:grid;grid-template-columns:80px 82px minmax(0,1fr) auto;gap:8px;align-items:start;border-top:1px solid var(--border);padding:8px 0; }
.activity-row:first-child { border-top:0;padding-top:0; }
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
.summary { min-width:0;overflow-wrap:anywhere; }
.empty { color:var(--muted); }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
<main>
<section class="hero">
<div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div>
<h1>${escapeHtml(task.title)}</h1>
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
</section>
<section class="panel">
<h2>Handoffs</h2>
${handoffRows}
</section>
<section class="panel">
<h2>Activity</h2>
${activityRows}
</section>
</main>
</body>
</html>`;
}