agenthub/src/server/board/columns.ts

898 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>'
: '';
// 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);
`;
}