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:
parent
1749f1c58c
commit
2fb9db15b1
1
assets/card_icons/agents.json
Normal file
1
assets/card_icons/agents.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/all_tasks.json
Normal file
1
assets/card_icons/all_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/all_tasks_old.json
Normal file
1
assets/card_icons/all_tasks_old.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/done_tasks.json
Normal file
1
assets/card_icons/done_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/impact.json
Normal file
1
assets/card_icons/impact.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/in_progress_tasks.json
Normal file
1
assets/card_icons/in_progress_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/in_review_tasks.json
Normal file
1
assets/card_icons/in_review_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/open_tasks.json
Normal file
1
assets/card_icons/open_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/vendor/lottie.min.js
vendored
Normal file
1
assets/vendor/lottie.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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">×</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>
|
||||
`;
|
||||
|
||||
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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>
|
||||
|
||||
@ -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>`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user