/** * 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) => `
${c.label}0
none
`, ) .join('\n'); return `
\n${cols}\n
`; } /** Modal markup (new task / delete confirm / task detail) + toast host, from v1. */ export function modalsHtml(): string { return `
`; } 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, '"'); } 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 '?unassigned'; var spec = agentSpec(name); var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect'; return '' + '' + esc(spec.initials) + '' + '@' + esc(name) + '' + ''; } function reviewerBadge(name) { if (!name) return ''; return 'reviewed by' + agentAvatar(name, 'reviewer') + ''; } 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') ? '
' + '' + '
' + '
waiting for output…
' + '
' + '
' : ''; // 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 ? '' + '' + '' + '' : ''; return '' + '
' + '' + esc(t.id) + '' + '' + '' + esc(statusLabel(status)) + '' + '' + '' + '
' + '

' + esc(t.title) + '

' + '
' + (status === 'review' && t.reviewer ? reviewerBadge(t.reviewer) : agentAvatar(t.assignedTo, t.role)) + '' + esc(timerLabel(status, t.createdAt, t.updatedAt)) + '' + '
' + ring + console + '
'; } // 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 = '
none
'; } 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 '
' + (ts ? '' + esc(ts) + '' : '') + (e.agent ? '' + esc(e.agent) + '' : '') + '' + esc(e.text) + '' + '
'; } function renderConsole(id, entries) { var body = document.querySelector('[data-console-body="' + id + '"]'); if (!body) return; if (!entries || !entries.length) { body.innerHTML = '
waiting for output…
'; 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 '
' + '' + esc(when) + '' + '' + esc(a.kind || '') + '' + '
' + esc(a.summary || '') + '
' + '' + esc(a.actor || '') + '' + '
'; } 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 = [ '
' + esc(task.id || id) + '' + esc(statusLabel(status)) + '' + (task.assignedTo ? agentAvatar(task.assignedTo, task.role) : '') + '
', '

' + esc(task.title || id) + '

' ]; if (desc) { sections.push('

Description

' + esc(desc) + '
'); } if (activity && activity.length) { sections.push('

Activity

' + activity.map(detailActivityHtml).join('') + '
'); } 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 = '
loading…
'; try { var results = await Promise.all([ getJSON(taskPath(id)), getJSON(taskPath(id) + '/activity') ]); renderTaskDetailModal(id, results[0], results[1] || []); } catch (err) { body.innerHTML = '
Could not load ' + esc(id) + '.
'; } } 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('' + esc(id) + (status ? ' → ' + esc(status) : ' updated') + (agent ? ' · @' + esc(agent) : '') + ''); } 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 = '' + esc(msg) + ''; 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 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 — 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 ' + esc(id) + ' — 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 — 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); `; }