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: "…" }\`.
|
what you did with **agenthub_memory_add** \`{ title: "<id> result", content: "…" }\`.
|
||||||
3. Call **agenthub_work** again. Loop: work → implement → review → work.
|
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
|
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).
|
(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">
|
<nav class="nav" aria-label="Primary">
|
||||||
<a href="/board">Board</a>
|
<a href="/board">Board</a>
|
||||||
<a href="/team">Team</a>
|
<a href="/team">Team</a>
|
||||||
|
<a href="/activity">Activity</a>
|
||||||
<a href="/decisions">Decisions</a>
|
<a href="/decisions">Decisions</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Constraints:
|
* Constraints:
|
||||||
* - One static HTML page: inline CSS + JS, no framework and no runtime deps.
|
* - One static HTML page: inline CSS + JS, no framework and no runtime deps.
|
||||||
* - Reads only same-origin endpoints (`/tasks`, `/handoffs`, `/decisions`,
|
* - Reads only same-origin endpoints (`/tasks`, `/tasks/:id/activity`,
|
||||||
* `/tasks/:id/activity`, `/events` and `/status`).
|
* `/events` and `/status`).
|
||||||
* - Never mutates state.
|
* - 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 ACTIVE_COLUMNS = BOARD_COLUMNS.filter((c) => ['open', 'in_progress', 'review'].includes(c.key));
|
||||||
const DONE_PREVIEW_LIMIT = 8;
|
|
||||||
|
|
||||||
function columnSkeleton(): string {
|
function columnSkeleton(): string {
|
||||||
return ACTIVE_COLUMNS.map(
|
return ACTIVE_COLUMNS.map(
|
||||||
@ -201,10 +200,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.metric-card,
|
.metric-card {
|
||||||
.donut-card,
|
|
||||||
.activity-card,
|
|
||||||
.done-panel {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -248,116 +244,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
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 {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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="review"] .col-label { color: var(--review); }
|
||||||
.column[data-column="done"] .col-label { color: var(--done); }
|
.column[data-column="done"] .col-label { color: var(--done); }
|
||||||
.column[data-column="cancelled"] .col-label { color: var(--cancelled); }
|
.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; }
|
.cards { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
.card {
|
.card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -594,7 +453,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dashboard { grid-template-columns: 1fr; }
|
.dashboard { grid-template-columns: 1fr; }
|
||||||
.work-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
body { padding-left: 14px; padding-right: 14px; }
|
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; }
|
.brand-copy { flex-direction: column; gap: 0; }
|
||||||
.project-name { max-width: calc(100vw - 96px); }
|
.project-name { max-width: calc(100vw - 96px); }
|
||||||
.metrics { grid-template-columns: 1fr; }
|
.metrics { grid-template-columns: 1fr; }
|
||||||
.donut-wrap { grid-template-columns: 1fr; }
|
|
||||||
.board { grid-template-columns: 1fr; }
|
.board { grid-template-columns: 1fr; }
|
||||||
.nav { flex: 1; }
|
.nav { flex: 1; }
|
||||||
.nav-link { flex: 1; justify-content: center; }
|
.nav-link { flex: 1; justify-content: center; }
|
||||||
@ -645,6 +502,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
<nav class="nav" aria-label="Primary">
|
<nav class="nav" aria-label="Primary">
|
||||||
<a class="nav-link active" href="/board">Board</a>
|
<a class="nav-link active" href="/board">Board</a>
|
||||||
<a class="nav-link" href="/team">Team</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>
|
<a class="nav-link" href="/decisions">Decisions</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="sse-status stale" id="sseStatus">
|
<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">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 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>
|
||||||
<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>
|
||||||
|
|
||||||
<section class="work-grid" aria-label="Task board">
|
<section class="board" id="board" aria-label="Task board">
|
||||||
<div class="board" id="board">
|
|
||||||
${columnSkeleton()}
|
${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>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -708,10 +533,8 @@ ${columnSkeleton()}
|
|||||||
var TIMER_MS = 1000;
|
var TIMER_MS = 1000;
|
||||||
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
|
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
|
||||||
var ACTIVE_COLUMNS = ${JSON.stringify(ACTIVE_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 PROJECT_NAME = ${initialProjectName};
|
||||||
var lastTasks = [];
|
var lastTasks = [];
|
||||||
var lastMessages = [];
|
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
@ -813,60 +636,6 @@ ${columnSkeleton()}
|
|||||||
});
|
});
|
||||||
return counts;
|
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) {
|
function renderMetrics(tasks) {
|
||||||
var counts = statusCounts(tasks);
|
var counts = statusCounts(tasks);
|
||||||
var active = (counts.open || 0) + (counts.in_progress || 0) + (counts.review || 0);
|
var active = (counts.open || 0) + (counts.in_progress || 0) + (counts.review || 0);
|
||||||
@ -883,7 +652,6 @@ ${columnSkeleton()}
|
|||||||
setMetric('review', counts.review || 0);
|
setMetric('review', counts.review || 0);
|
||||||
setMetric('done', counts.done || 0);
|
setMetric('done', counts.done || 0);
|
||||||
setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length);
|
setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length);
|
||||||
renderDonut(counts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTimelineItems(items) {
|
function renderTimelineItems(items) {
|
||||||
@ -986,27 +754,8 @@ ${columnSkeleton()}
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
renderDonePreview(lastTasks);
|
|
||||||
updateTimers();
|
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) {
|
function handoffRoute(h) {
|
||||||
var from = '<span class="who">' + esc(h.fromRole || '?') + '</span>';
|
var from = '<span class="who">' + esc(h.fromRole || '?') + '</span>';
|
||||||
var to = '<span class="who">' + esc(h.toRole || '?') + '</span>';
|
var to = '<span class="who">' + esc(h.toRole || '?') + '</span>';
|
||||||
@ -1038,42 +787,6 @@ ${columnSkeleton()}
|
|||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).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) {
|
function setConn(state, label) {
|
||||||
var el = document.getElementById('sseStatus');
|
var el = document.getElementById('sseStatus');
|
||||||
var text = document.getElementById('sseLabel');
|
var text = document.getElementById('sseLabel');
|
||||||
@ -1096,10 +809,8 @@ ${columnSkeleton()}
|
|||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
var r = await Promise.all([getJSON('/tasks'), getJSON('/messages')]);
|
var tasks = await getJSON('/tasks');
|
||||||
lastMessages = r[1] || [];
|
renderBoard(tasks);
|
||||||
renderBoard(r[0]);
|
|
||||||
renderRecentActivity(lastTasks, lastMessages);
|
|
||||||
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setConn('down', 'offline');
|
setConn('down', 'offline');
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export function renderDecisionsHtml(cwd: string): string {
|
|||||||
<nav class="nav" aria-label="Primary">
|
<nav class="nav" aria-label="Primary">
|
||||||
<a href="/board">Board</a>
|
<a href="/board">Board</a>
|
||||||
<a href="/team">Team</a>
|
<a href="/team">Team</a>
|
||||||
|
<a href="/activity">Activity</a>
|
||||||
<a class="active" href="/decisions">Decisions</a>
|
<a class="active" href="/decisions">Decisions</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { addMemory, searchMemory, listMemory } from '../core/services/memoryServ
|
|||||||
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||||
import { loadConfig } from '../core/config.js';
|
import { loadConfig } from '../core/config.js';
|
||||||
|
import { renderActivityHtml } from './activity.js';
|
||||||
import { renderBoardHtml } from './board.js';
|
import { renderBoardHtml } from './board.js';
|
||||||
import { renderTeamHtml } from './team.js';
|
import { renderTeamHtml } from './team.js';
|
||||||
import { renderArchiveHtml } from './archive.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);
|
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 ──────────────────────────────────────────────────
|
// ─── Server-Sent Events ──────────────────────────────────────────────────
|
||||||
// GET /events?role=<role>
|
// GET /events?role=<role>
|
||||||
//
|
//
|
||||||
|
|||||||
@ -112,6 +112,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
<nav class="nav" aria-label="Primary">
|
<nav class="nav" aria-label="Primary">
|
||||||
<a href="/board">Board</a>
|
<a href="/board">Board</a>
|
||||||
<a href="/team">Team</a>
|
<a href="/team">Team</a>
|
||||||
|
<a href="/activity">Activity</a>
|
||||||
<a href="/decisions">Decisions</a>
|
<a href="/decisions">Decisions</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -127,7 +127,7 @@ function providerGroup(kind: string, agents: RosterAgent[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tierLabel(text: string): 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 {
|
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. */
|
/** 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 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) =>
|
const navItem = (label: string, path: string, active: boolean) =>
|
||||||
`<a href="${path}" style="
|
`<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;">
|
<nav style="display:flex;gap:8px;align-items:center;">
|
||||||
${navItem('Board', '/board', current === 'board')}
|
${navItem('Board', '/board', current === 'board')}
|
||||||
${navItem('Team', '/team', current === 'team')}
|
${navItem('Team', '/team', current === 'team')}
|
||||||
|
${navItem('Activity', '/activity', current === 'activity')}
|
||||||
|
${navItem('Decisions', '/decisions', current === 'decisions')}
|
||||||
</nav>
|
</nav>
|
||||||
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
|
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
|
||||||
</header>`;
|
</header>`;
|
||||||
|
|||||||
@ -206,6 +206,28 @@ describe('server routes', () => {
|
|||||||
expect(html).toContain('msToHuman');
|
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 () => {
|
it('GET /handoffs returns fromRole and toRole fields', async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user