feat(board): v3 kanban + animated Lottie KPI icons

Board-v3 (TSK-0099, codex, architect-reviewed 7/7):
- kanban columns fill viewport height, scroll inside the column (no page scroll)
- padding/margin between task cards
- bar-chart HTML-escape bug fixed (est marker inserted raw, not esc())
- half-donut center text centered
- task detail opens in a modal (title/description/activity, empty sections hidden)
  instead of navigating to /tasks
- header-jitter on page switch fixed via scrollbar-gutter: stable
- SSE connection-leak/freeze fixed (EventSource closed on pagehide/beforeunload)

Animated KPI icons (architect):
- vendored lottie-web player + Lottie card icons under assets/, served via a
  path-traversal-guarded /card-assets/* route (offline-safe, no CDN)
- state-driven: loop-while-active (open/in_progress/review), pulse-on-change
  (active/done/agents); light KPI card bg so the black-line icons read clearly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QMN96FH9rJDY2bZnEBdPfA
This commit is contained in:
chahinebrini 2026-07-09 00:09:55 +02:00
parent 1749f1c58c
commit 2fb9db15b1
13 changed files with 351 additions and 36 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
assets/vendor/lottie.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -78,12 +78,17 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
html, body { margin: 0; min-height: 100%; height: 100%; scrollbar-gutter: stable; }
html { overflow: hidden; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 var(--sans);
padding: 96px 20px 32px;
height: 100vh;
overflow: hidden;
padding: 82px 20px 16px;
display: flex;
flex-direction: column;
}
button, a, .card { cursor: pointer; }
a { color: inherit; text-decoration: none; }
@ -174,6 +179,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 116px;
min-height: 34px;
color: var(--muted);
font: 12px/1 var(--mono);
@ -194,6 +200,13 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
flex: 0 0 auto;
}
main {
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.metrics {
display: grid;
@ -245,17 +258,34 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
border-radius: 50%;
background: currentColor;
}
/* Animated Lottie icon replaces the plain dot when a card has data-anim. */
.metric-icon[data-anim] {
width: 44px;
height: 44px;
right: 12px;
background: transparent;
}
.metric-icon[data-anim]::after { display: none; }
.metric-icon[data-anim] svg,
.metric-icon[data-anim] canvas { width: 100% !important; height: 100% !important; display: block; }
/* Light KPI cards so the black-line animated icons read clearly. */
.metric-card { background: #eef1f6; border-color: #d7dce6; }
.metric-card .metric-label { color: #5b6472; }
.metric-card .metric-value { color: #0b1220; }
.workbench {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: 14px;
align-items: stretch;
min-height: 0;
flex: 1 1 auto;
}
.board-area {
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.board {
display: grid;
@ -263,7 +293,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
gap: 12px;
align-items: stretch;
min-width: 0;
height: clamp(420px, calc(100vh - 210px), 760px);
min-height: 0;
height: 100%;
}
.column {
min-width: 0;
@ -306,13 +337,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
gap: 8px;
gap: 12px;
min-width: 0;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 2px;
padding: 2px 4px 2px 2px;
align-items: start;
scrollbar-gutter: stable;
}
.card {
min-width: 0;
@ -565,6 +597,22 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.modal-danger:hover { background: rgba(248,81,73,.26); }
.modal-danger:active { transform: translateY(1px); }
/* ── Task detail modal ──────────────────────────────────────────────── */
.detail-modal { width: min(760px, 100%); max-height: min(78vh, 760px); display: flex; flex-direction: column; }
.detail-body { min-height: 0; overflow-y: auto; padding: 4px 18px 18px; display: grid; gap: 12px; }
.detail-topline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.detail-title { margin: 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
.detail-section { border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 8px; }
.detail-section h3 { margin: 0; color: var(--muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
.detail-description { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); font: 12.5px/1.55 var(--mono); }
.detail-activity { display: grid; gap: 0; }
.detail-activity-row { display: grid; grid-template-columns: 78px 82px minmax(0, 1fr) auto; gap: 8px; padding: 8px 0; border-top: 1px solid rgba(48,54,61,.72); align-items: start; }
.detail-activity-row:first-child { border-top: 0; padding-top: 0; }
.detail-when, .detail-actor { color: var(--muted); font: 11px/1.45 var(--mono); }
.detail-kind { color: var(--accent); font: 11px/1.45 var(--mono); }
.detail-summary { min-width: 0; overflow-wrap: anywhere; }
.detail-error { color: #fca5a5; }
/* ── In-card live agent console ─────────────────────────────────────── */
.card-console-wrap { margin-top: 9px; }
.card:has(.card-console:not([hidden])) {
@ -618,6 +666,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
position: sticky;
top: 82px;
min-width: 0;
max-height: 100%;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
@ -683,8 +733,15 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
padding: 0 8px;
}
.donut-tabs button.active { background: var(--raised); color: var(--text); }
.half-donut { position: relative; min-height: 132px; display: grid; place-items: center; }
.half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; }
.half-donut {
position: relative;
width: min(220px, 100%);
aspect-ratio: 220 / 132;
display: grid;
place-items: center;
margin: 0 auto;
}
.half-donut svg { grid-area: 1 / 1; width: 100%; height: 100%; overflow: visible; }
.donut-segment {
animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both;
}
@ -693,11 +750,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
to { stroke-dashoffset: 0; }
}
.donut-center {
grid-area: 1 / 1;
position: absolute;
left: 0;
right: 0;
top: 58%;
transform: translateY(-50%);
left: 50%;
top: 66.7%;
width: min(180px, 82%);
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
@ -767,10 +825,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
}
@media (max-width: 920px) {
.cards { grid-template-columns: 1fr; }
.board { height: auto; }
.column { max-height: 64vh; }
.board { height: 100%; }
.column { max-height: none; }
.cards { grid-template-columns: 1fr; }
.card { min-height: 142px; }
.budget { max-height: 34vh; }
.detail-activity-row { grid-template-columns: 1fr; gap: 3px; }
}
@media (prefers-reduced-motion: reduce) {
@ -815,12 +875,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
<main>
<section class="dashboard" aria-label="Dashboard metrics">
<div class="metrics" id="metrics">
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" style="color:var(--accent)"></span></div>
<div class="metric-card"><div class="metric-label">Open</div><div class="metric-value" data-metric="open">0</div><span class="metric-icon" style="color:var(--status-open)"></span></div>
<div class="metric-card"><div class="metric-label">In progress</div><div class="metric-value" data-metric="in_progress">0</div><span class="metric-icon" style="color:var(--status-in_progress)"></span></div>
<div class="metric-card"><div class="metric-label">Review</div><div class="metric-value" data-metric="review">0</div><span class="metric-icon" style="color:var(--status-review)"></span></div>
<div class="metric-card"><div class="metric-label">Done total</div><div class="metric-value" data-metric="done">0</div><span class="metric-icon" style="color:var(--status-done)"></span></div>
<div class="metric-card"><div class="metric-label">Agents busy</div><div class="metric-value" data-metric="agents">0/0</div><span class="metric-icon" style="color:var(--green)"></span></div>
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" data-anim="all_tasks" style="color:var(--accent)"></span></div>
<div class="metric-card"><div class="metric-label">Open</div><div class="metric-value" data-metric="open">0</div><span class="metric-icon" data-anim="open_tasks" style="color:var(--status-open)"></span></div>
<div class="metric-card"><div class="metric-label">In progress</div><div class="metric-value" data-metric="in_progress">0</div><span class="metric-icon" data-anim="in_progress_tasks" style="color:var(--status-in_progress)"></span></div>
<div class="metric-card"><div class="metric-label">Review</div><div class="metric-value" data-metric="review">0</div><span class="metric-icon" data-anim="in_review_tasks" style="color:var(--status-review)"></span></div>
<div class="metric-card"><div class="metric-label">Done total</div><div class="metric-value" data-metric="done">0</div><span class="metric-icon" data-anim="done_tasks" style="color:var(--status-done)"></span></div>
<div class="metric-card"><div class="metric-label">Agents busy</div><div class="metric-value" data-metric="agents">0/0</div><span class="metric-icon" data-anim="agents" style="color:var(--green)"></span></div>
</div>
</section>
@ -898,6 +958,16 @@ ${columnSkeleton()}
</div>
</div>
<div class="modal-backdrop" id="detailModal" hidden>
<div class="modal detail-modal" role="dialog" aria-modal="true" aria-labelledby="dtHeading">
<div class="modal-head">
<h2 id="dtHeading">Task detail</h2>
<button class="modal-x" id="dtClose" type="button" aria-label="Close">&times;</button>
</div>
<div class="detail-body" id="dtBody"></div>
</div>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script>
@ -908,6 +978,7 @@ ${columnSkeleton()}
var PROJECT_NAME = ${initialProjectName};
var lastTasks = [];
var openConsoles = {}; // taskId -> true when its in-card console is expanded
var CONN_CACHE_KEY = 'agenthub-connection-state';
function esc(s) {
return String(s == null ? '' : s)
@ -933,6 +1004,9 @@ ${columnSkeleton()}
if (!res.ok) throw new Error(path + ' -> ' + res.status);
return res.json();
}
function taskPath(id) {
return '/tasks/' + encodeURIComponent(id);
}
function statusLabel(status) {
return String(status || 'open').replace(/_/g, ' ');
}
@ -1000,6 +1074,7 @@ ${columnSkeleton()}
function setMetric(name, value) {
var el = document.querySelector('[data-metric="' + name + '"]');
if (el) el.textContent = String(value);
if (window.__setMetricIcon) window.__setMetricIcon(name, value);
}
function statusCounts(tasks) {
var counts = { open: 0, in_progress: 0, review: 0, done: 0, cancelled: 0 };
@ -1109,9 +1184,59 @@ ${columnSkeleton()}
body.innerHTML = entries.map(consoleLineHtml).join('');
body.scrollTop = body.scrollHeight;
}
function detailActivityHtml(a) {
var when = '';
try { when = a.at ? ago(a.at) : ''; } catch (_) {}
return '<article class="detail-activity-row">' +
'<span class="detail-when">' + esc(when) + '</span>' +
'<span class="detail-kind">' + esc(a.kind || '') + '</span>' +
'<div class="detail-summary">' + esc(a.summary || '') + '</div>' +
'<span class="detail-actor">' + esc(a.actor || '') + '</span>' +
'</article>';
}
function renderTaskDetailModal(id, detail, activity) {
var body = document.getElementById('dtBody');
var heading = document.getElementById('dtHeading');
if (!body) return;
var task = detail && detail.task ? detail.task : {};
var desc = String(detail && detail.body ? detail.body : '').trim();
if (heading) heading.textContent = task.id ? task.id : 'Task detail';
var status = byStatus(task.status);
var sections = [
'<div class="detail-topline"><span class="id">' + esc(task.id || id) + '</span><span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + (task.assignedTo ? agentAvatar(task.assignedTo, task.role) : '') + '</div>',
'<h1 class="detail-title">' + esc(task.title || id) + '</h1>'
];
if (desc) {
sections.push('<section class="detail-section"><h3>Description</h3><pre class="detail-description">' + esc(desc) + '</pre></section>');
}
if (activity && activity.length) {
sections.push('<section class="detail-section"><h3>Activity</h3><div class="detail-activity">' + activity.map(detailActivityHtml).join('') + '</div></section>');
}
body.innerHTML = sections.join('');
}
async function openTaskDetail(id) {
var modal = document.getElementById('detailModal');
var body = document.getElementById('dtBody');
if (!modal || !body) return;
modal.hidden = false;
body.innerHTML = '<div class="empty">loading…</div>';
try {
var results = await Promise.all([
getJSON(taskPath(id)),
getJSON(taskPath(id) + '/activity')
]);
renderTaskDetailModal(id, results[0], results[1] || []);
} catch (err) {
body.innerHTML = '<div class="detail-error">Could not load ' + esc(id) + '.</div>';
}
}
function closeTaskDetail() {
var modal = document.getElementById('detailModal');
if (modal) modal.hidden = true;
}
async function loadConsole(id) {
try {
var data = await getJSON('/tasks/' + encodeURIComponent(id) + '/log');
var data = await getJSON(taskPath(id) + '/log');
renderConsole(id, data && data.log ? data.log : []);
} catch (_) {}
}
@ -1136,8 +1261,18 @@ ${columnSkeleton()}
function setConn(state, label) {
var el = document.getElementById('sseStatus');
var text = document.getElementById('sseLabel');
if (!el || !text) return;
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: state, label: text.textContent, at: Date.now() })); } catch (_) {}
}
function restoreConn() {
try {
var cached = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
if (cached && cached.at && Date.now() - cached.at < 30000) {
setConn(cached.state || 'stale', cached.label || 'connected');
}
} catch (_) {}
}
function applyProjectName(name) {
PROJECT_NAME = name || PROJECT_NAME;
@ -1165,6 +1300,7 @@ ${columnSkeleton()}
}
}
var eventSourceReady = false;
var eventSource = null;
var fallbackPollTimer = null;
function startFallbackPoll() {
if (fallbackPollTimer) return;
@ -1181,7 +1317,9 @@ ${columnSkeleton()}
startFallbackPoll();
return;
}
if (eventSource) eventSource.close();
var source = new EventSource('/events');
eventSource = source;
source.onopen = function() {
eventSourceReady = true;
stopFallbackPoll();
@ -1207,6 +1345,15 @@ ${columnSkeleton()}
} catch (_) {}
});
}
function closeEvents() {
stopFallbackPoll();
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
window.addEventListener('pagehide', closeEvents);
window.addEventListener('beforeunload', closeEvents);
// ── Roster ──────────────────────────────────────────────────────────────
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
@ -1407,7 +1554,7 @@ ${columnSkeleton()}
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + esc(est) + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
'</div>';
}
function renderAgentBars(active) {
@ -1632,6 +1779,13 @@ ${columnSkeleton()}
var card = e.target.closest && e.target.closest('.card');
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
}, true);
document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.card-del, [data-console-toggle], .card-console, .card-console-wrap')) return;
var card = e.target.closest && e.target.closest('.card');
if (!card || card.classList.contains('just-dragged')) return;
e.preventDefault();
openTaskDetail(card.dataset.id);
});
// ── Delete a task (permanent) ───────────────────────────────────────────
async function deleteTask(id) {
@ -1717,6 +1871,16 @@ ${columnSkeleton()}
if (willOpen) loadConsole(id);
});
// ── Task-detail modal ──────────────────────────────────────────────────
(function() {
var modal = document.getElementById('detailModal');
if (!modal) return;
var close = document.getElementById('dtClose');
if (close) close.addEventListener('click', closeTaskDetail);
modal.addEventListener('click', function(e) { if (e.target === modal) closeTaskDetail(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeTaskDetail(); });
})();
// ── New-task modal ──────────────────────────────────────────────────────
(function() {
var btn = document.getElementById('newTaskBtn');
@ -1756,6 +1920,7 @@ ${columnSkeleton()}
})();
loadStatusMeta();
restoreConn();
loadAgents();
refresh();
refreshBudget();
@ -1763,6 +1928,67 @@ ${columnSkeleton()}
setInterval(updateTimers, TIMER_MS);
setInterval(refreshBudget, 3000);
</script>
<script src="/card-assets/vendor/lottie.min.js"></script>
<script>
// Animated KPI icons, driven by task state (not just always-looping):
// • loop-while-active (open / in_progress / review): loops only while count > 0,
// frozen at frame 0 when the column is empty.
// • pulse-on-change (active / done / agents): sits static, plays once whenever the
// value changes (a task arrives, completes, an agent goes busy/free).
// Best-effort: if the player or a file is missing, the card just keeps its number.
(function () {
if (typeof lottie === 'undefined') { window.__setMetricIcon = function () {}; return; }
var SPEC = {
active: { file: 'all_tasks', mode: 'pulse' },
open: { file: 'open_tasks', mode: 'loop' },
in_progress: { file: 'in_progress_tasks', mode: 'loop' },
review: { file: 'in_review_tasks', mode: 'loop' },
done: { file: 'done_tasks', mode: 'pulse' },
agents: { file: 'agents', mode: 'pulse' }
};
var reg = {}; // metric -> { anim, mode, ready, playing, prev }
document.querySelectorAll('.metric-icon[data-anim]').forEach(function (el) {
var card = el.closest('.metric-card');
var vEl = card ? card.querySelector('[data-metric]') : null;
var name = vEl ? vEl.getAttribute('data-metric') : null;
var spec = (name && SPEC[name]) || { file: el.getAttribute('data-anim'), mode: 'loop' };
try {
var anim = lottie.loadAnimation({
container: el, renderer: 'svg', loop: false, autoplay: false,
path: '/card-assets/card_icons/' + spec.file + '.json'
});
var rec = { anim: anim, mode: spec.mode, ready: false, playing: false, prev: null };
anim.addEventListener('DOMLoaded', function () {
rec.ready = true;
anim.goToAndStop(0, true);
// Apply whatever is already on screen: loops start right away after load,
// pulse-mode just seeds its baseline so it doesn't fire on first paint.
var cur = vEl ? vEl.textContent : '0';
rec.prev = cur;
if (rec.mode === 'loop' && (parseInt(cur, 10) || 0) > 0) {
rec.playing = true; anim.loop = true; anim.play();
}
});
if (name) reg[name] = rec;
} catch (e) { /* icon optional — keep the card */ }
});
// Called by setMetric() after it writes the number.
window.__setMetricIcon = function (name, value) {
var rec = reg[name];
if (!rec) return;
if (!rec.ready) { rec.prev = value; return; }
if (rec.mode === 'loop') {
var n = parseInt(String(value), 10) || 0;
if (n > 0 && !rec.playing) { rec.playing = true; rec.anim.loop = true; rec.anim.goToAndPlay(0, true); }
else if (n <= 0 && rec.playing) { rec.playing = false; rec.anim.loop = false; rec.anim.goToAndStop(0, true); }
} else if (rec.prev !== null && String(value) !== String(rec.prev)) {
rec.anim.loop = false;
rec.anim.goToAndPlay(0, true); // one-shot; stops at the last frame
}
rec.prev = value;
};
})();
</script>
</body>
</html>
`;

View File

@ -20,9 +20,16 @@ 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';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join, extname, normalize } from 'node:path';
const SSE_KEEPALIVE_MS = 10_000;
// KPI-card animated icons + vendored lottie player live in the repo `assets/` dir
// (../../assets relative to the compiled dist/server/routes.js).
const CARD_ASSETS_DIR = fileURLToPath(new URL('../../assets/', import.meta.url));
function notFound(reply: FastifyReply, resource: string) {
return reply.status(404).send({ error: `${resource} not found` });
}
@ -53,6 +60,25 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
app.get('/card-assets/*', async (request, reply) => {
const rel = normalize((request.params as Record<string, string>)['*'] || '');
if (!rel || rel.startsWith('..') || rel.includes('\0')) return reply.status(404).send('not found');
const full = join(CARD_ASSETS_DIR, rel);
if (!full.startsWith(CARD_ASSETS_DIR)) return reply.status(404).send('not found');
try {
const buf = await readFile(full);
const ext = extname(full).toLowerCase();
const type = ext === '.json' ? 'application/json; charset=utf-8'
: ext === '.js' ? 'text/javascript; charset=utf-8'
: 'application/octet-stream';
return reply.type(type).header('Cache-Control', 'public, max-age=3600').send(buf);
} catch {
return reply.status(404).send('not found');
}
});
// Team hierarchy page: roles tree with per-agent free/busy state.
app.get('/team', async (_request, reply) => {
const teamHtml = renderTeamHtml(cwd);
@ -99,13 +125,31 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
raw.flushHeaders();
raw.write(': connected\n\n');
let closed = false;
let keepAliveTimer: ReturnType<typeof setInterval> | undefined;
const cleanup = () => {
if (closed) return;
closed = true;
if (keepAliveTimer) clearInterval(keepAliveTimer);
eventBus.off('change', listener);
eventBus.off('log', logListener);
};
const writeSse = (chunk: string) => {
if (closed || raw.destroyed || raw.writableEnded) return;
try {
raw.write(chunk);
} catch {
cleanup();
}
};
const listener = (event: AgentHubEvent) => {
// Server-side role filter: skip tasks that belong to a different role.
// Handoffs, decisions and memory always pass through.
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
return;
}
raw.write(`data: ${JSON.stringify(event)}\n\n`);
writeSse(`data: ${JSON.stringify(event)}\n\n`);
};
eventBus.on('change', listener);
@ -113,20 +157,20 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
// onmessage handler ignores them; only the task-detail live console listens.
const logListener = (payload: unknown) => {
raw.write(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
writeSse(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
};
eventBus.on('log', logListener);
const keepAliveTimer = setInterval(() => {
raw.write(': keepalive\n\n');
keepAliveTimer = setInterval(() => {
writeSse(': keepalive\n\n');
}, SSE_KEEPALIVE_MS);
// Clean up when the client disconnects (or the server closes).
request.raw.on('close', () => {
clearInterval(keepAliveTimer);
eventBus.off('change', listener);
eventBus.off('log', logListener);
});
request.raw.once('close', cleanup);
request.raw.once('error', cleanup);
raw.once('close', cleanup);
raw.once('error', cleanup);
raw.once('finish', cleanup);
});
// ─── Presence ──────────────────────────────────────────────────────────────

View File

@ -289,7 +289,23 @@ export function renderTeamHtml(cwd: string): string {
var parentOf = {}; // nodeId -> parent nodeId
var taskState = {}, baseline = false;
function setConn(ok) { if (!connBox) return; connBox.classList.remove('stale', 'down'); if (!ok) connBox.classList.add('down'); if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting'; }
var CONN_CACHE_KEY = 'agenthub-connection-state';
function setConn(ok) {
if (!connBox) return;
connBox.classList.remove('stale', 'down');
if (!ok) connBox.classList.add('down');
if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting';
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: ok ? 'ok' : 'down', label: ok ? 'connected' : 'reconnecting', at: Date.now() })); } catch (_) {}
}
try {
var cachedConn = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
if (cachedConn && cachedConn.at && Date.now() - cachedConn.at < 30000) {
connBox && connBox.classList.remove('stale', 'down');
if (cachedConn.state === 'down') connBox && connBox.classList.add('down');
if (cachedConn.state === 'stale') connBox && connBox.classList.add('stale');
if (connLabel) connLabel.textContent = cachedConn.label || 'connected';
}
} catch (_) {}
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/1000)); if (s<60) return s+'s'; var m=Math.floor(s/60); if (m<60) return m+'m'; var h=Math.floor(m/60); if (h<24) return h+'h'; return Math.floor(h/24)+'d'; }
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
@ -548,12 +564,15 @@ export function renderTeamHtml(cwd: string): string {
window.addEventListener('resize', relayout);
layoutEdges(); centerScroll();
setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
sync(); setInterval(sync, 1500);
sync(); var syncTimer = setInterval(sync, 1500);
if ('EventSource' in window) {
var es = new EventSource('/events');
es.onopen = function(){ setConn(true); };
es.onmessage = function(){ sync(); };
es.onerror = function(){ setConn(false); };
function closeEvents() { if (es) { es.close(); es = null; } clearInterval(syncTimer); }
window.addEventListener('pagehide', closeEvents);
window.addEventListener('beforeunload', closeEvents);
}
})();
</script>

View File

@ -34,7 +34,7 @@ export function designTokensCss(): string {
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
html, body { margin: 0; height: 100%; scrollbar-gutter: stable; }
body {
background: var(--bg);
color: var(--text);
@ -317,7 +317,7 @@ export function appHeaderCss(): string {
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
.new-task-btn:active { transform: translateY(1px); }
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); }
.sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
@ -447,9 +447,26 @@ export function connectionStatusJs(): string {
(function(){
var box=document.getElementById('sseStatus'), label=document.getElementById('sseLabel');
if(!box) return;
function setConn(state,text){ box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text; }
var KEY='agenthub-connection-state';
function setConn(state,text){
box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text;
try{ sessionStorage.setItem(KEY, JSON.stringify({state:state,label:text,at:Date.now()})); }catch(_){}
}
try{
var cached=JSON.parse(sessionStorage.getItem(KEY)||'null');
if(cached&&cached.at&&Date.now()-cached.at<30000) setConn(cached.state||'ok', cached.label||'connected');
}catch(_){}
if(!('EventSource' in window)){ setConn('stale','polling'); return; }
try { var s=new EventSource('/events'); s.onopen=function(){ setConn('ok','connected'); }; s.onerror=function(){ setConn('down','reconnecting'); }; }
try {
var s=null, timer=setTimeout(function(){
s=new EventSource('/events');
s.onopen=function(){ setConn('ok','connected'); };
s.onerror=function(){ setConn('down','reconnecting'); };
}, 500);
function closeEvents(){ if(s){ s.close(); s=null; } }
window.addEventListener('pagehide', function(){ clearTimeout(timer); closeEvents(); });
window.addEventListener('beforeunload', function(){ clearTimeout(timer); closeEvents(); });
}
catch(_){ setConn('down','offline'); }
})();
</script>`;