feat(board): replace v1 monolith with modular v2 glass dashboard
This commit is contained in:
parent
30b889acd9
commit
b09e4c78d2
2098
src/server/board.ts
2098
src/server/board.ts
File diff suppressed because it is too large
Load Diff
@ -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} <span class="b2-chev">▾</span></span>
|
||||
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ <span id="projectName">${name}</span> <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
893
src/server/board/columns.ts
Normal 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 975–1460 and 1752–2032) 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">×</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">×</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">×</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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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
55
src/server/board/index.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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
36
tests/boardV2.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user