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:
parent
caa009b66a
commit
10f3184ea2
@ -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 '<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) + ' 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) {
|
||||
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">' +
|
||||
'<span class="id">' + esc(t.id) + '</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)) +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="timeline"></div>' +
|
||||
'</article>';
|
||||
'</a>';
|
||||
}
|
||||
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('') : '<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();
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ export function renderDecisionsHtml(cwd: string): string {
|
||||
const rows = decisions.length
|
||||
? decisions
|
||||
.map(
|
||||
(d) => `<article class="decision">
|
||||
(d) => `<article class="decision" id="${escapeHtml(d.id)}">
|
||||
<div class="decision-top">
|
||||
<span class="id">${escapeHtml(d.id)}</span>
|
||||
<span class="status">${escapeHtml(d.status)}</span>
|
||||
|
||||
@ -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(`<span class="badge badge-tokens">${escapeHtml(String(meta.tokens))} 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 {
|
||||
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 = [
|
||||
`<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
|
||||
? handoffs
|
||||
.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="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>
|
||||
${h.decisions?.length ? `<div class="badges">${h.decisions.map((decisionId) => `<a class="badge" href="/decisions#${escapeHtml(decisionId)}">${escapeHtml(decisionId)}</a>`).join('')}</div>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<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
|
||||
? activity
|
||||
.map(
|
||||
(a) => `<div class="activity-row">
|
||||
(a) => `<article 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>
|
||||
<div class="summary">${escapeHtml(a.summary)}${metaBadges(a.meta)}</div>
|
||||
<span class="actor">${escapeHtml(a.actor)}</span>
|
||||
</div>`,
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<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; }
|
||||
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 {
|
||||
<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>
|
||||
<div class="stats">${taskStats}</div>
|
||||
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Handoffs</h2>
|
||||
${handoffRows}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Decisions & Reasoning</h2>
|
||||
${decisionRows}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Activity</h2>
|
||||
${activityRows}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user