feat(board): replace v1 monolith with modular v2 glass dashboard

This commit is contained in:
chahinebrini 2026-07-20 17:44:45 +02:00
parent 30b889acd9
commit b09e4c78d2
8 changed files with 1000 additions and 2103 deletions

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ export function headerHtml(projectName: string): string {
return `
<header class="b2-hdr">
<span class="b2-brand">${LOGO}<b>agenthub</b></span>
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ ${name}&nbsp;<span class="b2-chev"></span></span>
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ <span id="projectName">${name}</span>&nbsp;<span class="b2-chev"></span></span>
<nav class="b2-nav" aria-label="Primary">
<b aria-current="page">Board</b><a href="/team">Team</a><a href="/activity">Activity</a><a href="/decisions">Decisions</a>
</nav>

893
src/server/board/columns.ts Normal file
View File

@ -0,0 +1,893 @@
/**
* Board v2 columns/cards/modals/data-layer: a faithful port of the v1 client
* JS (old src/server/board.ts, lines 9751460 and 17522032) with these
* deliberate changes:
* - v1 KPI metric cards + Lottie icons are gone; renderBoard() instead calls
* window.__b2UpdateKpis / __b2RenderThroughput (kpis.ts / sidebar.ts).
* - taskCard() adds the b2-working glow and the in-progress PROGRESS RING
* (elapsed vs median-done-duration estimate; amber when over estimate).
* - SSE task events additionally push a line into the live feed
* (window.__b2FeedPush, sidebar.ts).
* - The init sequence is NOT included here (see initJs below) so index.ts
* can run it after all module scripts are defined.
* Everything else (patchColumn reconcile, drag & drop, race-guarded moves,
* live console, modals, toasts, connection state) is byte-faithful to v1.
*/
export interface BoardColumnDef {
key: string;
label: string;
}
/** Column skeletons; mount points match the ported JS (data-cards/data-count). */
export function columnsHtml(columns: BoardColumnDef[]): string {
const cols = columns
.map(
(c) => ` <section class="column b2-glass" data-column="${c.key}" aria-label="${c.label}">
<header class="col-head"><span class="col-dot" aria-hidden="true"></span><span class="col-label">${c.label}</span><span class="col-count" data-count="${c.key}">0</span></header>
<div class="cards" data-cards="${c.key}"><div class="empty">none</div></div>
</section>`,
)
.join('\n');
return `<section class="board" id="board" aria-label="Task board">\n${cols}\n </section>`;
}
/** Modal markup (new task / delete confirm / task detail) + toast host, from v1. */
export function modalsHtml(): string {
return `
<div class="modal-backdrop" id="taskModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
<div class="modal-head">
<h2 id="tmHeading">New task</h2>
<button class="modal-x" id="tmClose" type="button" aria-label="Close">&times;</button>
</div>
<form id="tmForm" autocomplete="off">
<label class="modal-field">
<span class="modal-label">Title</span>
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
</label>
<label class="modal-field">
<span class="modal-label">Description</span>
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
</label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Priority</span>
<select id="tmPriority">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
<button type="submit" class="modal-create">Create task</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="confirmModal" hidden>
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="cfHeading" aria-describedby="cfBody">
<div class="modal-head">
<h2 id="cfHeading">Delete task</h2>
<button class="modal-x" id="cfClose" type="button" aria-label="Close">&times;</button>
</div>
<div class="confirm-body">
<p id="cfBody">This cannot be undone.</p>
</div>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="cfCancel">Cancel</button>
<button type="button" class="modal-danger" id="cfConfirm">Delete</button>
</div>
</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>`;
}
export interface ColumnsJsOpts {
columns: string[];
activeColumns: string[];
projectName: string;
}
/** The ported client JS (no init sequence — index.ts appends initJs last). */
export function columnsJs(opts: ColumnsJsOpts): string {
return `
var REFRESH_MS = 6000;
var TIMER_MS = 1000;
var COLUMNS = ${JSON.stringify(opts.columns)};
var ACTIVE_COLUMNS = ${JSON.stringify(opts.activeColumns)};
var PROJECT_NAME = ${JSON.stringify(opts.projectName)};
var lastTasks = [];
var openConsoles = {}; // taskId -> true when its in-card console is expanded
var estimatedMs = 3600000; // progress-ring estimate; recomputed per refresh
var CONN_CACHE_KEY = 'agenthub-connection-state';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function compactDuration(ms) {
var s = Math.max(0, Math.floor(ms / 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 < 48) return h + 'h';
return Math.floor(h / 24) + 'd';
}
function ago(iso) {
var t = Date.parse(iso);
if (isNaN(t)) return '';
return compactDuration(Date.now() - t) + ' ago';
}
async function getJSON(path) {
var res = await fetch(path, { cache: 'no-store', headers: { accept: 'application/json' } });
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, ' ');
}
function hashColor(name) {
var colors = ['#0EA5E9', '#14B8A6', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
var h = 0;
var s = String(name || '');
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
return colors[Math.abs(h) % colors.length];
}
function agentSpec(name) {
var key = String(name || '').toLowerCase();
var map = {
claude: { color: '#D97757', initials: 'C', architect: true },
codex: { color: '#10A37F', initials: 'Cx' },
kimi: { color: '#7C3AED', initials: 'K' },
'windows-claude': { color: '#2563EB', initials: 'W' },
backyard: { color: '#64748B', initials: 'B' }
};
if (map[key]) return map[key];
var clean = key.replace(/[^a-z0-9]+/g, ' ').trim();
return {
color: hashColor(key),
initials: (clean ? clean.split(' ').map(function(p) { return p[0]; }).join('').slice(0, 2) : '?').toUpperCase()
};
}
function agentAvatar(name, role) {
if (!name) return '<span class="avatar" style="--agent-color:#64748B"><span class="avatar-core">?</span><span class="avatar-label">unassigned</span></span>';
var spec = agentSpec(name);
var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect';
return '<span class="avatar' + (isArchitect ? ' architect' : '') + '" style="--agent-color:' + esc(spec.color) + '">' +
'<span class="avatar-core">' + esc(spec.initials) + '</span>' +
'<span class="avatar-label">@' + esc(name) + '</span>' +
'</span>';
}
function reviewerBadge(name) {
if (!name) return '';
return '<span class="reviewer-wrap"><span class="reviewer-label">reviewed by</span>' + agentAvatar(name, 'reviewer') + '</span>';
}
function projectTag(title) {
var s = String(title || '').toLowerCase();
if (s.indexOf('win') >= 0 || s.indexOf('windows') >= 0) return 'windows';
if (s.indexOf('backend') >= 0 || s.indexOf('api') >= 0) return 'backend';
if (s.indexOf('magic') >= 0) return 'magic';
if (s.indexOf('agenthub') >= 0 || s.indexOf('board') >= 0 || s.indexOf('ui') >= 0) return 'agenthub';
return PROJECT_NAME;
}
function timerLabel(status, createdAt, updatedAt) {
var created = Date.parse(createdAt);
var updated = Date.parse(updatedAt || createdAt);
if (isNaN(created)) return '';
var key = String(status || 'open');
if (key === 'done' || key === 'cancelled') {
return 'total ' + compactDuration((isNaN(updated) ? Date.now() : updated) - created);
}
if (key === 'in_progress') return 'claimed ' + ago(updatedAt || createdAt);
if (key === 'review') return 'review ' + ago(updatedAt || createdAt);
return 'created ' + ago(createdAt);
}
function updateTimers() {
document.querySelectorAll('[data-timer]').forEach(function(el) {
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
});
// Progress rings on in-progress cards: elapsed vs estimated duration.
var est = Math.max(60000, estimatedMs);
document.querySelectorAll('[data-ring]').forEach(function(el) {
var claimed = Date.parse(el.getAttribute('data-claimed') || '');
if (isNaN(claimed)) return;
var elapsed = Math.max(0, Date.now() - claimed);
var pct = Math.min(1, elapsed / est);
var fill = el.querySelector('.b2-ring-fill');
if (fill) fill.style.strokeDashoffset = String((50.3 * (1 - pct)).toFixed(1));
var t = el.querySelector('[data-ring-time]');
if (t) t.textContent = compactDuration(elapsed) + ' / ~' + compactDuration(est);
el.classList.toggle('b2-over', elapsed > est);
});
}
function byStatus(status) {
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
}
function taskCard(t) {
var status = byStatus(t.status);
var isOpen = openConsoles[t.id] ? true : false;
var live = status === 'in_progress';
// Console lives inside the card while a task is worked (in_progress = live)
// and stays available in review so you can see what the agent did.
var console = (live || status === 'review')
? '<div class="card-console-wrap">' +
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
'</button>' +
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
'</div>' +
'</div>'
: '';
var ring = live
? '<span class="b2-ring" data-ring data-claimed="' + esc(t.claimedAt || t.updatedAt || t.createdAt || '') + '">' +
'<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">' +
'<circle class="b2-ring-track" cx="10" cy="10" r="8" stroke-width="3"/>' +
'<circle class="b2-ring-fill" cx="10" cy="10" r="8" stroke-width="3" stroke-dasharray="50.3" stroke-dashoffset="50.3"/>' +
'</svg>' +
'<span class="b2-ring-time" data-ring-time></span>' +
'</span>'
: '';
return '<a class="card' + (live ? ' b2-working' : '') + '" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '" data-reviewer="' + esc(t.reviewer || '') + '">' +
'<div class="card-top">' +
'<span class="id">' + esc(t.id) + '</span>' +
'<span class="card-top-right">' +
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
'<button class="card-del" type="button" data-del="' + esc(t.id) + '" title="Delete task permanently" aria-label="Delete ' + esc(t.id) + '">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"/></svg>' +
'</button>' +
'</span>' +
'</div>' +
'<h3 class="title">' + esc(t.title) + '</h3>' +
'<div class="meta-row">' +
(status === 'review' && t.reviewer
? reviewerBadge(t.reviewer)
: agentAvatar(t.assignedTo, t.role)) +
'<span class="pill timer-badge" data-timer data-status="' + esc(status) + '" data-created="' + esc(t.createdAt) + '" data-updated="' + esc(t.updatedAt) + '">' +
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
'</span>' +
'</div>' +
ring +
console +
'</a>';
}
// Median real duration of recently done tasks — the progress ring's "typical
// duration" estimate. Fallback 60 min (mirrors the budget-service live cap).
function estimateDurationMs(tasks) {
var ds = [];
(tasks || []).forEach(function(t) {
if (t.status !== 'done') return;
var start = Date.parse(t.claimedAt || t.createdAt || '');
var end = Date.parse(t.updatedAt || '');
if (!isNaN(start) && !isNaN(end) && end > start) ds.push(end - start);
});
if (!ds.length) return 3600000;
ds.sort(function(a, b) { return a - b; });
return ds[Math.floor(ds.length / 2)];
}
// Signature of everything that changes a card's rendered CONTENT — but NOT
// its console open/closed state (that lives in the DOM + openConsoles and
// must survive a patch). Identical signature ⇒ the card node is left alone.
function cardSig(t) {
return [byStatus(t.status), t.title || '', t.assignedTo || '', t.reviewer || '', t.role || '', t.createdAt || '', t.updatedAt || ''].join('\\u0001');
}
function cardNode(t, sig, isNew) {
var tmp = document.createElement('div');
tmp.innerHTML = taskCard(t);
var node = tmp.firstElementChild;
if (!node) return null;
node.setAttribute('data-sig', sig);
if (isNew) {
node.classList.add('card-new');
// One-shot marker: drop it once the enter animation has played, so a
// later reorder (insertBefore) can never replay the animation.
setTimeout(function() { node.classList.remove('card-new'); }, 300);
}
return node;
}
// Reconcile ONE column's DOM against its desired task list WITHOUT a full
// innerHTML= replace (which re-created every node on every event → board
// twitch + card overlap from the global enter animation). Only genuinely
// new/changed cards are (re)built; untouched cards are kept and merely
// reordered, so open live consoles + hover/animation state survive.
function patchColumn(cardsEl, list) {
if (!list.length) {
if (cardsEl.children.length !== 1 || !cardsEl.querySelector('.empty')) {
cardsEl.innerHTML = '<div class="empty">none</div>';
}
return;
}
var placeholder = cardsEl.querySelector('.empty');
if (placeholder) placeholder.remove();
var existing = {};
cardsEl.querySelectorAll('.card[data-id]').forEach(function(node) {
existing[node.getAttribute('data-id')] = node;
});
var seen = {};
var prev = null;
list.forEach(function(t) {
seen[t.id] = true;
var sig = cardSig(t);
var old = existing[t.id];
var node;
if (old && old.getAttribute('data-sig') === sig) {
node = old; // unchanged — keep node + its console
} else {
node = cardNode(t, sig, !old); // new-to-this-column ⇒ enter animation
if (old) old.remove(); // drop the stale version
if (!node) return;
}
// Place node immediately after the previous card so DOM order == list.
var anchor = prev ? prev.nextSibling : cardsEl.firstChild;
if (node !== anchor) cardsEl.insertBefore(node, anchor);
prev = node;
});
// Remove cards that left this column (moved elsewhere or were deleted).
Object.keys(existing).forEach(function(id) {
if (!seen[id]) existing[id].remove();
});
}
function renderBoard(tasks) {
lastTasks = tasks || [];
estimatedMs = estimateDurationMs(lastTasks);
var byCol = {};
ACTIVE_COLUMNS.forEach(function(k) { byCol[k] = []; });
lastTasks.forEach(function(t) {
var key = byStatus(t.status);
if (byCol[key]) byCol[key].push(t);
});
ACTIVE_COLUMNS.forEach(function(k) {
var list = byCol[k];
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length);
if (!cardsEl) return;
patchColumn(cardsEl, list);
});
updateTimers();
reapplyConsoles();
if (window.__b2UpdateKpis) {
window.__b2UpdateKpis(lastTasks, function(name) { return agentSpec(name).color; });
}
if (window.__b2RenderThroughput) window.__b2RenderThroughput(lastTasks);
}
// ── In-card live agent console ──────────────────────────────────────────
function consoleLineHtml(e) {
var lvl = String(e.level || 'info');
var ts = '';
try { ts = new Date(e.ts).toLocaleTimeString(); } catch (_) {}
return '<div class="cc-line level-' + esc(lvl) + '">' +
(ts ? '<span class="cc-ts">' + esc(ts) + '</span>' : '') +
(e.agent ? '<span class="cc-agent">' + esc(e.agent) + '</span>' : '') +
'<span class="cc-text">' + esc(e.text) + '</span>' +
'</div>';
}
function renderConsole(id, entries) {
var body = document.querySelector('[data-console-body="' + id + '"]');
if (!body) return;
if (!entries || !entries.length) { body.innerHTML = '<div class="cc-empty">waiting for output…</div>'; return; }
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(taskPath(id) + '/log');
renderConsole(id, data && data.log ? data.log : []);
} catch (_) {}
}
function appendConsoleLine(id, entry) {
if (!openConsoles[id]) return;
var body = document.querySelector('[data-console-body="' + id + '"]');
if (!body) return;
var empty = body.querySelector('.cc-empty');
if (empty) body.innerHTML = '';
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
body.scrollTop = body.scrollHeight;
}
// Re-open any consoles that were expanded before a re-render (cards rebuild
// their innerHTML, so the panel state must be re-applied + reloaded).
function reapplyConsoles() {
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) { panel.hidden = false; loadConsole(id); }
});
}
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;
var el = document.getElementById('projectName');
if (el) el.textContent = PROJECT_NAME;
}
async function loadStatusMeta() {
try {
var status = await getJSON('/status');
var body = String(status && status.body ? status.body : '');
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status[^\\S\\n]*[:\\-][^\\S\\n]*(.+)$/m);
var name = m && m[1] ? m[1].trim() : '';
// Guard: never let a summary line (long / contains a period) become the name.
applyProjectName(name && name.length <= 48 && name.indexOf('.') < 0 ? name : PROJECT_NAME);
} catch (_) {
applyProjectName(PROJECT_NAME);
}
}
async function refresh() {
try {
var tasks = await getJSON('/tasks');
renderBoard(tasks);
setConn('ok', eventSourceReady ? 'connected' : 'polling');
if (window.__b2SplashDone) window.__b2SplashDone();
} catch (_) {
setConn('down', 'offline');
}
}
var eventSourceReady = false;
var eventSource = null;
var fallbackPollTimer = null;
function startFallbackPoll() {
if (fallbackPollTimer) return;
fallbackPollTimer = setInterval(refresh, REFRESH_MS);
}
function stopFallbackPoll() {
if (!fallbackPollTimer) return;
clearInterval(fallbackPollTimer);
fallbackPollTimer = null;
}
// Compact one-liner for the sidebar live feed (defensive about payload shape).
function feedFromEvent(d) {
if (!window.__b2FeedPush) return;
var id = (d && (d.id || d.taskId || (d.task && d.task.id))) || 'task';
var status = (d && (d.status || d.to || (d.task && d.task.status))) || '';
var agent = (d && (d.agent || d.assignedTo)) || '';
window.__b2FeedPush('<span class="pulse">●</span><span>' + esc(id) + (status ? ' → ' + esc(status) : ' updated') + (agent ? ' · @' + esc(agent) : '') + '</span>');
}
function connectEvents() {
if (!('EventSource' in window)) {
setConn('stale', 'polling');
startFallbackPoll();
return;
}
if (eventSource) eventSource.close();
var source = new EventSource('/events');
eventSource = source;
source.onopen = function() {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
refresh();
refreshBudget();
};
source.onmessage = function(ev) {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
// Only task lifecycle changes affect the board. message / ask / decision /
// memory / handoff / agent-presence events must NOT trigger a board
// rerender — the old unconditional refresh() on every change was the root
// cause of the board twitch + card overlap. The budget panel has its own
// diff + throttle (shouldRenderDonut / FLIP bars), so it can refresh on
// any change without churn.
var type = '';
var data = null;
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
if (type === 'task') { refresh(); feedFromEvent(data); }
refreshBudget();
};
source.onerror = function() {
eventSourceReady = false;
setConn('stale', 'reconnecting');
startFallbackPoll();
};
// Named task-log events feed the in-card live console without a re-render.
source.addEventListener('task-log', function(ev) {
try {
var p = JSON.parse(ev.data);
if (p && p.taskId) appendConsoleLine(p.taskId, p);
} catch (_) {}
});
}
function closeEvents() {
stopFallbackPoll();
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
window.addEventListener('pagehide', closeEvents);
window.addEventListener('beforeunload', closeEvents);
window.addEventListener('pageshow', function() {
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
document.addEventListener('visibilitychange', function() {
if (document.hidden) return;
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
window.addEventListener('focus', function() {
refresh();
refreshBudget();
});
// ── Toasts ──────────────────────────────────────────────────────────────
function toast(msg, opts) {
opts = opts || {};
var wrap = document.getElementById('toasts');
if (!wrap) return;
var el = document.createElement('div');
el.className = 'toast' + (opts.error ? ' err' : '');
el.innerHTML = '<span class="dot" aria-hidden="true"></span><span>' + esc(msg) + '</span>';
wrap.appendChild(el);
setTimeout(function() {
el.classList.add('out');
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 240);
}, opts.ms || 3200);
}
// ── Mutations (create / assign / move) ──────────────────────────────────
async function postTask(body) {
var res = await fetch('/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('create failed (' + res.status + ')');
return res.json();
}
async function patchTask(id, body) {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) {
var msg = 'update failed (' + res.status + ')';
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
throw new Error(msg);
}
return res.json();
}
function flashCard(id) {
var el = document.querySelector('.card[data-id="' + id + '"]');
if (!el) return;
el.classList.add('flash');
setTimeout(function() { el.classList.remove('flash'); }, 900);
}
// Derive the addressed agent from a task title's "name:" prefix (delegation
// convention), matched case-insensitively against the known roster.
function agentFromTitle(id) {
var t = (lastTasks || []).find(function(x) { return x.id === id; });
if (!t || !t.title) return '';
var m = String(t.title).match(/^([A-Za-z][A-Za-z0-9_-]*)\\s*:/);
if (!m) return '';
var name = m[1].toLowerCase();
var hit = (AGENTS || []).find(function(a) { return String(a.name).toLowerCase() === name; });
return hit ? hit.name : '';
}
async function onDropColumn(id, status, assigned) {
var card = document.querySelector('.card[data-id="' + id + '"]');
var from = card ? card.dataset.status : '';
if (from === status) return;
try {
if (status === 'in_progress') {
// Claiming needs an agent: use the current assignee, else the one named
// in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual';
await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
} else if (status === 'review') {
var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
} else if (status === 'open') {
await patchTask(id, { status: 'open' });
toast(id + ' \\u2192 reopened');
} else { return; }
await refresh(); await refreshBudget(); flashCard(id);
} catch (e) { toast(e.message || 'move failed', { error: true }); }
}
// ── Drag & drop wiring (event delegation) ───────────────────────────────
// Drag a CARD onto a COLUMN to change its status (in_progress needs an agent).
var dragKind = null, dragId = null, lastTarget = null;
function dropTargetOf(e) {
var n = e.target;
if (!n || !n.closest) return null;
if (dragKind === 'card') return n.closest('.column');
return null;
}
document.addEventListener('dragstart', function(e) {
// Don't start a card drag when interacting with the in-card console.
if (e.target.closest && e.target.closest('.card-console-wrap')) { e.preventDefault(); return; }
var card = e.target.closest && e.target.closest('.card');
if (card) {
dragKind = 'card'; dragId = card.dataset.id;
card.classList.add('dragging');
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', dragId); } catch (_) {} }
}
});
document.addEventListener('dragend', function(e) {
var el = e.target.closest && e.target.closest('.card');
if (el) {
el.classList.remove('dragging');
// Guard the trailing click so a drag doesn't also open the task detail.
el.classList.add('just-dragged');
setTimeout(function() { el.classList.remove('just-dragged'); }, 350);
}
if (lastTarget) { lastTarget.classList.remove('drop-active'); lastTarget = null; }
dragKind = null; dragId = null;
});
document.addEventListener('dragover', function(e) {
if (!dragKind) return;
var tgt = dropTargetOf(e);
if (!tgt) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
if (lastTarget && lastTarget !== tgt) lastTarget.classList.remove('drop-active');
tgt.classList.add('drop-active');
lastTarget = tgt;
});
document.addEventListener('dragleave', function(e) {
var tgt = dropTargetOf(e);
if (tgt && e.relatedTarget && !tgt.contains(e.relatedTarget)) {
tgt.classList.remove('drop-active');
if (lastTarget === tgt) lastTarget = null;
}
});
document.addEventListener('drop', function(e) {
if (!dragKind) return;
var tgt = dropTargetOf(e);
if (!tgt) return;
e.preventDefault();
tgt.classList.remove('drop-active');
var card = document.querySelector('.card[data-id="' + dragId + '"]');
var assigned = card ? (card.dataset.assigned || '') : '';
onDropColumn(dragId, tgt.dataset.column, assigned);
});
// A drag that ends in a real drop still fires a click on the <a> card — swallow it.
document.addEventListener('click', function(e) {
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) {
try {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'DELETE', headers: { accept: 'application/json' } });
if (!res.ok) {
var msg = 'delete failed (' + res.status + ')';
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
throw new Error(msg);
}
toast(id + ' deleted');
await refresh(); await refreshBudget();
} catch (e) { toast(e.message || 'delete failed', { error: true }); }
}
// Themed confirm dialog (replaces window.confirm). Resolves true on confirm.
function confirmModal(opts) {
opts = opts || {};
return new Promise(function(resolve) {
var modal = document.getElementById('confirmModal');
if (!modal) { resolve(window.confirm(opts.bodyText || 'Are you sure?')); return; }
var heading = document.getElementById('cfHeading');
var body = document.getElementById('cfBody');
var okBtn = document.getElementById('cfConfirm');
var cancelBtn = document.getElementById('cfCancel');
var closeBtn = document.getElementById('cfClose');
if (heading) heading.textContent = opts.title || 'Confirm';
if (body) body.innerHTML = opts.bodyHtml || esc(opts.bodyText || '');
if (okBtn) okBtn.textContent = opts.confirmLabel || 'Confirm';
var done = false;
function cleanup(result) {
if (done) return; done = true;
modal.hidden = true;
okBtn && okBtn.removeEventListener('click', onOk);
cancelBtn && cancelBtn.removeEventListener('click', onCancel);
closeBtn && closeBtn.removeEventListener('click', onCancel);
modal.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBackdrop(e) { if (e.target === modal) cleanup(false); }
function onKey(e) { if (e.key === 'Escape') cleanup(false); }
okBtn && okBtn.addEventListener('click', onOk);
cancelBtn && cancelBtn.addEventListener('click', onCancel);
closeBtn && closeBtn.addEventListener('click', onCancel);
modal.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
modal.hidden = false;
if (okBtn) setTimeout(function() { okBtn.focus(); }, 20);
});
}
// Delete button lives inside the card <a> — stop the navigation + confirm.
document.addEventListener('click', function(e) {
var del = e.target.closest && e.target.closest('.card-del');
if (!del) return;
e.preventDefault(); e.stopPropagation();
var id = del.dataset.del;
if (!id) return;
confirmModal({
title: 'Delete ' + id + '?',
bodyHtml: 'This permanently removes <b>' + esc(id) + '</b> — its file and index entry. This cannot be undone.',
confirmLabel: 'Delete task',
}).then(function(ok) { if (ok) deleteTask(id); });
});
// Clicks/selection inside the console body must not navigate to the task page.
document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
});
// Live-console toggle lives inside the card <a> — stop the navigation.
document.addEventListener('click', function(e) {
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
if (!tog) return;
e.preventDefault(); e.stopPropagation();
var id = tog.getAttribute('data-console-toggle');
var willOpen = !openConsoles[id];
openConsoles[id] = willOpen;
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) panel.hidden = !willOpen;
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');
var modal = document.getElementById('taskModal');
var form = document.getElementById('tmForm');
if (!btn || !modal || !form) return;
var titleInput = document.getElementById('tmTitleInput');
function openModal() {
modal.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (titleInput) setTimeout(function() { titleInput.focus(); }, 20);
}
function closeModal() {
modal.hidden = true;
btn.setAttribute('aria-expanded', 'false');
form.reset();
}
btn.addEventListener('click', openModal);
document.getElementById('tmClose').addEventListener('click', closeModal);
document.getElementById('tmCancel').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeModal(); });
form.addEventListener('submit', async function(e) {
e.preventDefault();
var title = (titleInput.value || '').trim();
if (!title) return;
var description = (document.getElementById('tmDesc').value || '').trim();
var priority = document.getElementById('tmPriority').value || undefined;
try {
// No assignee/role: the task lands open in the architect's lap to route.
var task = await postTask({ title: title, description: description, priority: priority });
toast(task.id + ' created');
closeModal();
await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); }
});
})();
`;
}
/**
* Init sequence MUST run after every module script above is defined
* (refreshBudget comes from v1Budget.ts, __b2UpdateKpis from kpis.ts, ).
* index.ts appends this as the last chunk of the single inline script.
*/
export function initJs(): string {
return `
loadStatusMeta();
restoreConn();
loadAgents();
refresh();
refreshBudget();
connectEvents();
setInterval(updateTimers, TIMER_MS);
setInterval(refreshBudget, 3000);
`;
}

55
src/server/board/index.ts Normal file
View File

@ -0,0 +1,55 @@
import { boardV2Css } from './styles.js';
import { headerHtml, splashHtml, splashJs } from './chrome.js';
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
import { sidebarHtml, sidebarJs } from './sidebar.js';
import { columnsHtml, columnsJs, modalsHtml, initJs, type BoardColumnDef } from './columns.js';
import { v1BudgetJs } from './v1Budget.js';
/** All statuses the data layer understands (order = board semantics). */
export const BOARD_COLUMNS: BoardColumnDef[] = [
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'review', label: 'Review' },
{ key: 'done', label: 'Done' },
{ key: 'cancelled', label: 'Cancelled' },
];
/** The three lanes actually rendered as columns. */
export const ACTIVE_COLUMNS: BoardColumnDef[] = BOARD_COLUMNS.slice(0, 3);
/**
* Board v2: glass dashboard rendered once at server start; all data arrives
* via same-origin fetches + SSE. The single inline script is ordered so the
* init sequence (initJs) runs after every module's definitions exist.
*/
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agenthub Board</title>
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<style>${boardV2Css()}</style>
</head>
<body class="b2">
${splashHtml()}
${headerHtml(projectName)}
<main class="b2-body">
<div class="b2-main">
${kpiSkeletonHtml()}
${columnsHtml(ACTIVE_COLUMNS)}
</div>
${sidebarHtml()}
</main>
${modalsHtml()}
<script>${splashJs()}</script>
<script>${columnsJs({
columns: BOARD_COLUMNS.map((c) => c.key),
activeColumns: ACTIVE_COLUMNS.map((c) => c.key),
projectName,
})}${kpiJs()}${sidebarJs(v1BudgetJs)}${initJs()}</script>
</body>
</html>
`;
}

