diff --git a/src/server/board.ts b/src/server/board.ts index e7c3e7c..c5aa771 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -3,8 +3,7 @@ * * Constraints: * - One static HTML page: inline CSS + JS, no framework and no runtime deps. - * - Reads only same-origin endpoints (`/tasks`, `/tasks/:id/activity`, - * `/events` and `/status`). + * - Reads only same-origin endpoints (`/tasks`, `/events` and `/status`). * - Never mutates state. */ @@ -385,46 +384,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { } .empty { color: var(--muted); font-size: 12px; padding: 4px 2px; } - .timeline { - display: none; - min-width: 0; - max-width: 100%; - overflow: hidden; - margin-top: 9px; - padding-top: 8px; - border-top: 1px solid var(--border); - } - .card.expanded .timeline { display: block; } - .tl-row { - display: grid; - grid-template-columns: auto auto minmax(0, 1fr); - align-items: start; - gap: 6px; - padding: 4px 0; - border-top: 1px solid rgba(48, 54, 61, .5); - font-size: 12px; - min-width: 0; - } - .tl-row:first-child { border-top: 0; } - .tl-when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; min-width: 52px; } - .tl-kind { - font: 10px/1.3 var(--mono); - border-radius: 999px; - padding: 1px 6px; - border: 1px solid transparent; - white-space: nowrap; - } - .tl-kind-created { background: rgba(48, 54, 61, .6); color: var(--muted); border-color: var(--border); } - .tl-kind-handoff { background: rgba(88, 166, 255, .12); color: var(--accent); border-color: rgba(88, 166, 255, .25); } - .tl-kind-result { background: rgba(34, 197, 94, .12); color: var(--done); border-color: rgba(34, 197, 94, .25); } - .tl-kind-status { background: rgba(210, 153, 34, .12); color: var(--review); border-color: rgba(210, 153, 34, .25); } - .tl-actor { color: var(--muted); font: 11px/1.4 var(--mono); min-width: 0; overflow-wrap: anywhere; } - .tl-summary { grid-column: 1 / -1; min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; white-space: normal; } - .tl-meta { grid-column: 1 / -1; display: flex; gap: 4px; flex-wrap: wrap; min-width: 0; } - .badge.tl-tokens { color: #A371F7; border-color: rgba(163, 113, 247, .25); background: rgba(163, 113, 247, .12); } - .badge.tl-dur { color: #FFA657; border-color: rgba(255, 166, 87, .25); background: rgba(255, 166, 87, .12); } - .tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; } - .panels { display: grid; grid-template-columns: 1fr 1fr; @@ -555,10 +514,6 @@ ${columnSkeleton()} if (isNaN(t)) return ''; return compactDuration(Date.now() - t) + ' ago'; } - function msToHuman(ms) { - if (ms < 1000) return ms + 'ms'; - return compactDuration(ms); - } async function getJSON(path) { var res = await fetch(path, { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(path + ' -> ' + res.status); @@ -654,55 +609,9 @@ ${columnSkeleton()} setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length); } - function renderTimelineItems(items) { - if (!items || !items.length) return '
no activity yet
'; - return items.map(function(item) { - var meta = ''; - if (item.meta) { - if (item.meta.tokens != null) { - meta += '' + esc(item.meta.tokens) + ' tok'; - } - if (item.meta.duration != null) { - meta += '' + esc(msToHuman(item.meta.duration)) + ''; - } - } - return '
' + - '' + esc(ago(item.at)) + '' + - '' + esc(item.kind) + '' + - '' + esc(item.actor) + '' + - '' + esc(item.summary) + '' + - (meta ? '' + meta + '' : '') + - '
'; - }).join(''); - } - function loadTimeline(card, id) { - var tl = card.querySelector('.timeline'); - if (!tl) return; - tl.innerHTML = '
loading...
'; - getJSON('/tasks/' + encodeURIComponent(id) + '/activity') - .then(function(items) { tl.innerHTML = renderTimelineItems(items); }) - .catch(function() { tl.innerHTML = '
could not load activity
'; }); - } - - document.getElementById('board').addEventListener('click', function(e) { - var card = e.target.closest('.card[data-id]'); - if (!card) return; - var id = card.dataset.id; - var wasExpanded = card.classList.contains('expanded'); - card.classList.toggle('expanded'); - if (!wasExpanded) loadTimeline(card, id); - }); - document.getElementById('board').addEventListener('keydown', function(e) { - if (e.key !== 'Enter' && e.key !== ' ') return; - var card = e.target.closest('.card[data-id]'); - if (!card) return; - e.preventDefault(); - card.click(); - }); - function taskCard(t) { var status = byStatus(t.status); - return '
' + + return '' + '
' + '' + esc(t.id) + '' + '' + esc(statusLabel(status)) + '' + @@ -715,8 +624,7 @@ ${columnSkeleton()} esc(timerLabel(status, t.createdAt, t.updatedAt)) + '' + '
' + - '
' + - '
'; + ''; } function byStatus(status) { return COLUMNS.indexOf(status) >= 0 ? status : 'open'; @@ -736,23 +644,7 @@ ${columnSkeleton()} var count = document.querySelector('[data-count="' + k + '"]'); if (count) count.textContent = String(list.length); if (!cardsEl) return; - var expanded = {}; - cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) { - var timeline = c.querySelector('.timeline'); - expanded[c.dataset.id] = timeline ? timeline.innerHTML : ''; - }); cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '
none
'; - Object.keys(expanded).forEach(function(id) { - var card = cardsEl.querySelector('.card[data-id="' + id + '"]'); - if (!card) return; - card.classList.add('expanded'); - var timeline = card.querySelector('.timeline'); - if (timeline && expanded[id]) { - timeline.innerHTML = expanded[id]; - } else { - loadTimeline(card, id); - } - }); }); updateTimers(); } diff --git a/src/server/decisions.ts b/src/server/decisions.ts index 97a7719..97131a0 100644 --- a/src/server/decisions.ts +++ b/src/server/decisions.ts @@ -38,7 +38,7 @@ export function renderDecisionsHtml(cwd: string): string { const rows = decisions.length ? decisions .map( - (d) => `
+ (d) => `
${escapeHtml(d.id)} ${escapeHtml(d.status)} diff --git a/src/server/taskDetail.ts b/src/server/taskDetail.ts index 211da42..85942a4 100644 --- a/src/server/taskDetail.ts +++ b/src/server/taskDetail.ts @@ -1,9 +1,10 @@ import { loadConfig } from '../core/config.js'; import { getTask } from '../core/services/taskService.js'; import { getTaskActivity } from '../core/services/activityService.js'; +import { getDecision } from '../core/services/decisionService.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'; +import type { ActivityItem, Decision, Handoff } from '../core/schema.js'; function ago(iso: string): string { const t = Date.parse(iso); @@ -17,6 +18,41 @@ function ago(iso: string): string { return `${Math.floor(h / 24)}d ago`; } +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 elapsed(startIso: string, endIso?: string): string { + const start = Date.parse(startIso); + const end = Date.parse(endIso ?? new Date().toISOString()); + if (Number.isNaN(start) || Number.isNaN(end)) return ''; + return compactDuration(end - start); +} + +function msToHuman(value: unknown): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return ''; + if (value < 1000) return `${value}ms`; + return compactDuration(value); +} + +function metaBadges(meta: ActivityItem['meta']): string { + if (!meta) return ''; + const badges: string[] = []; + if (typeof meta.tokens === 'number') badges.push(`${escapeHtml(String(meta.tokens))} tok`); + if (typeof meta.duration === 'number') badges.push(`${escapeHtml(msToHuman(meta.duration))}`); + if (typeof meta.handoffId === 'string') badges.push(`${escapeHtml(meta.handoffId)}`); + if (typeof meta.memoryId === 'string') badges.push(`${escapeHtml(meta.memoryId)}`); + if (typeof meta.status === 'string') badges.push(`${escapeHtml(meta.status)}`); + if (typeof meta.by === 'string') badges.push(`by ${escapeHtml(meta.by)}`); + return badges.length ? `
${badges.join('')}
` : ''; +} + export function renderTaskDetailHtml(cwd: string, id: string): string { const config = loadConfig(cwd); const { task, body } = getTask(cwd, id); @@ -44,6 +80,25 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { } }); + const decisionIds = Array.from(new Set(handoffs.flatMap((h) => h.decisions ?? []))); + const decisions: Decision[] = decisionIds + .map((decisionId) => { + try { + return getDecision(cwd, decisionId).decision; + } catch { + return undefined; + } + }) + .filter((d): d is Decision => Boolean(d)); + + const taskStats = [ + `created${escapeHtml(ago(task.createdAt))}`, + `updated${escapeHtml(ago(task.updatedAt))}`, + `duration${escapeHtml(elapsed(task.createdAt, task.status === 'done' || task.status === 'cancelled' ? task.updatedAt : undefined))}`, + task.doneTokens != null ? `tokens${escapeHtml(String(task.doneTokens))}` : '', + task.doneDuration != null ? `done time${escapeHtml(msToHuman(task.doneDuration))}` : '', + ].filter(Boolean).join(''); + const handoffRows = handoffs.length ? handoffs .map( @@ -51,20 +106,36 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
${escapeHtml(h.id)}${escapeHtml(ago(h.createdAt))}
${escapeHtml(h.fromRole)} ${h.fromAgent ? agentAvatar(h.fromAgent, { size: 22 }) : ''}to${escapeHtml(h.toRole)} ${h.toAgent ? agentAvatar(h.toAgent, { size: 22 }) : ''}

${escapeHtml(h.summary)}

+ ${h.decisions?.length ? `
${h.decisions.map((decisionId) => `${escapeHtml(decisionId)}`).join('')}
` : ''}
`, ) .join('') : '
No handoffs for this task.
'; + const decisionRows = decisions.length + ? decisions + .map( + (d) => `
+
${escapeHtml(d.id)}${escapeHtml(d.status)}${escapeHtml(ago(d.updatedAt || d.createdAt))}
+

