${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 ? `
` : ''}
`,
)
.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())}` : ''}
+
+ 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 () => {