feat(board): task-detail transparency — activity log + decisions & reasoning + token/duration badges; cards stay slim (TSK-0041)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-01 01:52:32 +02:00
parent caa009b66a
commit 10f3184ea2
4 changed files with 140 additions and 129 deletions

View File

@ -3,8 +3,7 @@
* *
* 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`, `/tasks/:id/activity`, * - Reads only same-origin endpoints (`/tasks`, `/events` and `/status`).
* `/events` and `/status`).
* - Never mutates state. * - Never mutates state.
*/ */
@ -385,46 +384,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
} }
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; } .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 { .panels {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -555,10 +514,6 @@ ${columnSkeleton()}
if (isNaN(t)) return ''; if (isNaN(t)) return '';
return compactDuration(Date.now() - t) + ' ago'; return compactDuration(Date.now() - t) + ' ago';
} }
function msToHuman(ms) {
if (ms < 1000) return ms + 'ms';
return compactDuration(ms);
}
async function getJSON(path) { async function getJSON(path) {
var res = await fetch(path, { headers: { accept: 'application/json' } }); var res = await fetch(path, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(path + ' -> ' + res.status); if (!res.ok) throw new Error(path + ' -> ' + res.status);
@ -654,55 +609,9 @@ ${columnSkeleton()}
setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length); setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length);
} }
function renderTimelineItems(items) {
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
return items.map(function(item) {
var meta = '';
if (item.meta) {
if (item.meta.tokens != null) {
meta += '<span class="badge tl-tokens">' + esc(item.meta.tokens) + '&thinsp;tok</span>';
}
if (item.meta.duration != null) {
meta += '<span class="badge tl-dur">' + esc(msToHuman(item.meta.duration)) + '</span>';
}
}
return '<div class="tl-row">' +
'<span class="tl-when">' + esc(ago(item.at)) + '</span>' +
'<span class="tl-kind tl-kind-' + esc(item.kind) + '">' + esc(item.kind) + '</span>' +
'<span class="tl-actor">' + esc(item.actor) + '</span>' +
'<span class="tl-summary">' + esc(item.summary) + '</span>' +
(meta ? '<span class="tl-meta">' + meta + '</span>' : '') +
'</div>';
}).join('');
}
function loadTimeline(card, id) {
var tl = card.querySelector('.timeline');
if (!tl) return;
tl.innerHTML = '<div class="tl-loading">loading...</div>';
getJSON('/tasks/' + encodeURIComponent(id) + '/activity')
.then(function(items) { tl.innerHTML = renderTimelineItems(items); })
.catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; });
}
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) { function taskCard(t) {
var status = byStatus(t.status); var status = byStatus(t.status);
return '<article class="card" tabindex="0" data-id="' + esc(t.id) + '">' + return '<a class="card" href="/tasks/' + encodeURIComponent(t.id) + '" data-id="' + esc(t.id) + '">' +
'<div class="card-top">' + '<div class="card-top">' +
'<span class="id">' + esc(t.id) + '</span>' + '<span class="id">' + esc(t.id) + '</span>' +
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + '<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
@ -715,8 +624,7 @@ ${columnSkeleton()}
esc(timerLabel(status, t.createdAt, t.updatedAt)) + esc(timerLabel(status, t.createdAt, t.updatedAt)) +
'</span>' + '</span>' +
'</div>' + '</div>' +
'<div class="timeline"></div>' + '</a>';
'</article>';
} }
function byStatus(status) { function byStatus(status) {
return COLUMNS.indexOf(status) >= 0 ? status : 'open'; return COLUMNS.indexOf(status) >= 0 ? status : 'open';
@ -736,23 +644,7 @@ ${columnSkeleton()}
var count = document.querySelector('[data-count="' + k + '"]'); var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length); if (count) count.textContent = String(list.length);
if (!cardsEl) return; 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('') : '<div class="empty">none</div>'; cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>';
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(); updateTimers();
} }

View File

@ -38,7 +38,7 @@ export function renderDecisionsHtml(cwd: string): string {
const rows = decisions.length const rows = decisions.length
? decisions ? decisions
.map( .map(
(d) => `<article class="decision"> (d) => `<article class="decision" id="${escapeHtml(d.id)}">
<div class="decision-top"> <div class="decision-top">
<span class="id">${escapeHtml(d.id)}</span> <span class="id">${escapeHtml(d.id)}</span>
<span class="status">${escapeHtml(d.status)}</span> <span class="status">${escapeHtml(d.status)}</span>

View File

@ -1,9 +1,10 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { getTask } from '../core/services/taskService.js'; import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { getDecision } from '../core/services/decisionService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js'; import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill } from './ui-shared.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 { function ago(iso: string): string {
const t = Date.parse(iso); const t = Date.parse(iso);
@ -17,6 +18,41 @@ function ago(iso: string): string {
return `${Math.floor(h / 24)}d ago`; 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(`<span class="badge badge-tokens">${escapeHtml(String(meta.tokens))}&thinsp;tok</span>`);
if (typeof meta.duration === 'number') badges.push(`<span class="badge badge-duration">${escapeHtml(msToHuman(meta.duration))}</span>`);
if (typeof meta.handoffId === 'string') badges.push(`<span class="badge">${escapeHtml(meta.handoffId)}</span>`);
if (typeof meta.memoryId === 'string') badges.push(`<span class="badge">${escapeHtml(meta.memoryId)}</span>`);
if (typeof meta.status === 'string') badges.push(`<span class="badge">${escapeHtml(meta.status)}</span>`);
if (typeof meta.by === 'string') badges.push(`<span class="badge">by ${escapeHtml(meta.by)}</span>`);
return badges.length ? `<div class="badges">${badges.join('')}</div>` : '';
}
export function renderTaskDetailHtml(cwd: string, id: string): string { export function renderTaskDetailHtml(cwd: string, id: string): string {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const { task, body } = getTask(cwd, id); 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 = [
`<span class="stat"><strong>created</strong>${escapeHtml(ago(task.createdAt))}</span>`,
`<span class="stat"><strong>updated</strong>${escapeHtml(ago(task.updatedAt))}</span>`,
`<span class="stat"><strong>duration</strong>${escapeHtml(elapsed(task.createdAt, task.status === 'done' || task.status === 'cancelled' ? task.updatedAt : undefined))}</span>`,
task.doneTokens != null ? `<span class="stat"><strong>tokens</strong>${escapeHtml(String(task.doneTokens))}</span>` : '',
task.doneDuration != null ? `<span class="stat"><strong>done time</strong>${escapeHtml(msToHuman(task.doneDuration))}</span>` : '',
].filter(Boolean).join('');
const handoffRows = handoffs.length const handoffRows = handoffs.length
? handoffs ? handoffs
.map( .map(
@ -51,20 +106,36 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<div class="item-top"><span class="id">${escapeHtml(h.id)}</span><span class="when">${escapeHtml(ago(h.createdAt))}</span></div> <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> <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> <p>${escapeHtml(h.summary)}</p>
${h.decisions?.length ? `<div class="badges">${h.decisions.map((decisionId) => `<a class="badge" href="/decisions#${escapeHtml(decisionId)}">${escapeHtml(decisionId)}</a>`).join('')}</div>` : ''}
</article>`, </article>`,
) )
.join('') .join('')
: '<div class="empty">No handoffs for this task.</div>'; : '<div class="empty">No handoffs for this task.</div>';
const decisionRows = decisions.length
? decisions
.map(
(d) => `<article class="item" id="${escapeHtml(d.id)}">
<div class="item-top"><span class="id">${escapeHtml(d.id)}</span><span class="badge decision-status">${escapeHtml(d.status)}</span><span class="when">${escapeHtml(ago(d.updatedAt || d.createdAt))}</span></div>
<h3>${escapeHtml(d.title)}</h3>
<p><strong>Decision</strong> ${escapeHtml(d.decision)}</p>
${d.context ? `<p><strong>Reasoning</strong> ${escapeHtml(d.context)}</p>` : ''}
${d.consequences?.length ? `<div class="meta-block"><strong>Consequences</strong>${d.consequences.map((c) => `<span>${escapeHtml(c)}</span>`).join('')}</div>` : ''}
${d.alternatives?.length ? `<div class="meta-block"><strong>Alternatives</strong>${d.alternatives.map((a) => `<span>${escapeHtml(a)}</span>`).join('')}</div>` : ''}
</article>`,
)
.join('')
: '<div class="empty">No linked decisions for this task.</div>';
const activityRows = activity.length const activityRows = activity.length
? activity ? activity
.map( .map(
(a) => `<div class="activity-row"> (a) => `<article class="activity-row">
<span class="when">${escapeHtml(ago(a.at))}</span> <span class="when">${escapeHtml(ago(a.at))}</span>
<span class="kind">${escapeHtml(a.kind)}</span> <span class="kind">${escapeHtml(a.kind)}</span>
<span class="summary">${escapeHtml(a.summary)}</span> <div class="summary">${escapeHtml(a.summary)}${metaBadges(a.meta)}</div>
<span class="actor">${escapeHtml(a.actor)}</span> <span class="actor">${escapeHtml(a.actor)}</span>
</div>`, </article>`,
) )
.join('') .join('')
: '<div class="empty">No activity yet.</div>'; : '<div class="empty">No activity yet.</div>';
@ -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; } .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; } h1 { font-size:20px;line-height:1.25;margin:0 0 10px;overflow-wrap:anywhere; }
h2 { font-size:14px;margin:0 0 10px; } 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); } 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; } .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 { border-top:1px solid var(--border);padding:10px 0; }
.item:first-of-type { border-top:0;padding-top: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 { 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; } .activity-row:first-child { border-top:0;padding-top:0; }
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); } .kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
@ -120,12 +202,17 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<section class="hero"> <section class="hero">
<div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div> <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> <h1>${escapeHtml(task.title)}</h1>
<div class="stats">${taskStats}</div>
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''} ${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
</section> </section>
<section class="panel"> <section class="panel">
<h2>Handoffs</h2> <h2>Handoffs</h2>
${handoffRows} ${handoffRows}
</section> </section>
<section class="panel">
<h2>Decisions &amp; Reasoning</h2>
${decisionRows}
</section>
<section class="panel"> <section class="panel">
<h2>Activity</h2> <h2>Activity</h2>
${activityRows} ${activityRows}

View File

@ -189,21 +189,53 @@ describe('server routes', () => {
expect(html).toContain('who-cell'); 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 res = await app.inject({ method: 'GET', url: '/board' });
const html = res.payload; 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'); expect(html).toContain('data-id');
// The timeline div must be present inside cards expect(html).toContain('href="/tasks/');
expect(html).toContain('class="timeline"'); expect(html).not.toContain('class="timeline"');
// The activity fetch must call the /activity endpoint expect(html).not.toContain('expanded');
expect(html).toContain('/activity'); expect(html).not.toContain("'/tasks/' + encodeURIComponent(id) + '/activity'");
// The expansion toggle CSS class must exist });
expect(html).toContain('expanded');
// Token and duration badges must be defined it('task detail HTML shows activity metadata and linked decisions with reasoning', async () => {
expect(html).toContain('tl-tokens'); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Detail transparency', role: 'implementer' } });
expect(html).toContain('tl-dur'); await app.inject({
expect(html).toContain('msToHuman'); 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 &amp; Reasoning');
expect(res.payload).toContain('Keep cards slim');
expect(res.payload).toContain('Transparency belongs on the task detail page.');
expect(res.payload).toContain('3210&thinsp;tok');
expect(res.payload).toContain('1m');
}); });
it('serves the activity page with recent activity and done archive', async () => { it('serves the activity page with recent activity and done archive', async () => {