898 lines
42 KiB
TypeScript
898 lines
42 KiB
TypeScript
/**
|
||
* 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>'
|
||
: '';
|
||
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
||
// same design, review variant in amber. Filled = elapsed vs median estimate.
|
||
var ringStatus = live ? 'work' : status === 'review' ? 'review' : '';
|
||
var ringSince = live ? (t.claimedAt || t.updatedAt || t.createdAt || '') : (t.updatedAt || t.createdAt || '');
|
||
var ring = ringStatus
|
||
? '<span class="b2-ring' + (ringStatus === 'review' ? ' b2-ring-review' : '') + '" data-ring data-claimed="' + esc(ringSince) + '">' +
|
||
'<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);
|
||
`;
|
||
}
|