feat(board): lean board + /activity page (TSK-0032 rework, realtime folded in)
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 <noreply@anthropic.com>
This commit is contained in:
parent
996f988f11
commit
caa009b66a
@ -55,7 +55,8 @@ claims it, and returns the task + its handoff. Then:
|
||||
what you did with **agenthub_memory_add** \`{ title: "<id> 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 <id>\` + \`agenthub memory add …\`, then \`agenthub work\` again
|
||||
(run it in the background so the wait doesn't tie up your turn).
|
||||
|
||||
|
||||
140
src/server/activity.ts
Normal file
140
src/server/activity.ts
Normal file
@ -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: `<article class="activity-row">
|
||||
<span class="kind kind-message">message</span>
|
||||
<div class="activity-main"><span class="id">${escapeHtml(m.id)}</span> ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}</div>
|
||||
<span class="state">${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : '<span class="unread-dot" aria-hidden="true"></span>unread'}</span>
|
||||
</article>`,
|
||||
})),
|
||||
...tasks.map((t) => ({
|
||||
at: t.updatedAt || t.createdAt,
|
||||
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
|
||||
<span class="kind kind-task">task</span>
|
||||
<div class="activity-main"><span class="id">${escapeHtml(t.id)}</span> ${escapeHtml(snippet(t.title))}</div>
|
||||
<span class="state">${escapeHtml(t.status ?? 'open')}</span>
|
||||
</a>`,
|
||||
})),
|
||||
].sort((a, b) => b.at.localeCompare(a.at));
|
||||
|
||||
const activity = activityRows.length
|
||||
? activityRows.slice(0, 40).map((r) => r.html).join('')
|
||||
: '<div class="empty">No activity yet.</div>';
|
||||
|
||||
const archive = 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 Activity</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,.state { 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.active,.nav a:hover { color:var(--text);background:var(--raised); }
|
||||
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:12px;align-items:start; }
|
||||
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:0; }
|
||||
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
|
||||
h1 { font-size:18px;margin:0; }
|
||||
.activity-list,.task-list { display:grid;gap:7px; }
|
||||
.activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
|
||||
.activity-row:first-child { border-top:0;padding-top:0; }
|
||||
.kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; }
|
||||
.kind-message { color:var(--accent);border-color:rgba(88,166,255,.32);background:rgba(88,166,255,.08); }
|
||||
.kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); }
|
||||
.activity-main,.title { min-width:0;overflow-wrap:anywhere; }
|
||||
.unread-dot { display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:4px; }
|
||||
.task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
|
||||
.task-row:first-child { border-top:0;padding-top:0; }
|
||||
.task-row:hover,.activity-row:hover { color:var(--text); }
|
||||
.agent { display:flex;justify-content:flex-end; }
|
||||
.empty { color:var(--muted);font-size:12px; }
|
||||
@media (max-width:860px){ main{grid-template-columns:1fr}.app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center} }
|
||||
@media (max-width:560px){ .activity-row,.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 class="active" href="/activity">Activity</a>
|
||||
<a href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h1>Recent Activity</h1><span class="meta">${activityRows.length} events</span></div>
|
||||
<div class="activity-list">${activity}</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h1>Done Archive</h1><span class="meta">${done.length} done</span></div>
|
||||
<div class="task-list">${archive}</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@ -76,6 +76,7 @@ export function renderArchiveHtml(cwd: string): string {
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<a href="/board">Board</a>
|
||||
<a href="/team">Team</a>
|
||||
<a href="/activity">Activity</a>
|
||||
<a href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@ -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 {
|
||||
<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="/activity">Activity</a>
|
||||
<a class="nav-link" href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
<span class="sse-status stale" id="sseStatus">
|
||||
@ -663,43 +521,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
<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>
|
||||
|
||||
<section class="work-grid" aria-label="Task board">
|
||||
<div class="board" id="board">
|
||||
<section class="board" id="board" aria-label="Task 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>
|
||||
</main>
|
||||
|
||||
@ -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 = '<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);
|
||||
@ -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 = '<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>';
|
||||
@ -1038,42 +787,6 @@ ${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');
|
||||
@ -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');
|
||||
|
||||
@ -94,6 +94,7 @@ export function renderDecisionsHtml(cwd: string): string {
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<a href="/board">Board</a>
|
||||
<a href="/team">Team</a>
|
||||
<a href="/activity">Activity</a>
|
||||
<a class="active" href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@ -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=<role>
|
||||
//
|
||||
|
||||
@ -112,6 +112,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<a href="/board">Board</a>
|
||||
<a href="/team">Team</a>
|
||||
<a href="/activity">Activity</a>
|
||||
<a href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@ -127,7 +127,7 @@ function providerGroup(kind: string, agents: RosterAgent[]): string {
|
||||
}
|
||||
|
||||
function tierLabel(text: string): string {
|
||||
return `<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</div>`;
|
||||
return `<div data-role="${escapeHtml(text.toLowerCase().replace(/s$/, ''))}" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</div>`;
|
||||
}
|
||||
|
||||
export function renderTeamHtml(cwd: string): string {
|
||||
|
||||
@ -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 = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><circle cx="12" cy="12" r="10" stroke="var(--accent)" stroke-width="2.5"/><circle cx="12" cy="12" r="4" fill="var(--accent)"/></svg>`;
|
||||
const navItem = (label: string, path: string, active: boolean) =>
|
||||
`<a href="${path}" style="
|
||||
@ -273,6 +273,8 @@ export function pageHeader(projectName: string, current: 'board' | 'team'): stri
|
||||
<nav style="display:flex;gap:8px;align-items:center;">
|
||||
${navItem('Board', '/board', current === 'board')}
|
||||
${navItem('Team', '/team', current === 'team')}
|
||||
${navItem('Activity', '/activity', current === 'activity')}
|
||||
${navItem('Decisions', '/decisions', current === 'decisions')}
|
||||
</nav>
|
||||
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
|
||||
</header>`;
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user