View File

@ -46,6 +46,14 @@ ${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
function flashKpi(card, value) {
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
var cardEl = document.getElementById(card);
if (cardEl) { cardEl.classList.remove('metric-flash'); void cardEl.offsetWidth; cardEl.classList.add('metric-flash'); }
}
kpiPrev[card] = value;
}
function setK(card, key, fn) {
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
if (el) fn(el);
@ -53,6 +61,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
// Open
var open = tasks.filter(function (t) { return t.status === 'open'; });
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
flashKpi('kpiOpen', String(open.length));
setK('kpiOpen', 'total', function (el) {
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
});
@ -68,6 +77,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
var chips = laneChips(tasks, status);
var capped = capChips(chips, 3);
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
flashKpi(card, String(chips.length));
setK(card, 'chips', function (el) {
el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
@ -81,6 +91,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
// Done
var ds = doneStats(tasks);
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
flashKpi('kpiDone', String(ds.done));
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
setK('kpiDone', 'cap', function (el) {
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';

View File

@ -13,7 +13,7 @@ import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js';
import { loadConfig, saveConfig } from '../core/config.js';
import { renderActivityHtml } from './activity.js';
import { renderBoardHtml } from './board.js';
import { renderBoardHtml } from './board/index.js';
import { renderTeamHtml } from './team.js';
import { renderArchiveHtml } from './archive.js';
import { renderDecisionsHtml } from './decisions.js';

36
tests/boardV2.test.ts Normal file
View File

@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { renderBoardHtml } from '../src/server/board/index.js';
describe('renderBoardHtml (v2)', () => {
const html = renderBoardHtml('demo-project');
it('is a full html document with favicon and splash', () => {
expect(html).toMatch(/^<!doctype html>/i);
expect(html).toContain('rel="icon" href="/logo.svg"');
expect(html).toContain('id="b2-splash"');
});
it('contains header, kpis, columns and sidebar mount points', () => {
for (const s of ['b2-hdr', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
expect(html).toContain(s);
}
});
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'taskModal']) {
expect(html).toContain(s);
}
});
it('renders in-progress cards with a progress ring and working glow', () => {
expect(html).toContain('b2-ring');
expect(html).toContain('b2-working');
});
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
expect(html).not.toContain('metric-card');
expect(html).not.toContain('lottie');
});
it('runs the init sequence after all module definitions', () => {
const defIdx = html.indexOf('window.__b2UpdateKpis =');
const initIdx = html.indexOf('loadStatusMeta();');
expect(defIdx).toBeGreaterThan(-1);
expect(initIdx).toBeGreaterThan(defIdx);
});
});

View File

@ -143,9 +143,9 @@ describe('server routes', () => {
expect(res.headers['content-type']).toContain('text/html');
const html = res.payload;
expect(html).toContain('<title>AgentHub Board</title>');
// All five status columns are present in the static markup.
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
expect(html).toContain('<title>agenthub — Board</title>');
// The three active lanes are present in the static markup (board v2).
for (const col of ['open', 'in_progress', 'review']) {
expect(html).toContain(`data-column="${col}"`);
}
// Board polling stays wired; handoffs/decisions now live on dedicated pages.