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:
parent
d00f2812f9
commit
844db1fd2b
91
src/server/archive.ts
Normal file
91
src/server/archive.ts
Normal 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>`;
|
||||||
|
}
|
||||||
@ -22,8 +22,11 @@ export const BOARD_COLUMNS: BoardColumn[] = [
|
|||||||
{ key: 'cancelled', label: 'Cancelled' },
|
{ 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 {
|
function columnSkeleton(): string {
|
||||||
return BOARD_COLUMNS.map(
|
return ACTIVE_COLUMNS.map(
|
||||||
(c) => ` <section class="column" data-column="${c.key}">
|
(c) => ` <section class="column" data-column="${c.key}">
|
||||||
<header class="col-head">
|
<header class="col-head">
|
||||||
<span class="col-label">${c.label}</span>
|
<span class="col-label">${c.label}</span>
|
||||||
@ -68,6 +71,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
--done: #22C55E;
|
--done: #22C55E;
|
||||||
--cancelled: #6E7681;
|
--cancelled: #6E7681;
|
||||||
--danger: #F85149;
|
--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;
|
--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;
|
--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.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); }
|
.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;
|
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;
|
gap: 12px;
|
||||||
align-items: start;
|
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 {
|
.column {
|
||||||
min-width: 0;
|
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="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 {
|
||||||
@ -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 .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; }
|
.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) {
|
@media (max-width: 860px) {
|
||||||
body { padding-left: 14px; padding-right: 14px; }
|
body { padding-left: 14px; padding-right: 14px; }
|
||||||
.app-header { margin-left: -14px; margin-right: -14px; 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); }
|
.board { grid-template-columns: repeat(2, 1fr); }
|
||||||
.panels { grid-template-columns: 1fr; }
|
.panels { grid-template-columns: 1fr; }
|
||||||
.project-name { max-width: 50vw; }
|
.project-name { max-width: 50vw; }
|
||||||
@ -402,6 +610,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
.brand { width: 100%; }
|
.brand { width: 100%; }
|
||||||
.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; }
|
||||||
|
.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; }
|
||||||
@ -435,6 +645,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="/decisions">Decisions</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="sse-status stale" id="sseStatus">
|
<span class="sse-status stale" id="sseStatus">
|
||||||
<span class="conn-dot" aria-hidden="true"></span>
|
<span class="conn-dot" aria-hidden="true"></span>
|
||||||
@ -442,26 +653,65 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="board" id="board">
|
<main>
|
||||||
${columnSkeleton()}
|
<section class="dashboard" aria-label="Dashboard metrics">
|
||||||
</main>
|
<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="work-grid" aria-label="Task board">
|
||||||
<section class="panel">
|
<div class="board" id="board">
|
||||||
<h2>Handoffs</h2>
|
${columnSkeleton()}
|
||||||
<div id="handoffs"><div class="empty">none</div></div>
|
</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>
|
||||||
<section class="panel">
|
</main>
|
||||||
<h2>Decisions</h2>
|
|
||||||
<div id="decisions"><div class="empty">none</div></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var REFRESH_MS = 6000;
|
var REFRESH_MS = 6000;
|
||||||
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 DONE_PREVIEW_LIMIT = ${DONE_PREVIEW_LIMIT};
|
||||||
var PROJECT_NAME = ${initialProjectName};
|
var PROJECT_NAME = ${initialProjectName};
|
||||||
|
var lastTasks = [];
|
||||||
|
var lastMessages = [];
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
@ -551,6 +801,90 @@ ${columnSkeleton()}
|
|||||||
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
|
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) {
|
function renderTimelineItems(items) {
|
||||||
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
|
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';
|
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
|
||||||
}
|
}
|
||||||
function renderBoard(tasks) {
|
function renderBoard(tasks) {
|
||||||
|
lastTasks = tasks || [];
|
||||||
|
renderMetrics(lastTasks);
|
||||||
var byCol = {};
|
var byCol = {};
|
||||||
COLUMNS.forEach(function(k) { byCol[k] = []; });
|
ACTIVE_COLUMNS.forEach(function(k) { byCol[k] = []; });
|
||||||
(tasks || []).forEach(function(t) {
|
lastTasks.forEach(function(t) {
|
||||||
byCol[byStatus(t.status)].push(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 list = byCol[k];
|
||||||
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
||||||
var count = document.querySelector('[data-count="' + k + '"]');
|
var count = document.querySelector('[data-count="' + k + '"]');
|
||||||
@ -649,14 +986,33 @@ ${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>';
|
||||||
if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole);
|
if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole);
|
||||||
if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole);
|
if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole);
|
||||||
return from + '<span class="id">to</span>' + to;
|
return from + '<span class="id">→</span>' + to;
|
||||||
}
|
}
|
||||||
function renderHandoffs(items) {
|
function renderHandoffs(items) {
|
||||||
var el = document.getElementById('handoffs');
|
var el = document.getElementById('handoffs');
|
||||||
@ -682,6 +1038,42 @@ ${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');
|
||||||
@ -704,41 +1096,54 @@ ${columnSkeleton()}
|
|||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
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]);
|
renderBoard(r[0]);
|
||||||
renderHandoffs(r[1]);
|
renderRecentActivity(lastTasks, lastMessages);
|
||||||
renderDecisions(r[2]);
|
|
||||||
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setConn('down', 'offline');
|
setConn('down', 'offline');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var eventSourceReady = false;
|
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() {
|
function connectEvents() {
|
||||||
if (!('EventSource' in window)) {
|
if (!('EventSource' in window)) {
|
||||||
setConn('stale', 'polling');
|
setConn('stale', 'polling');
|
||||||
|
startFallbackPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var source = new EventSource('/events');
|
var source = new EventSource('/events');
|
||||||
source.onopen = function() {
|
source.onopen = function() {
|
||||||
eventSourceReady = true;
|
eventSourceReady = true;
|
||||||
|
stopFallbackPoll();
|
||||||
setConn('ok', 'connected');
|
setConn('ok', 'connected');
|
||||||
};
|
};
|
||||||
source.onmessage = function() {
|
source.onmessage = function() {
|
||||||
eventSourceReady = true;
|
eventSourceReady = true;
|
||||||
|
stopFallbackPoll();
|
||||||
setConn('ok', 'connected');
|
setConn('ok', 'connected');
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
source.onerror = function() {
|
source.onerror = function() {
|
||||||
eventSourceReady = false;
|
eventSourceReady = false;
|
||||||
setConn('stale', 'reconnecting');
|
setConn('stale', 'reconnecting');
|
||||||
|
startFallbackPoll();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStatusMeta();
|
loadStatusMeta();
|
||||||
refresh();
|
refresh();
|
||||||
connectEvents();
|
connectEvents();
|
||||||
setInterval(refresh, REFRESH_MS);
|
|
||||||
setInterval(updateTimers, TIMER_MS);
|
setInterval(updateTimers, TIMER_MS);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
106
src/server/decisions.ts
Normal file
106
src/server/decisions.ts
Normal 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>`;
|
||||||
|
}
|
||||||
@ -10,6 +10,9 @@ import { autoDelegate, suggestDelegation } from '../core/services/delegateServic
|
|||||||
import { loadConfig } from '../core/config.js';
|
import { loadConfig } from '../core/config.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 { renderDecisionsHtml } from './decisions.js';
|
||||||
|
import { renderTaskDetailHtml } from './taskDetail.js';
|
||||||
import { eventBus, emitChange } from './events.js';
|
import { eventBus, emitChange } from './events.js';
|
||||||
import type { AgentHubEvent } from './events.js';
|
import type { AgentHubEvent } from './events.js';
|
||||||
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.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 });
|
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> {
|
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||||
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||||
// /decisions on the same origin; no build step, no deps. Cached once — the
|
// /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);
|
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 ──────────────────────────────────────────────────
|
// ─── Server-Sent Events ──────────────────────────────────────────────────
|
||||||
// GET /events?role=<role>
|
// GET /events?role=<role>
|
||||||
//
|
//
|
||||||
@ -136,6 +149,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
app.get('/tasks/:id', async (request, reply) => {
|
app.get('/tasks/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
try {
|
try {
|
||||||
|
if (wantsHtml(request)) {
|
||||||
|
return reply.type('text/html; charset=utf-8').send(renderTaskDetailHtml(cwd, id));
|
||||||
|
}
|
||||||
const { task, body } = getTask(cwd, id);
|
const { task, body } = getTask(cwd, id);
|
||||||
return { task, body };
|
return { task, body };
|
||||||
} catch {
|
} catch {
|
||||||
@ -249,7 +265,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Decisions ───────────────────────────────────────────────────────────
|
// ─── 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) => {
|
app.post('/decisions', async (request, reply) => {
|
||||||
let decision: Decision;
|
let decision: Decision;
|
||||||
try {
|
try {
|
||||||
|
|||||||
135
src/server/taskDetail.ts
Normal file
135
src/server/taskDetail.ts
Normal 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>`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user