feat(board): v2 — drop handoffs/decisions panels, tabbed+throttled company donut, real-vs-estimated agent bars with FLIP reorder, session/total reset, fixed-height scrollable columns
codex TSK-0097 board redesign v2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0884032db1
commit
1749f1c58c
@ -11,8 +11,9 @@ import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
|
|||||||
* 1. REAL — `doneTokens` recorded on a task (via `task done --tokens`).
|
* 1. REAL — `doneTokens` recorded on a task (via `task done --tokens`).
|
||||||
* 2. EST. — an estimate derived from time-on-task: an active agent burns
|
* 2. EST. — an estimate derived from time-on-task: an active agent burns
|
||||||
* roughly TOKENS_PER_ACTIVE_MIN while a task sits in_progress /
|
* roughly TOKENS_PER_ACTIVE_MIN while a task sits in_progress /
|
||||||
* review, so the estimate grows live as work happens. Done tasks
|
* review, capped aggressively so stale status does not dominate.
|
||||||
* without a recorded count fall back to their total active span.
|
* 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
|
* Every estimated figure is flagged `estimated:true` so the UI can mark it
|
||||||
* with a "~" — no silent fabrication.
|
* with a "~" — no silent fabrication.
|
||||||
@ -22,7 +23,7 @@ import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
|
|||||||
export const TOKENS_PER_ACTIVE_MIN = 3000;
|
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
|
* 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
|
* 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
|
* 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;
|
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). */
|
/** USD → EUR display rate (rough, labelled approximate in the UI). */
|
||||||
const USD_TO_EUR = 0.92;
|
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') {
|
} else if (status === 'in_progress' || status === 'review') {
|
||||||
ms = now - (isNaN(updated) ? now : updated); // since it was claimed / sent to 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 {
|
} else {
|
||||||
return { tokens: 0, estimated: true }; // open / cancelled — no work yet
|
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 },
|
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
||||||
assumptions: {
|
assumptions: {
|
||||||
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
|
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(),
|
generatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -249,7 +249,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.board-area {
|
.board-area {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -261,8 +261,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: clamp(420px, calc(100vh - 210px), 760px);
|
||||||
}
|
}
|
||||||
.column {
|
.column {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -270,7 +271,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
min-height: 88px;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
box-shadow: 0 14px 36px rgba(2, 6, 18, .18);
|
box-shadow: 0 14px 36px rgba(2, 6, 18, .18);
|
||||||
}
|
}
|
||||||
.col-head {
|
.col-head {
|
||||||
@ -305,6 +308,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: 2px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
@ -316,8 +323,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
min-height: 164px;
|
min-height: 142px;
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@ -426,32 +432,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
}
|
}
|
||||||
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
.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) {
|
@media (max-width: 1180px) {
|
||||||
.dashboard { grid-template-columns: 1fr; }
|
.dashboard { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@ -460,7 +440,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
.app-header { padding-left: 14px; padding-right: 14px; }
|
.app-header { padding-left: 14px; padding-right: 14px; }
|
||||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.board { grid-template-columns: repeat(2, 1fr); }
|
.board { grid-template-columns: repeat(2, 1fr); }
|
||||||
.panels { grid-template-columns: 1fr; }
|
|
||||||
.project-name { max-width: 50vw; }
|
.project-name { max-width: 50vw; }
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
@ -589,7 +568,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
/* ── In-card live agent console ─────────────────────────────────────── */
|
/* ── In-card live agent console ─────────────────────────────────────── */
|
||||||
.card-console-wrap { margin-top: 9px; }
|
.card-console-wrap { margin-top: 9px; }
|
||||||
.card:has(.card-console:not([hidden])) {
|
.card:has(.card-console:not([hidden])) {
|
||||||
aspect-ratio: auto;
|
|
||||||
min-height: 230px;
|
min-height: 230px;
|
||||||
}
|
}
|
||||||
.card-console-toggle {
|
.card-console-toggle {
|
||||||
@ -655,6 +633,36 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.budget-head .total small { color: var(--muted); font-weight: 400; }
|
.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-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||||
.donut-card {
|
.donut-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -664,6 +672,17 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
background: rgba(15, 23, 42, .44);
|
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-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 { position: relative; min-height: 132px; display: grid; place-items: center; }
|
||||||
.half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; }
|
.half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; }
|
||||||
.donut-segment {
|
.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; }
|
.legend-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.agent-bars { margin-top: 14px; }
|
.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-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; }
|
.agent-bar-row:first-of-type { border-top: 0; }
|
||||||
.budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
.budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
.budget-agent .ba-core {
|
.budget-agent .ba-core {
|
||||||
@ -748,7 +767,10 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
}
|
}
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
.cards { grid-template-columns: 1fr; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@ -807,17 +829,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
<section class="board" id="board" aria-label="Task board">
|
<section class="board" id="board" aria-label="Task board">
|
||||||
${columnSkeleton()}
|
${columnSkeleton()}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panels" aria-label="Coordination context">
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Handoffs</h2>
|
|
||||||
<div id="handoffs"><div class="empty">loading</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Decisions</h2>
|
|
||||||
<div id="decisions"><div class="empty">loading</div></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="budget" id="budget" aria-label="Token insights">
|
<aside class="budget" id="budget" aria-label="Token insights">
|
||||||
@ -825,6 +836,13 @@ ${columnSkeleton()}
|
|||||||
<h2>Token Insights</h2>
|
<h2>Token Insights</h2>
|
||||||
<span class="est-note" id="budgetNote">real agents only</span>
|
<span class="est-note" id="budgetNote">real agents only</span>
|
||||||
<span class="total" id="budgetTotal"></span>
|
<span class="total" id="budgetTotal"></span>
|
||||||
|
<div class="insight-actions">
|
||||||
|
<span class="seg" aria-label="Token insight range">
|
||||||
|
<button type="button" data-budget-mode="session" class="active">Session</button>
|
||||||
|
<button type="button" data-budget-mode="total">Gesamt</button>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -1115,37 +1133,6 @@ ${columnSkeleton()}
|
|||||||
if (panel) { panel.hidden = false; loadConsole(id); }
|
if (panel) { panel.hidden = false; loadConsole(id); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function handoffRoute(h) {
|
|
||||||
var from = '<span class="who">' + esc(h.fromRole || '?') + '</span>';
|
|
||||||
var to = '<span class="who">' + esc(h.toRole || '?') + '</span>';
|
|
||||||
if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole);
|
|
||||||
if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole);
|
|
||||||
return from + '<span class="id">→</span>' + to;
|
|
||||||
}
|
|
||||||
function renderHandoffs(items) {
|
|
||||||
var el = document.getElementById('handoffs');
|
|
||||||
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
||||||
el.innerHTML = items.slice(0, 12).map(function(h) {
|
|
||||||
return '<div class="row">' +
|
|
||||||
'<span class="id">' + esc(h.id) + '</span>' +
|
|
||||||
'<span class="what">' + esc(h.title) + '</span>' +
|
|
||||||
'<span class="who-cell">' + handoffRoute(h) + '</span>' +
|
|
||||||
'<span class="when">' + esc(ago(h.createdAt)) + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
function renderDecisions(items) {
|
|
||||||
var el = document.getElementById('decisions');
|
|
||||||
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
||||||
el.innerHTML = items.slice(0, 12).map(function(d) {
|
|
||||||
var st = d.status ? '<span class="badge">' + esc(d.status) + '</span>' : '';
|
|
||||||
return '<div class="row">' +
|
|
||||||
'<span class="id">' + esc(d.id) + '</span>' +
|
|
||||||
'<span class="what">' + esc(d.title) + ' ' + st + '</span>' +
|
|
||||||
'<span class="when">' + esc(ago(d.createdAt)) + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
function setConn(state, label) {
|
function setConn(state, label) {
|
||||||
var el = document.getElementById('sseStatus');
|
var el = document.getElementById('sseStatus');
|
||||||
var text = document.getElementById('sseLabel');
|
var text = document.getElementById('sseLabel');
|
||||||
@ -1176,14 +1163,6 @@ ${columnSkeleton()}
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
setConn('down', 'offline');
|
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 eventSourceReady = false;
|
||||||
var fallbackPollTimer = null;
|
var fallbackPollTimer = null;
|
||||||
@ -1234,6 +1213,11 @@ ${columnSkeleton()}
|
|||||||
// known agent when the architect drags a card into In Progress.
|
// known agent when the architect drags a card into In Progress.
|
||||||
var AGENTS = [];
|
var AGENTS = [];
|
||||||
var BUDGET = null;
|
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() {
|
async function loadAgents() {
|
||||||
try {
|
try {
|
||||||
@ -1250,6 +1234,56 @@ ${columnSkeleton()}
|
|||||||
}
|
}
|
||||||
function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
|
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 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) {
|
function companySpec(kind) {
|
||||||
var key = String(kind || '').toLowerCase();
|
var key = String(kind || '').toLowerCase();
|
||||||
var map = {
|
var map = {
|
||||||
@ -1302,6 +1336,7 @@ ${columnSkeleton()}
|
|||||||
}).join('') : '<div class="empty">no provider data</div>';
|
}).join('') : '<div class="empty">no provider data</div>';
|
||||||
return '<div class="donut-card">' +
|
return '<div class="donut-card">' +
|
||||||
'<h3 class="donut-title">' + esc(title) + '</h3>' +
|
'<h3 class="donut-title">' + esc(title) + '</h3>' +
|
||||||
|
'<div class="donut-tabs"><button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Tokens</button><button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button></div>' +
|
||||||
'<div class="half-donut">' +
|
'<div class="half-donut">' +
|
||||||
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths + '</svg>' +
|
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths + '</svg>' +
|
||||||
'<div class="donut-center"><span class="donut-value" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</span><span class="donut-label">' + esc(centerLabel) + '</span></div>' +
|
'<div class="donut-center"><span class="donut-value" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</span><span class="donut-label">' + esc(centerLabel) + '</span></div>' +
|
||||||
@ -1325,47 +1360,141 @@ ${columnSkeleton()}
|
|||||||
requestAnimationFrame(step);
|
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 ? '<span class="est">~ estimated</span>' : 'real';
|
||||||
|
var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0;
|
||||||
|
var company = companySpec(a.kind);
|
||||||
|
return '<div class="agent-bar-row" data-agent-row="' + esc(a.name) + '">' +
|
||||||
|
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
|
||||||
|
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
|
||||||
|
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
|
||||||
|
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
|
||||||
|
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + esc(est) + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
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 = '<h3>Token-Verbrauch pro Agent</h3>' + 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;
|
BUDGET = rep;
|
||||||
var rows = document.getElementById('budgetRows');
|
var rows = document.getElementById('budgetRows');
|
||||||
var total = document.getElementById('budgetTotal');
|
var total = document.getElementById('budgetTotal');
|
||||||
if (!rows || !total) return;
|
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) {
|
if (!active.length) {
|
||||||
rows.innerHTML = '<div class="budget-empty">no real agent token data yet</div>';
|
rows.innerHTML = '<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>';
|
||||||
total.textContent = '';
|
total.textContent = '';
|
||||||
|
lastDonutSignature = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var mx = maxTok(active) || 1;
|
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
|
||||||
var tokenCompanies = aggregateByCompany(active, 'tokens');
|
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
|
||||||
var costCompanies = aggregateByCompany(active, 'costEur');
|
}
|
||||||
var bars = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0); }).map(function(a) {
|
renderDonut(active, opts.forceDonut);
|
||||||
var spec = agentSpec(a.name);
|
renderAgentBars(active);
|
||||||
var est = a.estimated ? '<span class="est">~</span>' : '';
|
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
|
||||||
var pct = Math.max(4, Math.round(((a.tokens || 0) / mx) * 100));
|
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
|
||||||
var company = companySpec(a.kind);
|
|
||||||
return '<div class="agent-bar-row">' +
|
|
||||||
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
|
|
||||||
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
|
|
||||||
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
|
|
||||||
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
|
|
||||||
'<div class="agent-bar-meta"><span class="tok">' + est + fmtTokens(a.tokens) + ' tok</span><span>' + fmtEur(a.costEur) + '</span></div></div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
rows.innerHTML =
|
|
||||||
'<div class="donut-grid">' +
|
|
||||||
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) +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="agent-bars"><h3>Token-Verbrauch pro Agent</h3>' + bars + '</div>';
|
|
||||||
total.innerHTML = (rep.totals.estimated ? '~' : '') + fmtTokens(rep.totals.tokens) +
|
|
||||||
' tok <small>\\u00b7 \\u2248 ' + fmtEur(rep.totals.costEur) + '</small>';
|
|
||||||
animateCounts(rows);
|
|
||||||
}
|
}
|
||||||
async function refreshBudget() {
|
async function refreshBudget() {
|
||||||
try { renderBudget(await getJSON('/budget')); } catch (_) {}
|
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 ──────────────────────────────────────────────────────────────
|
// ── Toasts ──────────────────────────────────────────────────────────────
|
||||||
function toast(msg, opts) {
|
function toast(msg, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
|||||||
@ -127,11 +127,11 @@ describe('server routes', () => {
|
|||||||
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
|
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
|
||||||
expect(html).toContain(`data-column="${col}"`);
|
expect(html).toContain(`data-column="${col}"`);
|
||||||
}
|
}
|
||||||
// Handoffs + decisions panels and the polling logic are wired in.
|
// Board polling stays wired; handoffs/decisions now live on dedicated pages.
|
||||||
expect(html).toContain('Handoffs');
|
|
||||||
expect(html).toContain('Decisions');
|
|
||||||
expect(html).toContain("getJSON('/tasks')");
|
expect(html).toContain("getJSON('/tasks')");
|
||||||
expect(html).toContain('setInterval(refresh');
|
expect(html).toContain('setInterval(refresh');
|
||||||
|
expect(html).toContain('Token Insights');
|
||||||
|
expect(html).toContain('data-budget-mode="session"');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Activity timeline endpoint ─────────────────────────────────────────
|
// ── Activity timeline endpoint ─────────────────────────────────────────
|
||||||
@ -192,16 +192,6 @@ describe('server routes', () => {
|
|||||||
expect(status!.meta?.by).toBe('claude');
|
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 () => {
|
it('board HTML keeps cards slim and links to task detail pages', async () => {
|
||||||
const res = await app.inject({ method: 'GET', url: '/board' });
|
const res = await app.inject({ method: 'GET', url: '/board' });
|
||||||
const html = res.payload;
|
const html = res.payload;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user