${escapeHtml(d.title)}

+

Decision ${escapeHtml(d.decision)}

+ ${d.context ? `

Reasoning ${escapeHtml(d.context)}

` : ''} + ${d.consequences?.length ? `
Consequences${d.consequences.map((c) => `${escapeHtml(c)}`).join('')}
` : ''} + ${d.alternatives?.length ? `
Alternatives${d.alternatives.map((a) => `${escapeHtml(a)}`).join('')}
` : ''} +
`, + ) + .join('') + : '
No linked decisions for this task.
'; + const activityRows = activity.length ? activity .map( - (a) => `
+ (a) => `
${escapeHtml(ago(a.at))} ${escapeHtml(a.kind)} - ${escapeHtml(a.summary)} +
${escapeHtml(a.summary)}${metaBadges(a.meta)}
${escapeHtml(a.actor)} -
`, +
`, ) .join('') : '
No activity yet.
'; @@ -91,11 +162,22 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { .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; } + h3 { font-size:13px;margin:0 0 8px;overflow-wrap:anywhere; } pre { margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font:12px/1.5 var(--font-mono); } + .stats { display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0; } + .stat,.badge { display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(148,163,184,.25);background:rgba(148,163,184,.08);border-radius:999px;padding:2px 8px;color:var(--muted);font:11px/1.4 var(--font-mono);text-decoration:none; } + .stat strong { color:var(--text);font-weight:650; } + .badge-tokens { color:#A371F7;border-color:rgba(163,113,247,.28);background:rgba(163,113,247,.12); } + .badge-duration { color:#FFA657;border-color:rgba(255,166,87,.28);background:rgba(255,166,87,.12); } + .decision-status { color:var(--status-done);border-color:rgba(34,197,94,.32);background:rgba(34,197,94,.10); } .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; } + .item p { margin:0 0 8px;overflow-wrap:anywhere; } + .item p:last-child { margin-bottom:0; } + .badges { display:flex;gap:5px;flex-wrap:wrap;margin-top:6px; } + .meta-block { display:grid;gap:4px;margin-top:8px;color:var(--muted);font-size:12px; } + .meta-block strong { color:var(--text); } .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); } @@ -120,12 +202,17 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
${escapeHtml(task.id)}${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}

${escapeHtml(task.title)}

+
${taskStats}
${body.trim() ? `
${escapeHtml(body.trim())}
` : ''}

Handoffs

${handoffRows}
+
+

Decisions & Reasoning

+ ${decisionRows} +

Activity

${activityRows} diff --git a/tests/server.test.ts b/tests/server.test.ts index 08d49b2..60783d9 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -189,21 +189,53 @@ describe('server routes', () => { expect(html).toContain('who-cell'); }); - it('board HTML contains activity timeline expansion logic', async () => { + it('board HTML keeps cards slim and links to task detail pages', async () => { const res = await app.inject({ method: 'GET', url: '/board' }); const html = res.payload; - // Cards must be clickable and carry a data-id attribute + // Cards carry data-id for realtime refresh bookkeeping and link to detail pages. expect(html).toContain('data-id'); - // The timeline div must be present inside cards - expect(html).toContain('class="timeline"'); - // The activity fetch must call the /activity endpoint - expect(html).toContain('/activity'); - // The expansion toggle CSS class must exist - expect(html).toContain('expanded'); - // Token and duration badges must be defined - expect(html).toContain('tl-tokens'); - expect(html).toContain('tl-dur'); - expect(html).toContain('msToHuman'); + expect(html).toContain('href="/tasks/'); + expect(html).not.toContain('class="timeline"'); + expect(html).not.toContain('expanded'); + expect(html).not.toContain("'/tasks/' + encodeURIComponent(id) + '/activity'"); + }); + + it('task detail HTML shows activity metadata and linked decisions with reasoning', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Detail transparency', role: 'implementer' } }); + await app.inject({ + method: 'POST', + url: '/decisions', + payload: { + title: 'Keep cards slim', + context: 'Transparency belongs on the task detail page.', + decision: 'Task cards link to detail pages instead of expanding inline.', + }, + }); + await app.inject({ + method: 'POST', + url: '/handoffs', + payload: { + fromRole: 'architect', + toRole: 'implementer', + taskId: 'TSK-0001', + summary: 'Implement detail view', + decisions: ['DEC-0001'], + }, + }); + await app.inject({ + method: 'PATCH', + url: '/tasks/TSK-0001', + payload: { status: 'done', doneTokens: 3210, doneDuration: 65000, doneBy: 'codex' }, + }); + + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.payload).toContain('Decisions & Reasoning'); + expect(res.payload).toContain('Keep cards slim'); + expect(res.payload).toContain('Transparency belongs on the task detail page.'); + expect(res.payload).toContain('3210 tok'); + expect(res.payload).toContain('1m'); }); it('serves the activity page with recent activity and done archive', async () => {