From b09e4c78d2c29878f6b98db28d9df59650fe3ee6 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 20 Jul 2026 17:44:45 +0200 Subject: [PATCH] feat(board): replace v1 monolith with modular v2 glass dashboard --- src/server/board.ts | 2098 ----------------------------------- src/server/board/chrome.ts | 2 +- src/server/board/columns.ts | 893 +++++++++++++++ src/server/board/index.ts | 55 + src/server/board/kpis.ts | 11 + src/server/routes.ts | 2 +- tests/boardV2.test.ts | 36 + tests/server.test.ts | 6 +- 8 files changed, 1000 insertions(+), 2103 deletions(-) delete mode 100644 src/server/board.ts create mode 100644 src/server/board/columns.ts create mode 100644 src/server/board/index.ts create mode 100644 tests/boardV2.test.ts diff --git a/src/server/board.ts b/src/server/board.ts deleted file mode 100644 index 587dc7a..0000000 --- a/src/server/board.ts +++ /dev/null @@ -1,2098 +0,0 @@ -/** - * Self-contained AgentHub task board, served at `GET /board`. - * - * Constraints: - * - One static HTML page: inline CSS + JS, no framework and no runtime deps. - * - Reads only same-origin endpoints (`/tasks`, `/events` and `/status`). - * - Never mutates state. - */ - -export interface BoardColumn { - key: string; - label: string; -} - -/** Task statuses, in board order. Mirrors `TaskStatus` in core/schema.ts. */ -export const BOARD_COLUMNS: BoardColumn[] = [ - { key: 'open', label: 'Open' }, - { key: 'in_progress', label: 'In Progress' }, - { key: 'review', label: 'Review' }, - { key: 'done', label: 'Done' }, - { key: 'cancelled', label: 'Cancelled' }, -]; - -const ACTIVE_COLUMNS = BOARD_COLUMNS.filter((c) => ['open', 'in_progress', 'review'].includes(c.key)); - -function columnSkeleton(): string { - return ACTIVE_COLUMNS.map( - (c) => `
-
- ${c.label} - 0 -
-
-
`, - ).join('\n'); -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -export function renderBoardHtml(projectName = 'AgentHub Project'): string { - const initialProjectName = JSON.stringify(projectName).replace(/ - - - - - - AgentHub Board - - - -
-
- -
- AgentHub - ${projectNameHtml} -
-
- - - - - - connecting - -
- -
-
-
-
Active tasks
0
-
Open
0
-
In progress
0
-
Review
0
-
Done total
0
-
Agents busy
0/0
-
-
- -
-
-
-${columnSkeleton()} -
-
- - -
-
- - - - - - - -
- - - - - - -`; -} diff --git a/src/server/board/chrome.ts b/src/server/board/chrome.ts index 22b2430..af264ba 100644 --- a/src/server/board/chrome.ts +++ b/src/server/board/chrome.ts @@ -13,7 +13,7 @@ export function headerHtml(projectName: string): string { return `
${LOGO}agenthub - / ${name}  + / ${name}  diff --git a/src/server/board/columns.ts b/src/server/board/columns.ts new file mode 100644 index 0000000..6c3755d --- /dev/null +++ b/src/server/board/columns.ts @@ -0,0 +1,893 @@ +/** + * Board v2 columns/cards/modals/data-layer: a faithful port of the v1 client + * JS (old src/server/board.ts, lines 975–1460 and 1752–2032) with these + * deliberate changes: + * - v1 KPI metric cards + Lottie icons are gone; renderBoard() instead calls + * window.__b2UpdateKpis / __b2RenderThroughput (kpis.ts / sidebar.ts). + * - taskCard() adds the b2-working glow and the in-progress PROGRESS RING + * (elapsed vs median-done-duration estimate; amber when over estimate). + * - SSE task events additionally push a line into the live feed + * (window.__b2FeedPush, sidebar.ts). + * - The init sequence is NOT included here (see initJs below) so index.ts + * can run it after all module scripts are defined. + * Everything else (patchColumn reconcile, drag & drop, race-guarded moves, + * live console, modals, toasts, connection state) is byte-faithful to v1. + */ +export interface BoardColumnDef { + key: string; + label: string; +} + +/** Column skeletons; mount points match the ported JS (data-cards/data-count). */ +export function columnsHtml(columns: BoardColumnDef[]): string { + const cols = columns + .map( + (c) => `
+
${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…
' + + '
' + + '
' + : ''; + var ring = live + ? '' + + '' + + '' + + '' + : ''; + 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); +`; +} diff --git a/src/server/board/index.ts b/src/server/board/index.ts new file mode 100644 index 0000000..88cc39b --- /dev/null +++ b/src/server/board/index.ts @@ -0,0 +1,55 @@ +import { boardV2Css } from './styles.js'; +import { headerHtml, splashHtml, splashJs } from './chrome.js'; +import { kpiSkeletonHtml, kpiJs } from './kpis.js'; +import { sidebarHtml, sidebarJs } from './sidebar.js'; +import { columnsHtml, columnsJs, modalsHtml, initJs, type BoardColumnDef } from './columns.js'; +import { v1BudgetJs } from './v1Budget.js'; + +/** All statuses the data layer understands (order = board semantics). */ +export const BOARD_COLUMNS: BoardColumnDef[] = [ + { key: 'open', label: 'Open' }, + { key: 'in_progress', label: 'In Progress' }, + { key: 'review', label: 'Review' }, + { key: 'done', label: 'Done' }, + { key: 'cancelled', label: 'Cancelled' }, +]; + +/** The three lanes actually rendered as columns. */ +export const ACTIVE_COLUMNS: BoardColumnDef[] = BOARD_COLUMNS.slice(0, 3); + +/** + * Board v2: glass dashboard rendered once at server start; all data arrives + * via same-origin fetches + SSE. The single inline script is ordered so the + * init sequence (initJs) runs after every module's definitions exist. + */ +export function renderBoardHtml(projectName = 'AgentHub Project'): string { + return ` + + + + +agenthub — Board + + + + +${splashHtml()} +${headerHtml(projectName)} +
+
+ ${kpiSkeletonHtml()} + ${columnsHtml(ACTIVE_COLUMNS)} +
+ ${sidebarHtml()} +
+${modalsHtml()} + + + + +`; +} diff --git a/src/server/board/kpis.ts b/src/server/board/kpis.ts index e65e7f0..6d3581f 100644 --- a/src/server/board/kpis.ts +++ b/src/server/board/kpis.ts @@ -46,6 +46,14 @@ ${backlogSeries.toString()} ${areaPath.toString()} ${laneChips.toString()} window.__b2UpdateKpis = function (tasks, agentColor) { + var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {}); + function flashKpi(card, value) { + if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) { + var cardEl = document.getElementById(card); + if (cardEl) { cardEl.classList.remove('metric-flash'); void cardEl.offsetWidth; cardEl.classList.add('metric-flash'); } + } + kpiPrev[card] = value; + } function setK(card, key, fn) { var el = document.querySelector('#' + card + ' [data-k="' + key + '"]'); if (el) fn(el); @@ -53,6 +61,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) { // Open var open = tasks.filter(function (t) { return t.status === 'open'; }); setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); }); + flashKpi('kpiOpen', String(open.length)); setK('kpiOpen', 'total', function (el) { el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length; }); @@ -68,6 +77,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) { var chips = laneChips(tasks, status); var capped = capChips(chips, 3); setK(card, 'count', function (el) { el.textContent = String(chips.length); }); + flashKpi(card, String(chips.length)); setK(card, 'chips', function (el) { el.innerHTML = capped.visible.map(function (c) { var color = agentColor ? agentColor(c.name) : '#a5b4fc'; @@ -81,6 +91,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) { // Done var ds = doneStats(tasks); setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); }); + flashKpi('kpiDone', String(ds.done)); setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; }); setK('kpiDone', 'cap', function (el) { el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche'; diff --git a/src/server/routes.ts b/src/server/routes.ts index efe0560..15a2433 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -13,7 +13,7 @@ import { computeBudget } from '../core/services/budgetService.js'; import { getRoster } from '../core/services/rosterService.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { renderActivityHtml } from './activity.js'; -import { renderBoardHtml } from './board.js'; +import { renderBoardHtml } from './board/index.js'; import { renderTeamHtml } from './team.js'; import { renderArchiveHtml } from './archive.js'; import { renderDecisionsHtml } from './decisions.js'; diff --git a/tests/boardV2.test.ts b/tests/boardV2.test.ts new file mode 100644 index 0000000..de419cb --- /dev/null +++ b/tests/boardV2.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { renderBoardHtml } from '../src/server/board/index.js'; + +describe('renderBoardHtml (v2)', () => { + const html = renderBoardHtml('demo-project'); + + it('is a full html document with favicon and splash', () => { + expect(html).toMatch(/^/i); + expect(html).toContain('rel="icon" href="/logo.svg"'); + expect(html).toContain('id="b2-splash"'); + }); + it('contains header, kpis, columns and sidebar mount points', () => { + for (const s of ['b2-hdr', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) { + expect(html).toContain(s); + } + }); + it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => { + for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'taskModal']) { + expect(html).toContain(s); + } + }); + it('renders in-progress cards with a progress ring and working glow', () => { + expect(html).toContain('b2-ring'); + expect(html).toContain('b2-working'); + }); + it('does not ship the old v1 metric cards or lottie kpi icons', () => { + expect(html).not.toContain('metric-card'); + expect(html).not.toContain('lottie'); + }); + it('runs the init sequence after all module definitions', () => { + const defIdx = html.indexOf('window.__b2UpdateKpis ='); + const initIdx = html.indexOf('loadStatusMeta();'); + expect(defIdx).toBeGreaterThan(-1); + expect(initIdx).toBeGreaterThan(defIdx); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 9fcb5d7..426d904 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -143,9 +143,9 @@ describe('server routes', () => { expect(res.headers['content-type']).toContain('text/html'); const html = res.payload; - expect(html).toContain('AgentHub Board'); - // All five status columns are present in the static markup. - for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) { + expect(html).toContain('agenthub — Board'); + // The three active lanes are present in the static markup (board v2). + for (const col of ['open', 'in_progress', 'review']) { expect(html).toContain(`data-column="${col}"`); } // Board polling stays wired; handoffs/decisions now live on dedicated pages.