diff --git a/src/server/archive.ts b/src/server/archive.ts new file mode 100644 index 0000000..ed90600 --- /dev/null +++ b/src/server/archive.ts @@ -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) => ` + ${escapeHtml(t.id)} + ${escapeHtml(t.title)} + ${escapeHtml(totalTime(t))} + ${agentAvatar(t.assignedTo, { size: 24 })} +`, + ) + .join('') + : '
No done tasks yet.
'; + + return ` + + + + + + AgentHub Done Archive + + + +
+ AgentHub + ${escapeHtml(config.projectName)} + + +
+
+
+

Done Archive

+ ${done.length} done tasks +
+
${rows}
+
+ +`; +} diff --git a/src/server/board.ts b/src/server/board.ts index bc00951..3d46be8 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -22,8 +22,11 @@ export const BOARD_COLUMNS: BoardColumn[] = [ { 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 { - return BOARD_COLUMNS.map( + return ACTIVE_COLUMNS.map( (c) => `
${c.label} @@ -68,6 +71,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { --done: #22C55E; --cancelled: #6E7681; --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; --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.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; - 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; 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 { 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="done"] .col-label { color: var(--done); } .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; } .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 .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) { body { 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); } .panels { grid-template-columns: 1fr; } .project-name { max-width: 50vw; } @@ -402,6 +610,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .brand { width: 100%; } .brand-copy { flex-direction: column; gap: 0; } .project-name { max-width: calc(100vw - 96px); } + .metrics { grid-template-columns: 1fr; } + .donut-wrap { grid-template-columns: 1fr; } .board { grid-template-columns: 1fr; } .nav { flex: 1; } .nav-link { flex: 1; justify-content: center; } @@ -435,6 +645,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { @@ -442,26 +653,65 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
-
-${columnSkeleton()} -
+
+
+
+
Active tasks
0
+
Open
0
+
In progress
0
+
Review
0
+
Done total
0
+
Agents busy
0/0
+
+
+
+
+

Status Distribution

+ 0 total +
+
+ + + 0 + tasks + +
+
+
+
+
+

Recent Activity

+ 0 events +
+
none
+
+
+
-
-
-

Handoffs

-
none
+
+
+${columnSkeleton()} +
+
-
-

Decisions

-
none
-
-
+
diff --git a/src/server/decisions.ts b/src/server/decisions.ts new file mode 100644 index 0000000..47f6906 --- /dev/null +++ b/src/server/decisions.ts @@ -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) => `
+
+ ${escapeHtml(d.id)} + ${escapeHtml(d.status)} + ${escapeHtml(ago(d.updatedAt || d.createdAt))} +
+

${escapeHtml(d.title)}

+

${escapeHtml(d.decision)}

+ ${ + d.consequences?.length + ? `
Consequences${d.consequences.map((c) => `${escapeHtml(c)}`).join('')}
` + : '' + } +
`, + ) + .join('') + : '
No decisions yet.
'; + + return ` + + + + + + AgentHub Decisions + + + +
+ AgentHub + ${escapeHtml(config.projectName)} + + +
+
+

Decision Log

+ ${rows} +
+ +`; +} diff --git a/src/server/routes.ts b/src/server/routes.ts index eb6d3ef..1b02d14 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -10,6 +10,9 @@ import { autoDelegate, suggestDelegation } from '../core/services/delegateServic import { loadConfig } from '../core/config.js'; import { renderBoardHtml } from './board.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 type { AgentHubEvent } from './events.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 }); } +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 { // Static, self-contained Trello-like board. Polls /tasks, /handoffs and // /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); }); + app.get('/archive', async (_request, reply) => { + const archiveHtml = renderArchiveHtml(cwd); + return reply.type('text/html; charset=utf-8').send(archiveHtml); + }); + // ─── Server-Sent Events ────────────────────────────────────────────────── // GET /events?role= // @@ -136,6 +149,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise app.get('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { + if (wantsHtml(request)) { + return reply.type('text/html; charset=utf-8').send(renderTaskDetailHtml(cwd, id)); + } const { task, body } = getTask(cwd, id); return { task, body }; } catch { @@ -249,7 +265,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise }); // ─── 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) => { let decision: Decision; try { diff --git a/src/server/taskDetail.ts b/src/server/taskDetail.ts new file mode 100644 index 0000000..134da4e --- /dev/null +++ b/src/server/taskDetail.ts @@ -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) => `
+
${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)}

+
`, + ) + .join('') + : '
No handoffs for this task.
'; + + const activityRows = activity.length + ? activity + .map( + (a) => `
+ ${escapeHtml(ago(a.at))} + ${escapeHtml(a.kind)} + ${escapeHtml(a.summary)} + ${escapeHtml(a.actor)} +
`, + ) + .join('') + : '
No activity yet.
'; + + return ` + + + + + + ${escapeHtml(task.id)} · AgentHub + + + +
+ AgentHub + ${escapeHtml(config.projectName)} + + +
+
+
+
${escapeHtml(task.id)}${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}
+

${escapeHtml(task.title)}

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

Handoffs

+ ${handoffRows} +
+
+

Activity

+ ${activityRows} +
+
+ +`; +}