From 1749f1c58c1943637ac99ea944844506a6783ce1 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 8 Jul 2026 21:11:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(board):=20v2=20=E2=80=94=20drop=20handoffs?= =?UTF-8?q?/decisions=20panels,=20tabbed+throttled=20company=20donut,=20re?= =?UTF-8?q?al-vs-estimated=20agent=20bars=20with=20FLIP=20reorder,=20sessi?= =?UTF-8?q?on/total=20reset,=20fixed-height=20scrollable=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex TSK-0097 board redesign v2. Co-Authored-By: Claude Opus 4.8 --- src/core/services/budgetService.ts | 18 +- src/server/board.ts | 355 ++++++++++++++++++++--------- tests/server.test.ts | 16 +- 3 files changed, 259 insertions(+), 130 deletions(-) diff --git a/src/core/services/budgetService.ts b/src/core/services/budgetService.ts index 8c2c6d5..44482bf 100644 --- a/src/core/services/budgetService.ts +++ b/src/core/services/budgetService.ts @@ -11,8 +11,9 @@ import { getRoster, inferKind, type RosterEntry } from './rosterService.js'; * 1. REAL — `doneTokens` recorded on a task (via `task done --tokens`). * 2. EST. — an estimate derived from time-on-task: an active agent burns * roughly TOKENS_PER_ACTIVE_MIN while a task sits in_progress / - * review, so the estimate grows live as work happens. Done tasks - * without a recorded count fall back to their total active span. + * review, capped aggressively so stale status does not dominate. + * Done tasks without a recorded count fall back to their total + * active span with a separate cap. * * Every estimated figure is flagged `estimated:true` so the UI can mark it * with a "~" — no silent fabrication. @@ -22,7 +23,7 @@ import { getRoster, inferKind, type RosterEntry } from './rosterService.js'; export const TOKENS_PER_ACTIVE_MIN = 3000; /** - * Hard cap on ESTIMATED active minutes per task. Wall-clock time-in-state is a + * Hard cap on ESTIMATED done-task minutes. Wall-clock time-in-state is a * bad proxy for actual compute: a task can sit "in review" or "done" for days * while the real work was minutes. Without this cap a task open for 12 days * would estimate ~50M tokens. A single coding task rarely exceeds ~45 min of @@ -31,6 +32,13 @@ export const TOKENS_PER_ACTIVE_MIN = 3000; */ export const MAX_ACTIVE_MIN_PER_TASK = 45; +/** + * Active in_progress/review tasks are the least reliable source: external + * agents can stay in a state for hours/days while doing no compute. Keep the + * live estimate useful without letting one stale task flatten the distribution. + */ +export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12; + /** USD → EUR display rate (rough, labelled approximate in the UI). */ const USD_TO_EUR = 0.92; @@ -87,6 +95,8 @@ function tokensForTask(cwd: string, id: string, status: string, createdAt: strin } } else if (status === 'in_progress' || status === 'review') { ms = now - (isNaN(updated) ? now : updated); // since it was claimed / sent to review + const minutes = Math.min(Math.max(0, ms) / 60_000, MAX_LIVE_ESTIMATE_MIN_PER_TASK); + return { tokens: Math.round(minutes * TOKENS_PER_ACTIVE_MIN), estimated: true }; } else { return { tokens: 0, estimated: true }; // open / cancelled — no work yet } @@ -191,7 +201,7 @@ export function computeBudget(cwd: string): BudgetReport { totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated }, assumptions: { tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN, - note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (capped at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`, + note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`, }, generatedAt: new Date().toISOString(), }; diff --git a/src/server/board.ts b/src/server/board.ts index 36eb93a..cb7582c 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -249,7 +249,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); gap: 14px; - align-items: start; + align-items: stretch; } .board-area { min-width: 0; @@ -261,8 +261,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; - align-items: start; + align-items: stretch; min-width: 0; + height: clamp(420px, calc(100vh - 210px), 760px); } .column { min-width: 0; @@ -270,7 +271,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border: 1px solid var(--border); border-radius: 8px; padding: 10px; - min-height: 88px; + min-height: 0; + display: flex; + flex-direction: column; box-shadow: 0 14px 36px rgba(2, 6, 18, .18); } .col-head { @@ -305,6 +308,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 8px; min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 2px; align-items: start; } .card { @@ -316,8 +323,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border-left: 3px solid var(--accent); border-radius: 8px; padding: 9px 10px; - min-height: 164px; - aspect-ratio: 1; + min-height: 142px; display: flex; flex-direction: column; user-select: none; @@ -426,32 +432,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { } .empty { color: var(--muted); font-size: 12px; padding: 4px 2px; } - .panels { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-top: 20px; - } - .panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px 14px; - } - .panel h2 { font-size: 13px; margin: 0 0 10px; font-weight: 700; } - .row { - display: flex; - gap: 8px; - align-items: center; - padding: 7px 0; - border-top: 1px solid var(--border); - } - .row:first-of-type { border-top: 0; } - .row .what { flex: 1; min-width: 0; overflow-wrap: anywhere; } - .row .who-cell { display: flex; align-items: center; gap: 4px; font-size: 12px; white-space: nowrap; } - .row .who { display: flex; align-items: center; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; } - .row .when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; } - @media (max-width: 1180px) { .dashboard { grid-template-columns: 1fr; } } @@ -460,7 +440,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .app-header { padding-left: 14px; padding-right: 14px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board { grid-template-columns: repeat(2, 1fr); } - .panels { grid-template-columns: 1fr; } .project-name { max-width: 50vw; } } @media (max-width: 560px) { @@ -589,7 +568,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { /* ── In-card live agent console ─────────────────────────────────────── */ .card-console-wrap { margin-top: 9px; } .card:has(.card-console:not([hidden])) { - aspect-ratio: auto; min-height: 230px; } .card-console-toggle { @@ -655,6 +633,36 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { color: var(--text); } .budget-head .total small { color: var(--muted); font-weight: 400; } + .insight-actions { + flex-basis: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .seg { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(15, 23, 42, .46); + } + .seg button, .reset-btn { + min-height: 28px; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + font: 700 11px/1 var(--mono); + padding: 0 9px; + cursor: pointer; + transition: color 160ms ease, background 160ms ease; + } + .seg button.active { color: var(--text); background: var(--raised); } + .reset-btn { border: 1px solid rgba(148, 163, 184, .22); } + .reset-btn:hover, .seg button:hover { color: var(--text); background: rgba(148, 163, 184, .12); } .donut-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } .donut-card { min-width: 0; @@ -664,6 +672,17 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { background: rgba(15, 23, 42, .44); } .donut-title { margin: 0 0 6px; color: var(--muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; } + .donut-tabs { display: flex; justify-content: flex-end; gap: 3px; margin: -2px 0 4px; } + .donut-tabs button { + min-height: 26px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--muted); + font: 700 10.5px/1 var(--mono); + padding: 0 8px; + } + .donut-tabs button.active { background: var(--raised); color: var(--text); } .half-donut { position: relative; min-height: 132px; display: grid; place-items: center; } .half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; } .donut-segment { @@ -690,7 +709,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .legend-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .agent-bars { margin-top: 14px; } .agent-bars h3 { margin: 0 0 10px; font: 700 12px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } - .agent-bar-row { display: grid; grid-template-columns: minmax(96px, 1fr) 1.35fr; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border); } + .agent-bar-row { display: grid; grid-template-columns: minmax(96px, 1fr) 1.35fr; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border); transition: transform 260ms cubic-bezier(.2,.7,.2,1); } .agent-bar-row:first-of-type { border-top: 0; } .budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; } .budget-agent .ba-core { @@ -748,7 +767,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { } @media (max-width: 920px) { .cards { grid-template-columns: 1fr; } - .card { aspect-ratio: auto; min-height: 142px; } + .board { height: auto; } + .column { max-height: 64vh; } + .cards { grid-template-columns: 1fr; } + .card { min-height: 142px; } } @media (prefers-reduced-motion: reduce) { @@ -807,17 +829,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
${columnSkeleton()}
- -
-
-

Handoffs

-
loading
-
-
-

Decisions

-
loading
-
-
@@ -1115,37 +1133,6 @@ ${columnSkeleton()} if (panel) { panel.hidden = false; loadConsole(id); } }); } - function handoffRoute(h) { - var from = '' + esc(h.fromRole || '?') + ''; - var to = '' + esc(h.toRole || '?') + ''; - if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole); - if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole); - return from + '' + to; - } - function renderHandoffs(items) { - var el = document.getElementById('handoffs'); - if (!items || !items.length) { el.innerHTML = '
none
'; return; } - el.innerHTML = items.slice(0, 12).map(function(h) { - return '
' + - '' + esc(h.id) + '' + - '' + esc(h.title) + '' + - '' + handoffRoute(h) + '' + - '' + esc(ago(h.createdAt)) + '' + - '
'; - }).join(''); - } - function renderDecisions(items) { - var el = document.getElementById('decisions'); - if (!items || !items.length) { el.innerHTML = '
none
'; return; } - el.innerHTML = items.slice(0, 12).map(function(d) { - var st = d.status ? '' + esc(d.status) + '' : ''; - return '
' + - '' + esc(d.id) + '' + - '' + esc(d.title) + ' ' + st + '' + - '' + esc(ago(d.createdAt)) + '' + - '
'; - }).join(''); - } function setConn(state, label) { var el = document.getElementById('sseStatus'); var text = document.getElementById('sseLabel'); @@ -1176,14 +1163,6 @@ ${columnSkeleton()} } catch (_) { setConn('down', 'offline'); } - try { - var handoffs = await getJSON('/handoffs'); - renderHandoffs(handoffs); - } catch (_) {} - try { - var decisions = await getJSON('/decisions'); - renderDecisions(decisions); - } catch (_) {} } var eventSourceReady = false; var fallbackPollTimer = null; @@ -1234,6 +1213,11 @@ ${columnSkeleton()} // known agent when the architect drags a card into In Progress. var AGENTS = []; var BUDGET = null; + var budgetMode = localStorage.getItem('agenthub-budget-mode') || 'session'; + var donutMetric = localStorage.getItem('agenthub-donut-metric') || 'tokens'; + var lastDonutSignature = ''; + var lastDonutAt = 0; + var DONUT_REFRESH_MS = 180000; async function loadAgents() { try { @@ -1250,6 +1234,56 @@ ${columnSkeleton()} } function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); } function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); } + function cloneAgent(a) { + return { + name: a.name, + role: a.role, + model: a.model, + kind: a.kind, + tokens: Number(a.tokens) || 0, + realTokens: Number(a.realTokens) || 0, + estimatedTokens: Number(a.estimatedTokens) || 0, + estimated: !!a.estimated, + costEur: Number(a.costEur) || 0, + taskCount: Number(a.taskCount) || 0 + }; + } + function currentSnapshot(rep) { + var byAgent = {}; + (rep && rep.agents ? rep.agents : []).forEach(function(a) { + byAgent[a.name] = { tokens: Number(a.tokens) || 0, costEur: Number(a.costEur) || 0 }; + }); + return { createdAt: Date.now(), byAgent: byAgent }; + } + function readBaseline() { + try { return JSON.parse(localStorage.getItem('agenthub-budget-baseline') || 'null'); } catch (_) { return null; } + } + function writeBaseline(rep) { + localStorage.setItem('agenthub-budget-baseline', JSON.stringify(currentSnapshot(rep))); + } + function applyBudgetMode(rep) { + var agents = (rep && rep.agents ? rep.agents : []).map(cloneAgent); + if (budgetMode === 'session') { + var base = readBaseline(); + if (!base) { writeBaseline(rep); base = readBaseline(); } + agents.forEach(function(a) { + var b = base && base.byAgent ? base.byAgent[a.name] : null; + var baseTokens = b ? Number(b.tokens) || 0 : 0; + var baseCost = b ? Number(b.costEur) || 0 : 0; + a.tokens = Math.max(0, a.tokens - baseTokens); + a.costEur = Math.max(0, a.costEur - baseCost); + a.realTokens = Math.min(a.realTokens, a.tokens); + a.estimatedTokens = Math.max(0, a.tokens - a.realTokens); + a.estimated = a.estimated && a.estimatedTokens > 0; + }); + } + var totals = { + tokens: agents.reduce(function(s, a) { return s + (a.tokens || 0); }, 0), + costEur: agents.reduce(function(s, a) { return s + (a.costEur || 0); }, 0), + estimated: agents.some(function(a) { return a.estimated; }) + }; + return { agents: agents, totals: totals }; + } function companySpec(kind) { var key = String(kind || '').toLowerCase(); var map = { @@ -1302,6 +1336,7 @@ ${columnSkeleton()} }).join('') : '
no provider data
'; return '
' + '

' + esc(title) + '

' + + '
' + '
' + '' + paths + '' + '
0' + esc(centerLabel) + '
' + @@ -1325,47 +1360,141 @@ ${columnSkeleton()} requestAnimationFrame(step); }); } - function renderBudget(rep) { + function signatureFor(rows) { + return rows.map(function(r) { return r.kind + ':' + Math.round((Number(r.value) || 0) * 100); }).join('|'); + } + function shouldRenderDonut(nextSig, force) { + if (force) return true; + var now = Date.now(); + if (!lastDonutSignature || now - lastDonutAt > DONUT_REFRESH_MS) return true; + if (nextSig === lastDonutSignature) return false; + var prev = {}; + lastDonutSignature.split('|').forEach(function(part) { + if (!part) return; + var bits = part.split(':'); + prev[bits[0]] = Number(bits[1]) || 0; + }); + var changed = false; + nextSig.split('|').forEach(function(part) { + if (!part) return; + var bits = part.split(':'); + var oldVal = prev[bits[0]] || 0; + var newVal = Number(bits[1]) || 0; + if (Math.abs(newVal - oldVal) / Math.max(1, oldVal) > 0.10) changed = true; + }); + return changed; + } + function renderDonut(active, force) { + var wrap = document.getElementById('donutWrap'); + if (!wrap) return; + var field = donutMetric === 'cost' ? 'costEur' : 'tokens'; + var companies = aggregateByCompany(active, field); + var sig = donutMetric + ':' + signatureFor(companies); + if (!shouldRenderDonut(sig, force)) return; + lastDonutSignature = sig; + lastDonutAt = Date.now(); + var center = active.reduce(function(s, a) { return s + (Number(a[field]) || 0); }, 0); + wrap.innerHTML = halfDonut(donutMetric === 'cost' ? 'Cost by Company' : 'Tokens by Company', companies, center, donutMetric === 'cost' ? 'EUR' : 'tokens', donutMetric === 'cost' ? fmtEur : fmtTokens); + animateCounts(wrap); + } + function agentBarHtml(a, mx) { + var spec = agentSpec(a.name); + var est = a.estimated ? '~ estimated' : 'real'; + var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0; + var company = companySpec(a.kind); + return '
' + + '
' + esc(spec.initials) + '' + + '' + esc(a.name) + '' + + '' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '
' + + '
' + + '
' + fmtTokens(a.tokens) + ' tok' + esc(est) + ' · ' + fmtEur(a.costEur) + '
' + + '
'; + } + function renderAgentBars(active) { + var host = document.getElementById('agentBars'); + if (!host) return; + var before = {}; + host.querySelectorAll('[data-agent-row]').forEach(function(el) { + before[el.getAttribute('data-agent-row')] = el.getBoundingClientRect(); + }); + var mx = maxTok(active) || 1; + var sorted = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0) || a.name.localeCompare(b.name); }); + host.innerHTML = '

Token-Verbrauch pro Agent

' + sorted.map(function(a) { return agentBarHtml(a, mx); }).join(''); + host.querySelectorAll('[data-agent-row]').forEach(function(el) { + var key = el.getAttribute('data-agent-row'); + var old = before[key]; + if (!old) return; + var now = el.getBoundingClientRect(); + var dy = old.top - now.top; + if (!dy) return; + el.style.transform = 'translateY(' + dy + 'px)'; + requestAnimationFrame(function() { el.style.transform = ''; }); + }); + } + function updateBudgetButtons() { + document.querySelectorAll('[data-budget-mode]').forEach(function(btn) { + btn.classList.toggle('active', btn.getAttribute('data-budget-mode') === budgetMode); + }); + document.querySelectorAll('[data-donut-tab]').forEach(function(btn) { + btn.classList.toggle('active', btn.getAttribute('data-donut-tab') === donutMetric); + }); + } + function renderBudget(rep, opts) { + opts = opts || {}; BUDGET = rep; var rows = document.getElementById('budgetRows'); var total = document.getElementById('budgetTotal'); if (!rows || !total) return; - var active = (rep && rep.agents ? rep.agents : []).filter(function(a) { return a.kind && a.tokens > 0; }); + var scoped = applyBudgetMode(rep); + var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; }); + updateBudgetButtons(); if (!active.length) { - rows.innerHTML = '
no real agent token data yet
'; + rows.innerHTML = '
no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '
'; total.textContent = ''; + lastDonutSignature = ''; return; } - var mx = maxTok(active) || 1; - var tokenCompanies = aggregateByCompany(active, 'tokens'); - var costCompanies = aggregateByCompany(active, 'costEur'); - var bars = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0); }).map(function(a) { - var spec = agentSpec(a.name); - var est = a.estimated ? '~' : ''; - var pct = Math.max(4, Math.round(((a.tokens || 0) / mx) * 100)); - var company = companySpec(a.kind); - return '
' + - '
' + esc(spec.initials) + '' + - '' + esc(a.name) + '' + - '' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '
' + - '
' + - '
' + est + fmtTokens(a.tokens) + ' tok' + fmtEur(a.costEur) + '
' + - '
'; - }).join(''); - rows.innerHTML = - '
' + - halfDonut('Tokens by Company', tokenCompanies, active.reduce(function(s, a) { return s + (a.tokens || 0); }, 0), 'tokens', fmtTokens) + - halfDonut('Cost by Company', costCompanies, active.reduce(function(s, a) { return s + (a.costEur || 0); }, 0), 'EUR', fmtEur) + - '
' + - '

Token-Verbrauch pro Agent

' + bars + '
'; - total.innerHTML = (rep.totals.estimated ? '~' : '') + fmtTokens(rep.totals.tokens) + - ' tok \\u00b7 \\u2248 ' + fmtEur(rep.totals.costEur) + ''; - animateCounts(rows); + if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) { + rows.innerHTML = '
'; + } + renderDonut(active, opts.forceDonut); + renderAgentBars(active); + total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) + + ' tok \\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + ''; } async function refreshBudget() { try { renderBudget(await getJSON('/budget')); } catch (_) {} } + document.addEventListener('click', function(e) { + var modeBtn = e.target.closest && e.target.closest('[data-budget-mode]'); + if (modeBtn) { + budgetMode = modeBtn.getAttribute('data-budget-mode') || 'session'; + localStorage.setItem('agenthub-budget-mode', budgetMode); + lastDonutSignature = ''; + if (BUDGET) renderBudget(BUDGET, { forceDonut: true }); + return; + } + var tabBtn = e.target.closest && e.target.closest('[data-donut-tab]'); + if (tabBtn) { + donutMetric = tabBtn.getAttribute('data-donut-tab') || 'tokens'; + localStorage.setItem('agenthub-donut-metric', donutMetric); + if (BUDGET) renderBudget(BUDGET, { forceDonut: true }); + return; + } + var reset = e.target.closest && e.target.closest('#budgetReset'); + if (reset) { + if (BUDGET) { + writeBaseline(BUDGET); + budgetMode = 'session'; + localStorage.setItem('agenthub-budget-mode', budgetMode); + lastDonutSignature = ''; + renderBudget(BUDGET, { forceDonut: true }); + toast('Token session reset'); + } + } + }); + // ── Toasts ────────────────────────────────────────────────────────────── function toast(msg, opts) { opts = opts || {}; diff --git a/tests/server.test.ts b/tests/server.test.ts index 34decc0..ab4b948 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -127,11 +127,11 @@ describe('server routes', () => { for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) { expect(html).toContain(`data-column="${col}"`); } - // Handoffs + decisions panels and the polling logic are wired in. - expect(html).toContain('Handoffs'); - expect(html).toContain('Decisions'); + // Board polling stays wired; handoffs/decisions now live on dedicated pages. expect(html).toContain("getJSON('/tasks')"); expect(html).toContain('setInterval(refresh'); + expect(html).toContain('Token Insights'); + expect(html).toContain('data-budget-mode="session"'); }); // ── Activity timeline endpoint ───────────────────────────────────────── @@ -192,16 +192,6 @@ describe('server routes', () => { expect(status!.meta?.by).toBe('claude'); }); - it('board HTML contains the who-arrow rendering logic', async () => { - const res = await app.inject({ method: 'GET', url: '/board' }); - const html = res.payload; - // The handoffRoute helper and the → arrow must be present - expect(html).toContain('handoffRoute'); - expect(html).toContain('→'); - // The who-cell class must be used in renderHandoffs - expect(html).toContain('who-cell'); - }); - it('board HTML keeps cards slim and links to task detail pages', async () => { const res = await app.inject({ method: 'GET', url: '/board' }); const html = res.payload;