2009 lines
87 KiB
TypeScript
2009 lines
87 KiB
TypeScript
/**
|
|
* 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) => ` <section class="column" data-column="${c.key}">
|
|
<header class="col-head">
|
|
<span class="col-label">${c.label}</span>
|
|
<span class="col-count" data-count="${c.key}">0</span>
|
|
</header>
|
|
<div class="cards" data-cards="${c.key}"></div>
|
|
</section>`,
|
|
).join('\n');
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|
const initialProjectName = JSON.stringify(projectName).replace(/</g, '\\u003c');
|
|
const projectNameHtml = escapeHtml(projectName);
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="color-scheme" content="dark" />
|
|
<title>AgentHub Board</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0F172A;
|
|
--surface: #161B22;
|
|
--raised: #1E293B;
|
|
--border: #30363D;
|
|
--text: #F8FAFC;
|
|
--muted: #94A3B8;
|
|
--accent: #58A6FF;
|
|
--green: #22C55E;
|
|
--open: #8B949E;
|
|
--in_progress: #58A6FF;
|
|
--review: #D29922;
|
|
--done: #22C55E;
|
|
--cancelled: #6E7681;
|
|
--danger: #F85149;
|
|
--status-open: var(--open);
|
|
--status-in_progress: var(--in_progress);
|
|
--status-review: var(--review);
|
|
--status-done: var(--done);
|
|
--status-cancelled: var(--cancelled);
|
|
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; min-height: 100%; height: 100%; scrollbar-gutter: stable; }
|
|
html { overflow: hidden; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 14px/1.5 var(--sans);
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
padding: 82px 20px 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
button, a, .card { cursor: pointer; }
|
|
a { color: inherit; text-decoration: none; }
|
|
a:focus-visible, .card:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
.app-header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 40;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
min-height: 64px;
|
|
margin: 0;
|
|
padding: 10px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: rgba(15, 23, 42, .96);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
.mark {
|
|
width: 34px;
|
|
height: 34px;
|
|
flex: 0 0 auto;
|
|
border: 1px solid rgba(88, 166, 255, .38);
|
|
border-radius: 8px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--raised);
|
|
}
|
|
.mark svg { width: 22px; height: 22px; }
|
|
.brand-copy {
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.brand-title {
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
font-size: 17px;
|
|
}
|
|
.project-name {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-family: var(--mono);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 42vw;
|
|
}
|
|
.header-spacer { flex: 1; }
|
|
.nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
padding: 3px;
|
|
border-radius: 8px;
|
|
}
|
|
.nav-link {
|
|
min-height: 34px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
border-radius: 6px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
transition: color 180ms ease, background 180ms ease;
|
|
}
|
|
.nav-link:hover, .nav-link.active {
|
|
color: var(--text);
|
|
background: var(--raised);
|
|
}
|
|
.sse-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-width: 116px;
|
|
min-height: 34px;
|
|
color: var(--muted);
|
|
font: 12px/1 var(--mono);
|
|
white-space: nowrap;
|
|
}
|
|
.conn-dot {
|
|
width: 9px;
|
|
height: 9px;
|
|
border-radius: 50%;
|
|
background: var(--green);
|
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, .16);
|
|
}
|
|
.sse-status.stale .conn-dot { background: var(--review); box-shadow: 0 0 0 3px rgba(210, 153, 34, .14); }
|
|
.sse-status.down .conn-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248, 81, 73, .14); }
|
|
|
|
.dashboard {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
flex: 0 0 auto;
|
|
}
|
|
main {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
.metric-card {
|
|
min-width: 0;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
}
|
|
.metric-card {
|
|
position: relative;
|
|
padding: 14px 16px;
|
|
min-height: 84px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
.metric-label {
|
|
color: var(--muted);
|
|
font-size: 12.5px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.metric-value {
|
|
font: 700 28px/1 var(--mono);
|
|
letter-spacing: 0;
|
|
}
|
|
.metric-icon {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 16px;
|
|
transform: translateY(-50%);
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: color-mix(in srgb, currentColor 16%, transparent);
|
|
}
|
|
.metric-icon::after {
|
|
content: '';
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
/* Animated Lottie icon replaces the plain dot when a card has data-anim. */
|
|
.metric-icon[data-anim] {
|
|
width: 44px;
|
|
height: 44px;
|
|
right: 12px;
|
|
background: transparent;
|
|
}
|
|
.metric-icon[data-anim]::after { display: none; }
|
|
.metric-icon[data-anim] svg,
|
|
.metric-icon[data-anim] canvas { width: 100% !important; height: 100% !important; display: block; }
|
|
/* Light KPI cards so the black-line animated icons read clearly. */
|
|
.metric-card { background: #eef1f6; border-color: #d7dce6; }
|
|
.metric-card .metric-label { color: #5b6472; }
|
|
.metric-card .metric-value { color: #0b1220; }
|
|
.workbench {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
|
gap: 14px;
|
|
align-items: stretch;
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
.board-area {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
min-height: 0;
|
|
}
|
|
.board {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
align-items: stretch;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
height: 100%;
|
|
}
|
|
.column {
|
|
min-width: 0;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 14px 36px rgba(2, 6, 18, .18);
|
|
}
|
|
.col-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 0 2px 10px;
|
|
}
|
|
.col-label {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0;
|
|
}
|
|
.col-count {
|
|
min-width: 26px;
|
|
text-align: center;
|
|
border-radius: 999px;
|
|
padding: 1px 8px;
|
|
background: var(--raised);
|
|
color: var(--muted);
|
|
font: 12px/1.45 var(--mono);
|
|
}
|
|
.column[data-column="open"] .col-label { color: var(--open); }
|
|
.column[data-column="in_progress"] .col-label { color: var(--in_progress); }
|
|
.column[data-column="review"] .col-label { color: var(--review); }
|
|
.column[data-column="done"] .col-label { color: var(--done); }
|
|
.column[data-column="cancelled"] .col-label { color: var(--cancelled); }
|
|
.cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
|
gap: 12px;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
padding: 2px 4px 2px 2px;
|
|
align-items: start;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
.card {
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
background: var(--raised);
|
|
border: 1px solid var(--border);
|
|
border-left: 3px solid var(--accent);
|
|
border-radius: 8px;
|
|
padding: 9px 10px;
|
|
min-height: 142px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
user-select: none;
|
|
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
|
}
|
|
.card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2, 6, 18, .24); }
|
|
.column[data-column="open"] .card { border-left-color: var(--open); }
|
|
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
|
|
.column[data-column="review"] .card { border-left-color: var(--review); }
|
|
.column[data-column="done"] .card { border-left-color: var(--done); }
|
|
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
|
|
.card-top {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.id {
|
|
color: var(--muted);
|
|
font: 11px/1.3 var(--mono);
|
|
white-space: nowrap;
|
|
}
|
|
.title {
|
|
margin: 0 0 8px;
|
|
font-weight: 650;
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 4;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
margin-top: auto;
|
|
}
|
|
.pill, .badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
min-height: 22px;
|
|
border-radius: 999px;
|
|
padding: 2px 8px;
|
|
border: 1px solid rgba(148, 163, 184, .25);
|
|
background: rgba(148, 163, 184, .08);
|
|
color: var(--muted);
|
|
font: 11px/1.2 var(--mono);
|
|
white-space: nowrap;
|
|
}
|
|
.status-pill { color: var(--text); }
|
|
.status-open { border-color: rgba(139, 148, 158, .38); color: var(--open); }
|
|
.status-in_progress { border-color: rgba(88, 166, 255, .42); color: var(--in_progress); background: rgba(88, 166, 255, .10); }
|
|
.status-review { border-color: rgba(210, 153, 34, .42); color: var(--review); background: rgba(210, 153, 34, .10); }
|
|
.status-done { border-color: rgba(34, 197, 94, .40); color: var(--done); background: rgba(34, 197, 94, .10); }
|
|
.status-cancelled { border-color: rgba(110, 118, 129, .45); color: var(--cancelled); }
|
|
.project-tag { color: var(--accent); border-color: rgba(88, 166, 255, .30); background: rgba(88, 166, 255, .08); }
|
|
.timer-badge { color: var(--text); background: rgba(15, 23, 42, .45); }
|
|
.avatar {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-height: 24px;
|
|
max-width: 100%;
|
|
padding: 2px 8px 2px 3px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(148, 163, 184, .25);
|
|
box-shadow: inset 3px 0 0 var(--agent-color);
|
|
background: rgba(15, 23, 42, .45);
|
|
color: var(--text);
|
|
font: 11px/1.2 var(--mono);
|
|
}
|
|
.avatar-core {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
display: inline-grid;
|
|
place-items: center;
|
|
background: var(--agent-color);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
}
|
|
.avatar.architect { box-shadow: 0 0 0 2px rgba(217, 119, 87, .24); }
|
|
.avatar-label {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 120px;
|
|
}
|
|
.reviewer-wrap {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
}
|
|
.reviewer-label {
|
|
color: var(--review);
|
|
font: 10px/1.2 var(--mono);
|
|
white-space: nowrap;
|
|
}
|
|
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
|
|
|
@media (max-width: 1180px) {
|
|
.dashboard { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 860px) {
|
|
body { padding-left: 14px; padding-right: 14px; }
|
|
.app-header { padding-left: 14px; padding-right: 14px; }
|
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.board { grid-template-columns: repeat(2, 1fr); }
|
|
.project-name { max-width: 50vw; }
|
|
}
|
|
@media (max-width: 560px) {
|
|
.app-header { align-items: flex-start; flex-wrap: wrap; }
|
|
.header-spacer { display: none; }
|
|
.brand { width: 100%; }
|
|
.brand-copy { flex-direction: column; gap: 0; }
|
|
.project-name { max-width: calc(100vw - 96px); }
|
|
.metrics { grid-template-columns: 1fr; }
|
|
.board { grid-template-columns: 1fr; }
|
|
.nav { flex: 1; }
|
|
.nav-link { flex: 1; justify-content: center; }
|
|
.sse-status { margin-left: auto; }
|
|
.row { align-items: flex-start; flex-direction: column; gap: 4px; }
|
|
}
|
|
/* ── New-task button ────────────────────────────────────────────────── */
|
|
.new-task-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-height: 34px;
|
|
padding: 0 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(88, 166, 255, .45);
|
|
background: linear-gradient(180deg, rgba(88,166,255,.20), rgba(88,166,255,.10));
|
|
color: var(--text);
|
|
font: 600 13px/1 var(--sans);
|
|
transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
|
}
|
|
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
|
|
.new-task-btn:active { transform: translateY(1px); }
|
|
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
|
|
|
|
/* ── New-task modal ─────────────────────────────────────────────────── */
|
|
.modal-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 60;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 12vh 16px 16px;
|
|
background: rgba(2, 6, 18, .62);
|
|
backdrop-filter: blur(3px);
|
|
animation: modalFade 140ms ease;
|
|
}
|
|
.modal-backdrop[hidden] { display: none; }
|
|
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
|
|
.modal {
|
|
width: min(520px, 100%);
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
box-shadow: 0 24px 64px rgba(0,0,0,.5);
|
|
animation: modalRise 180ms cubic-bezier(.2,.7,.2,1);
|
|
}
|
|
@keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } }
|
|
.modal-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px 18px 8px;
|
|
}
|
|
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; }
|
|
.modal-x {
|
|
width: 30px; height: 30px; border-radius: 8px;
|
|
border: 1px solid var(--border); background: var(--raised);
|
|
color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer;
|
|
transition: color 140ms, border-color 140ms;
|
|
}
|
|
.modal-x:hover { color: var(--text); border-color: var(--muted); }
|
|
#tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
|
|
.modal-field { display: flex; flex-direction: column; gap: 5px; }
|
|
.modal-field-inline { flex-direction: row; align-items: center; gap: 12px; }
|
|
.modal-field-inline .modal-label { margin: 0; }
|
|
.modal-label { color: var(--muted); font: 600 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
|
|
.modal-field input, .modal-field textarea, .modal-field select {
|
|
background: var(--raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text);
|
|
font: 14px/1.4 var(--sans);
|
|
padding: 9px 11px;
|
|
}
|
|
.modal-field textarea { resize: vertical; min-height: 84px; }
|
|
.modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
.modal-note { margin: 0; color: var(--muted); font-size: 11.5px; }
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
|
.modal-cancel, .modal-create {
|
|
min-height: 38px; padding: 0 16px; border-radius: 8px;
|
|
font: 600 13px/1 var(--sans); cursor: pointer;
|
|
transition: background 160ms ease, transform 120ms ease, border-color 160ms ease;
|
|
}
|
|
.modal-cancel { border: 1px solid var(--border); background: var(--raised); color: var(--muted); }
|
|
.modal-cancel:hover { color: var(--text); border-color: var(--muted); }
|
|
.modal-create { border: 1px solid var(--green); background: rgba(34,197,94,.16); color: #d1fadf; }
|
|
.modal-create:hover { background: rgba(34,197,94,.26); }
|
|
.modal-create:active, .modal-cancel:active { transform: translateY(1px); }
|
|
|
|
/* ── Card delete affordance ─────────────────────────────────────────── */
|
|
.card-top-right { display: flex; align-items: center; gap: 6px; }
|
|
.card-del {
|
|
width: 24px; height: 24px; flex: 0 0 auto;
|
|
display: inline-grid; place-items: center;
|
|
border-radius: 7px; border: 1px solid transparent;
|
|
background: transparent; color: var(--muted);
|
|
line-height: 1; cursor: pointer;
|
|
opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms, background 140ms;
|
|
}
|
|
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
|
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
|
|
|
/* ── Confirm (delete) modal ─────────────────────────────────────────── */
|
|
.confirm-modal { width: min(420px, 100%); }
|
|
.confirm-body { padding: 2px 18px 2px; }
|
|
.confirm-body p { margin: 0; color: var(--muted); font-size: 13.5px; line-height: 1.5; }
|
|
.confirm-body b { color: var(--text); }
|
|
.confirm-modal .modal-actions { padding: 14px 18px 18px; margin-top: 0; }
|
|
.modal-danger {
|
|
min-height: 38px; padding: 0 16px; border-radius: 8px;
|
|
border: 1px solid var(--danger); background: rgba(248,81,73,.16); color: #ffd7d3;
|
|
font: 600 13px/1 var(--sans); cursor: pointer;
|
|
transition: background 160ms ease, transform 120ms ease;
|
|
}
|
|
.modal-danger:hover { background: rgba(248,81,73,.26); }
|
|
.modal-danger:active { transform: translateY(1px); }
|
|
|
|
/* ── Task detail modal ──────────────────────────────────────────────── */
|
|
.detail-modal { width: min(760px, 100%); max-height: min(78vh, 760px); display: flex; flex-direction: column; }
|
|
.detail-body { min-height: 0; overflow-y: auto; padding: 4px 18px 18px; display: grid; gap: 12px; }
|
|
.detail-topline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.detail-title { margin: 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
|
|
.detail-section { border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 8px; }
|
|
.detail-section h3 { margin: 0; color: var(--muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
|
|
.detail-description { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); font: 12.5px/1.55 var(--mono); }
|
|
.detail-activity { display: grid; gap: 0; }
|
|
.detail-activity-row { display: grid; grid-template-columns: 78px 82px minmax(0, 1fr) auto; gap: 8px; padding: 8px 0; border-top: 1px solid rgba(48,54,61,.72); align-items: start; }
|
|
.detail-activity-row:first-child { border-top: 0; padding-top: 0; }
|
|
.detail-when, .detail-actor { color: var(--muted); font: 11px/1.45 var(--mono); }
|
|
.detail-kind { color: var(--accent); font: 11px/1.45 var(--mono); }
|
|
.detail-summary { min-width: 0; overflow-wrap: anywhere; }
|
|
.detail-error { color: #fca5a5; }
|
|
|
|
/* ── In-card live agent console ─────────────────────────────────────── */
|
|
.card-console-wrap { margin-top: 9px; }
|
|
.card:has(.card-console:not([hidden])) {
|
|
min-height: 230px;
|
|
}
|
|
.card-console-toggle {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: transparent; border: 1px solid var(--border);
|
|
color: var(--muted); font: 700 10px/1 var(--mono);
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
padding: 4px 9px; border-radius: 6px; cursor: pointer;
|
|
transition: color 140ms, border-color 140ms;
|
|
}
|
|
.card-console-toggle:hover { color: var(--text); border-color: var(--muted); }
|
|
.card-console-toggle[aria-expanded="true"] { color: var(--in_progress); border-color: rgba(88,166,255,.4); }
|
|
.card-console-toggle .cc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
|
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
|
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
|
.card-console { margin-top: 7px; }
|
|
.card-console[hidden] { display: none; }
|
|
.card-console-body {
|
|
max-height: 180px; overflow-y: auto;
|
|
background: #0b1020; border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 8px 10px; font: 11px/1.55 var(--mono); color: #cbd5e1;
|
|
}
|
|
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
|
.cc-line .cc-ts { color: var(--muted); opacity: .8; flex: 0 0 auto; }
|
|
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
|
.cc-line.level-error .cc-text { color: #fca5a5; }
|
|
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
|
.cc-empty { color: var(--muted); font-style: italic; }
|
|
|
|
/* ── Drag & drop ────────────────────────────────────────────────────── */
|
|
.card { touch-action: none; }
|
|
.card.dragging { opacity: .5; cursor: grabbing; transform: scale(.98) rotate(-1deg); }
|
|
.card.flash { animation: cardFlash 900ms ease; }
|
|
@keyframes cardFlash {
|
|
0% { border-color: var(--green); box-shadow: 0 0 0 2px rgba(34,197,94,.4); }
|
|
100% { border-color: var(--border); box-shadow: none; }
|
|
}
|
|
.card { animation: cardEnter 240ms cubic-bezier(.2,.7,.2,1); }
|
|
@keyframes cardEnter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
.column.drop-active { border-color: var(--accent); background: #1a2436; box-shadow: 0 0 0 1px rgba(88,166,255,.4) inset; }
|
|
.column.drop-active .col-head { color: var(--accent); }
|
|
.column .cards.drop-hover { outline: 1px dashed rgba(88,166,255,.5); outline-offset: 3px; border-radius: 6px; }
|
|
/* Card as an assign drop-target (an agent chip dragged onto it). */
|
|
.card.drop-active { border-color: var(--green); background: #172a1d; box-shadow: 0 0 0 2px rgba(34,197,94,.45); transform: translateY(-1px); }
|
|
|
|
/* ── Budget panel ───────────────────────────────────────────────────── */
|
|
.budget {
|
|
position: sticky;
|
|
top: 82px;
|
|
min-width: 0;
|
|
max-height: 100%;
|
|
overflow-y: auto;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 14px 16px;
|
|
box-shadow: 0 18px 40px rgba(2, 6, 18, .24);
|
|
}
|
|
.budget-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
.budget-head h2 { font-size: 14px; margin: 0; font-weight: 700; }
|
|
.budget-head .est-note { color: var(--muted); font-size: 11px; }
|
|
.budget-head .total {
|
|
margin-left: auto;
|
|
font: 700 14px/1 var(--mono);
|
|
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;
|
|
padding: 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
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;
|
|
width: min(220px, 100%);
|
|
aspect-ratio: 220 / 132;
|
|
display: grid;
|
|
place-items: center;
|
|
margin: 0 auto;
|
|
}
|
|
.half-donut svg { grid-area: 1 / 1; width: 100%; height: 100%; overflow: visible; }
|
|
.donut-segment {
|
|
animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both;
|
|
}
|
|
@keyframes donutDraw {
|
|
from { stroke-dashoffset: 100; }
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
.donut-center {
|
|
grid-area: 1 / 1;
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 66.7%;
|
|
width: min(180px, 82%);
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
.donut-value { display: block; font: 800 28px/1 var(--mono); color: var(--text); letter-spacing: 0; }
|
|
.donut-label { display: block; margin-top: 3px; color: var(--muted); font: 10px/1.2 var(--mono); }
|
|
.legend { display: grid; gap: 6px; margin-top: 6px; }
|
|
.legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--muted); }
|
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }
|
|
.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); 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 {
|
|
width: 24px; height: 24px; border-radius: 50%;
|
|
display: inline-grid; place-items: center;
|
|
background: var(--agent-color); color: #fff; font: 800 9px/1 var(--mono); flex: 0 0 auto;
|
|
}
|
|
.budget-agent .ba-name { display: block; font-weight: 650; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.budget-agent .ba-model { display: block; color: var(--muted); font: 10px/1.2 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.agent-bar-cell { min-width: 0; }
|
|
.agent-bar-track { height: 9px; border-radius: 999px; background: var(--raised); overflow: hidden; position: relative; }
|
|
.agent-bar-track > span {
|
|
display: block; height: 100%;
|
|
background: linear-gradient(90deg, var(--bar-color), color-mix(in srgb, var(--bar-color) 62%, #fff));
|
|
width: 0; transition: width 900ms cubic-bezier(.2,.7,.2,1);
|
|
}
|
|
.agent-bar-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; color: var(--muted); font: 10.5px/1.25 var(--mono); }
|
|
.agent-bar-meta .tok { color: var(--text); font-weight: 650; }
|
|
.budget-empty { color: var(--muted); font-size: 12px; padding: 6px 0; }
|
|
|
|
/* ── Toasts ─────────────────────────────────────────────────────────── */
|
|
.toasts {
|
|
position: fixed;
|
|
right: 18px; bottom: 18px;
|
|
z-index: 50;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
pointer-events: none;
|
|
}
|
|
.toast {
|
|
display: flex; align-items: center; gap: 8px;
|
|
background: var(--raised);
|
|
border: 1px solid var(--border);
|
|
border-left: 3px solid var(--green);
|
|
border-radius: 10px;
|
|
padding: 9px 13px;
|
|
color: var(--text);
|
|
font: 13px/1.3 var(--sans);
|
|
box-shadow: 0 8px 28px rgba(0,0,0,.42);
|
|
animation: toastIn 220ms cubic-bezier(.2,.7,.2,1);
|
|
max-width: 340px;
|
|
}
|
|
.toast.out { animation: toastOut 240ms ease forwards; }
|
|
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.18); flex: 0 0 auto; }
|
|
.toast.err { border-left-color: var(--danger); }
|
|
.toast.err .dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248,81,73,.18); }
|
|
@keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
|
|
|
|
@media (max-width: 640px) {
|
|
.agent-bar-row { grid-template-columns: 1fr; gap: 6px; }
|
|
}
|
|
@media (max-width: 1120px) {
|
|
.workbench { grid-template-columns: 1fr; }
|
|
.budget { position: static; }
|
|
}
|
|
@media (max-width: 920px) {
|
|
.cards { grid-template-columns: 1fr; }
|
|
.board { height: 100%; }
|
|
.column { max-height: none; }
|
|
.cards { grid-template-columns: 1fr; }
|
|
.card { min-height: 142px; }
|
|
.budget { max-height: 34vh; }
|
|
.detail-activity-row { grid-template-columns: 1fr; gap: 3px; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
transition-duration: 0.01ms !important;
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="app-header">
|
|
<div class="brand">
|
|
<span class="mark" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" fill="none">
|
|
<path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8" />
|
|
<path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8" />
|
|
</svg>
|
|
</span>
|
|
<div class="brand-copy">
|
|
<span class="brand-title">AgentHub</span>
|
|
<span class="project-name" id="projectName">${projectNameHtml}</span>
|
|
</div>
|
|
</div>
|
|
<span class="header-spacer"></span>
|
|
<nav class="nav" aria-label="Primary">
|
|
<a class="nav-link active" href="/board">Board</a>
|
|
<a class="nav-link" href="/team">Team</a>
|
|
<a class="nav-link" href="/activity">Activity</a>
|
|
<a class="nav-link" href="/decisions">Decisions</a>
|
|
</nav>
|
|
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
|
|
<span class="plus" aria-hidden="true">+</span> New task
|
|
</button>
|
|
<span class="sse-status stale" id="sseStatus">
|
|
<span class="conn-dot" aria-hidden="true"></span>
|
|
<span id="sseLabel">connecting</span>
|
|
</span>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="dashboard" aria-label="Dashboard metrics">
|
|
<div class="metrics" id="metrics">
|
|
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" data-anim="all_tasks" style="color:var(--accent)"></span></div>
|
|
<div class="metric-card"><div class="metric-label">Open</div><div class="metric-value" data-metric="open">0</div><span class="metric-icon" data-anim="open_tasks" style="color:var(--status-open)"></span></div>
|
|
<div class="metric-card"><div class="metric-label">In progress</div><div class="metric-value" data-metric="in_progress">0</div><span class="metric-icon" data-anim="in_progress_tasks" style="color:var(--status-in_progress)"></span></div>
|
|
<div class="metric-card"><div class="metric-label">Review</div><div class="metric-value" data-metric="review">0</div><span class="metric-icon" data-anim="in_review_tasks" style="color:var(--status-review)"></span></div>
|
|
<div class="metric-card"><div class="metric-label">Done total</div><div class="metric-value" data-metric="done">0</div><span class="metric-icon" data-anim="done_tasks" style="color:var(--status-done)"></span></div>
|
|
<div class="metric-card"><div class="metric-label">Agents busy</div><div class="metric-value" data-metric="agents">0/0</div><span class="metric-icon" data-anim="agents" style="color:var(--green)"></span></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="workbench">
|
|
<div class="board-area">
|
|
<section class="board" id="board" aria-label="Task board">
|
|
${columnSkeleton()}
|
|
</section>
|
|
</div>
|
|
|
|
<aside class="budget" id="budget" aria-label="Token insights">
|
|
<div class="budget-head">
|
|
<h2>Token Insights</h2>
|
|
<span class="est-note" id="budgetNote">real agents only</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 id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
|
|
<div class="modal-backdrop" id="taskModal" hidden>
|
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
|
|
<div class="modal-head">
|
|
<h2 id="tmHeading">New task</h2>
|
|
<button class="modal-x" id="tmClose" type="button" aria-label="Close">×</button>
|
|
</div>
|
|
<form id="tmForm" autocomplete="off">
|
|
<label class="modal-field">
|
|
<span class="modal-label">Title</span>
|
|
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
|
|
</label>
|
|
<label class="modal-field">
|
|
<span class="modal-label">Description</span>
|
|
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
|
|
</label>
|
|
<label class="modal-field modal-field-inline">
|
|
<span class="modal-label">Priority</span>
|
|
<select id="tmPriority">
|
|
<option value="low">low</option>
|
|
<option value="medium" selected>medium</option>
|
|
<option value="high">high</option>
|
|
<option value="critical">critical</option>
|
|
</select>
|
|
</label>
|
|
<p class="modal-note">Created unassigned — the architect picks it up and delegates it.</p>
|
|
<div class="modal-actions">
|
|
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
|
|
<button type="submit" class="modal-create">Create task</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" id="confirmModal" hidden>
|
|
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="cfHeading" aria-describedby="cfBody">
|
|
<div class="modal-head">
|
|
<h2 id="cfHeading">Delete task</h2>
|
|
<button class="modal-x" id="cfClose" type="button" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="confirm-body">
|
|
<p id="cfBody">This cannot be undone.</p>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="modal-cancel" id="cfCancel">Cancel</button>
|
|
<button type="button" class="modal-danger" id="cfConfirm">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" id="detailModal" hidden>
|
|
<div class="modal detail-modal" role="dialog" aria-modal="true" aria-labelledby="dtHeading">
|
|
<div class="modal-head">
|
|
<h2 id="dtHeading">Task detail</h2>
|
|
<button class="modal-x" id="dtClose" type="button" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="detail-body" id="dtBody"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
|
|
<script>
|
|
var REFRESH_MS = 6000;
|
|
var TIMER_MS = 1000;
|
|
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
|
|
var ACTIVE_COLUMNS = ${JSON.stringify(ACTIVE_COLUMNS.map((c) => c.key))};
|
|
var PROJECT_NAME = ${initialProjectName};
|
|
var lastTasks = [];
|
|
var openConsoles = {}; // taskId -> true when its in-card console is expanded
|
|
var CONN_CACHE_KEY = 'agenthub-connection-state';
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').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 '<span class="avatar" style="--agent-color:#64748B"><span class="avatar-core">?</span><span class="avatar-label">unassigned</span></span>';
|
|
var spec = agentSpec(name);
|
|
var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect';
|
|
return '<span class="avatar' + (isArchitect ? ' architect' : '') + '" style="--agent-color:' + esc(spec.color) + '">' +
|
|
'<span class="avatar-core">' + esc(spec.initials) + '</span>' +
|
|
'<span class="avatar-label">@' + esc(name) + '</span>' +
|
|
'</span>';
|
|
}
|
|
function reviewerBadge(name) {
|
|
if (!name) return '';
|
|
return '<span class="reviewer-wrap"><span class="reviewer-label">reviewed by</span>' + agentAvatar(name, 'reviewer') + '</span>';
|
|
}
|
|
function projectTag(title) {
|
|
var s = String(title || '').toLowerCase();
|
|
if (s.indexOf('win') >= 0 || s.indexOf('windows') >= 0) return 'windows';
|
|
if (s.indexOf('backend') >= 0 || s.indexOf('api') >= 0) return 'backend';
|
|
if (s.indexOf('magic') >= 0) return 'magic';
|
|
if (s.indexOf('agenthub') >= 0 || s.indexOf('board') >= 0 || s.indexOf('ui') >= 0) return 'agenthub';
|
|
return PROJECT_NAME;
|
|
}
|
|
function timerLabel(status, createdAt, updatedAt) {
|
|
var created = Date.parse(createdAt);
|
|
var updated = Date.parse(updatedAt || createdAt);
|
|
if (isNaN(created)) return '';
|
|
var key = String(status || 'open');
|
|
if (key === 'done' || key === 'cancelled') {
|
|
return 'total ' + compactDuration((isNaN(updated) ? Date.now() : updated) - created);
|
|
}
|
|
if (key === 'in_progress') return 'claimed ' + ago(updatedAt || createdAt);
|
|
if (key === 'review') return 'review ' + ago(updatedAt || createdAt);
|
|
return 'created ' + ago(createdAt);
|
|
}
|
|
function updateTimers() {
|
|
document.querySelectorAll('[data-timer]').forEach(function(el) {
|
|
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
|
|
});
|
|
}
|
|
function setMetric(name, value) {
|
|
var el = document.querySelector('[data-metric="' + name + '"]');
|
|
if (el) el.textContent = String(value);
|
|
if (window.__setMetricIcon) window.__setMetricIcon(name, value);
|
|
}
|
|
function statusCounts(tasks) {
|
|
var counts = { open: 0, in_progress: 0, review: 0, done: 0, cancelled: 0 };
|
|
(tasks || []).forEach(function(t) {
|
|
var key = byStatus(t.status);
|
|
counts[key] = (counts[key] || 0) + 1;
|
|
});
|
|
return counts;
|
|
}
|
|
function renderMetrics(tasks) {
|
|
var counts = statusCounts(tasks);
|
|
var active = (counts.open || 0) + (counts.in_progress || 0) + (counts.review || 0);
|
|
var agents = {};
|
|
var busy = {};
|
|
(tasks || []).forEach(function(t) {
|
|
if (!t.assignedTo) return;
|
|
agents[t.assignedTo] = true;
|
|
if (t.status === 'in_progress') busy[t.assignedTo] = true;
|
|
});
|
|
setMetric('active', active);
|
|
setMetric('open', counts.open || 0);
|
|
setMetric('in_progress', counts.in_progress || 0);
|
|
setMetric('review', counts.review || 0);
|
|
setMetric('done', counts.done || 0);
|
|
setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length);
|
|
}
|
|
|
|
function taskCard(t) {
|
|
var status = byStatus(t.status);
|
|
var isOpen = openConsoles[t.id] ? true : false;
|
|
var live = status === 'in_progress';
|
|
// Console lives inside the card while a task is worked (in_progress = live)
|
|
// and stays available in review so you can see what the agent did.
|
|
var console = (live || status === 'review')
|
|
? '<div class="card-console-wrap">' +
|
|
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
|
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
|
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
|
'</button>' +
|
|
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
|
|
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
|
|
'</div>' +
|
|
'</div>'
|
|
: '';
|
|
return '<a class="card" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
|
|
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '" data-reviewer="' + esc(t.reviewer || '') + '">' +
|
|
'<div class="card-top">' +
|
|
'<span class="id">' + esc(t.id) + '</span>' +
|
|
'<span class="card-top-right">' +
|
|
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
|
|
'<button class="card-del" type="button" data-del="' + esc(t.id) + '" title="Delete task permanently" aria-label="Delete ' + esc(t.id) + '">' +
|
|
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"/></svg>' +
|
|
'</button>' +
|
|
'</span>' +
|
|
'</div>' +
|
|
'<h3 class="title">' + esc(t.title) + '</h3>' +
|
|
'<div class="meta-row">' +
|
|
(status === 'review' && t.reviewer
|
|
? reviewerBadge(t.reviewer)
|
|
: agentAvatar(t.assignedTo, t.role)) +
|
|
'<span class="pill timer-badge" data-timer data-status="' + esc(status) + '" data-created="' + esc(t.createdAt) + '" data-updated="' + esc(t.updatedAt) + '">' +
|
|
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
|
|
'</span>' +
|
|
'</div>' +
|
|
console +
|
|
'</a>';
|
|
}
|
|
function byStatus(status) {
|
|
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
|
|
}
|
|
function renderBoard(tasks) {
|
|
lastTasks = tasks || [];
|
|
renderMetrics(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;
|
|
cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>';
|
|
});
|
|
updateTimers();
|
|
reapplyConsoles();
|
|
}
|
|
|
|
// ── In-card live agent console ──────────────────────────────────────────
|
|
function consoleLineHtml(e) {
|
|
var lvl = String(e.level || 'info');
|
|
var ts = '';
|
|
try { ts = new Date(e.ts).toLocaleTimeString(); } catch (_) {}
|
|
return '<div class="cc-line level-' + esc(lvl) + '">' +
|
|
(ts ? '<span class="cc-ts">' + esc(ts) + '</span>' : '') +
|
|
(e.agent ? '<span class="cc-agent">' + esc(e.agent) + '</span>' : '') +
|
|
'<span class="cc-text">' + esc(e.text) + '</span>' +
|
|
'</div>';
|
|
}
|
|
function renderConsole(id, entries) {
|
|
var body = document.querySelector('[data-console-body="' + id + '"]');
|
|
if (!body) return;
|
|
if (!entries || !entries.length) { body.innerHTML = '<div class="cc-empty">waiting for output…</div>'; return; }
|
|
body.innerHTML = entries.map(consoleLineHtml).join('');
|
|
body.scrollTop = body.scrollHeight;
|
|
}
|
|
function detailActivityHtml(a) {
|
|
var when = '';
|
|
try { when = a.at ? ago(a.at) : ''; } catch (_) {}
|
|
return '<article class="detail-activity-row">' +
|
|
'<span class="detail-when">' + esc(when) + '</span>' +
|
|
'<span class="detail-kind">' + esc(a.kind || '') + '</span>' +
|
|
'<div class="detail-summary">' + esc(a.summary || '') + '</div>' +
|
|
'<span class="detail-actor">' + esc(a.actor || '') + '</span>' +
|
|
'</article>';
|
|
}
|
|
function renderTaskDetailModal(id, detail, activity) {
|
|
var body = document.getElementById('dtBody');
|
|
var heading = document.getElementById('dtHeading');
|
|
if (!body) return;
|
|
var task = detail && detail.task ? detail.task : {};
|
|
var desc = String(detail && detail.body ? detail.body : '').trim();
|
|
if (heading) heading.textContent = task.id ? task.id : 'Task detail';
|
|
var status = byStatus(task.status);
|
|
var sections = [
|
|
'<div class="detail-topline"><span class="id">' + esc(task.id || id) + '</span><span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + (task.assignedTo ? agentAvatar(task.assignedTo, task.role) : '') + '</div>',
|
|
'<h1 class="detail-title">' + esc(task.title || id) + '</h1>'
|
|
];
|
|
if (desc) {
|
|
sections.push('<section class="detail-section"><h3>Description</h3><pre class="detail-description">' + esc(desc) + '</pre></section>');
|
|
}
|
|
if (activity && activity.length) {
|
|
sections.push('<section class="detail-section"><h3>Activity</h3><div class="detail-activity">' + activity.map(detailActivityHtml).join('') + '</div></section>');
|
|
}
|
|
body.innerHTML = sections.join('');
|
|
}
|
|
async function openTaskDetail(id) {
|
|
var modal = document.getElementById('detailModal');
|
|
var body = document.getElementById('dtBody');
|
|
if (!modal || !body) return;
|
|
modal.hidden = false;
|
|
body.innerHTML = '<div class="empty">loading…</div>';
|
|
try {
|
|
var results = await Promise.all([
|
|
getJSON(taskPath(id)),
|
|
getJSON(taskPath(id) + '/activity')
|
|
]);
|
|
renderTaskDetailModal(id, results[0], results[1] || []);
|
|
} catch (err) {
|
|
body.innerHTML = '<div class="detail-error">Could not load ' + esc(id) + '.</div>';
|
|
}
|
|
}
|
|
function closeTaskDetail() {
|
|
var modal = document.getElementById('detailModal');
|
|
if (modal) modal.hidden = true;
|
|
}
|
|
async function loadConsole(id) {
|
|
try {
|
|
var data = await getJSON(taskPath(id) + '/log');
|
|
renderConsole(id, data && data.log ? data.log : []);
|
|
} catch (_) {}
|
|
}
|
|
function appendConsoleLine(id, entry) {
|
|
if (!openConsoles[id]) return;
|
|
var body = document.querySelector('[data-console-body="' + id + '"]');
|
|
if (!body) return;
|
|
var empty = body.querySelector('.cc-empty');
|
|
if (empty) body.innerHTML = '';
|
|
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
|
body.scrollTop = body.scrollHeight;
|
|
}
|
|
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
|
// their innerHTML, so the panel state must be re-applied + reloaded).
|
|
function reapplyConsoles() {
|
|
Object.keys(openConsoles).forEach(function(id) {
|
|
if (!openConsoles[id]) return;
|
|
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
|
if (panel) { panel.hidden = false; loadConsole(id); }
|
|
});
|
|
}
|
|
function setConn(state, label) {
|
|
var el = document.getElementById('sseStatus');
|
|
var text = document.getElementById('sseLabel');
|
|
if (!el || !text) return;
|
|
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
|
|
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
|
|
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: state, label: text.textContent, at: Date.now() })); } catch (_) {}
|
|
}
|
|
function restoreConn() {
|
|
try {
|
|
var cached = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
|
|
if (cached && cached.at && Date.now() - cached.at < 30000) {
|
|
setConn(cached.state || 'stale', cached.label || 'connected');
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
function applyProjectName(name) {
|
|
PROJECT_NAME = name || PROJECT_NAME;
|
|
document.getElementById('projectName').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');
|
|
} 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;
|
|
}
|
|
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() {
|
|
eventSourceReady = true;
|
|
stopFallbackPoll();
|
|
setConn('ok', 'connected');
|
|
refresh();
|
|
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();
|
|
});
|
|
|
|
// ── Roster ──────────────────────────────────────────────────────────────
|
|
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
|
|
// 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 {
|
|
AGENTS = await getJSON('/agents');
|
|
} catch (_) { AGENTS = []; }
|
|
}
|
|
|
|
// ── Budget panel ────────────────────────────────────────────────────────
|
|
function fmtTokens(n) {
|
|
n = Number(n) || 0;
|
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
return String(Math.round(n));
|
|
}
|
|
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 = {
|
|
anthropic: { name: 'Anthropic', color: '#D97757' },
|
|
openai: { name: 'OpenAI', color: '#10A37F' },
|
|
moonshot: { name: 'Moonshot', color: '#7C3AED' }
|
|
};
|
|
return map[key] || { name: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Unknown', color: hashColor(key || 'unknown') };
|
|
}
|
|
function donutPolar(cx, cy, r, angleDeg) {
|
|
var rad = angleDeg * Math.PI / 180;
|
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
}
|
|
function donutArc(cx, cy, r, startDeg, endDeg) {
|
|
var start = donutPolar(cx, cy, r, startDeg);
|
|
var end = donutPolar(cx, cy, r, endDeg);
|
|
var largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
|
return 'M ' + start.x.toFixed(2) + ' ' + start.y.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + end.x.toFixed(2) + ' ' + end.y.toFixed(2);
|
|
}
|
|
function aggregateByCompany(agents, field) {
|
|
var byKind = {};
|
|
(agents || []).forEach(function(a) {
|
|
var kind = String(a.kind || '').toLowerCase();
|
|
if (!kind || !(a.tokens > 0)) return;
|
|
var spec = companySpec(kind);
|
|
if (!byKind[kind]) byKind[kind] = { kind: kind, name: spec.name, color: spec.color, value: 0 };
|
|
byKind[kind].value += Number(a[field]) || 0;
|
|
});
|
|
return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
|
|
}
|
|
function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
|
|
var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
|
|
var safeTotal = Math.max(1, total);
|
|
var cx = 110, cy = 122, r = 90, stroke = 18;
|
|
var angle = 180;
|
|
var paths = '<path d="' + donutArc(cx, cy, r, 180, 360) + '" stroke="#1E293B" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" />';
|
|
rows.forEach(function(row, idx) {
|
|
var start = angle;
|
|
var end = angle + 180 * ((row.value || 0) / safeTotal);
|
|
angle = end;
|
|
if (end <= start + 0.4) return;
|
|
paths += '<path class="donut-segment" d="' + donutArc(cx, cy, r, start, end) + '" stroke="' + esc(row.color) + '" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="animation-delay:' + (idx * 80) + 'ms" />';
|
|
});
|
|
if (!rows.length) paths += '<circle cx="' + cx + '" cy="' + (cy - r + stroke / 2) + '" r="3" fill="#94A3B8" />';
|
|
var legend = rows.length ? rows.map(function(row) {
|
|
var pct = total > 0 ? Math.round((row.value / total) * 100) : 0;
|
|
return '<div class="legend-row" style="color:' + esc(row.color) + '">' +
|
|
'<span class="legend-dot"></span><span class="legend-name">' + esc(row.name) + '</span>' +
|
|
'<span>' + esc(formatValue(row.value)) + ' · ' + pct + '%</span></div>';
|
|
}).join('') : '<div class="empty">no provider data</div>';
|
|
return '<div class="donut-card">' +
|
|
'<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">' +
|
|
'<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>' +
|
|
'<div class="legend">' + legend + '</div>' +
|
|
'</div>';
|
|
}
|
|
function animateCounts(root) {
|
|
var nodes = (root || document).querySelectorAll('[data-count-to]');
|
|
nodes.forEach(function(node) {
|
|
var target = Number(node.getAttribute('data-count-to')) || 0;
|
|
var format = node.getAttribute('data-count-format');
|
|
var start = performance.now();
|
|
function step(now) {
|
|
var p = Math.min(1, (now - start) / 900);
|
|
var eased = 1 - Math.pow(1 - p, 3);
|
|
var val = target * eased;
|
|
node.textContent = format === 'eur' ? fmtEur(val) : fmtTokens(val);
|
|
if (p < 1) requestAnimationFrame(step);
|
|
}
|
|
requestAnimationFrame(step);
|
|
});
|
|
}
|
|
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>' + 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;
|
|
var rows = document.getElementById('budgetRows');
|
|
var total = document.getElementById('budgetTotal');
|
|
if (!rows || !total) return;
|
|
var scoped = applyBudgetMode(rep);
|
|
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
|
|
updateBudgetButtons();
|
|
if (!active.length) {
|
|
rows.innerHTML = '<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>';
|
|
total.textContent = '';
|
|
lastDonutSignature = '';
|
|
return;
|
|
}
|
|
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
|
|
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
|
|
}
|
|
renderDonut(active, opts.forceDonut);
|
|
renderAgentBars(active);
|
|
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
|
|
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
|
|
}
|
|
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 || {};
|
|
var wrap = document.getElementById('toasts');
|
|
if (!wrap) return;
|
|
var el = document.createElement('div');
|
|
el.className = 'toast' + (opts.error ? ' err' : '');
|
|
el.innerHTML = '<span class="dot" aria-hidden="true"></span><span>' + esc(msg) + '</span>';
|
|
wrap.appendChild(el);
|
|
setTimeout(function() {
|
|
el.classList.add('out');
|
|
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 240);
|
|
}, opts.ms || 3200);
|
|
}
|
|
|
|
// ── Mutations (create / assign / move) ──────────────────────────────────
|
|
async function postTask(body) {
|
|
var res = await fetch('/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
if (!res.ok) throw new Error('create failed (' + res.status + ')');
|
|
return res.json();
|
|
}
|
|
async function patchTask(id, body) {
|
|
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
if (!res.ok) {
|
|
var msg = 'update failed (' + res.status + ')';
|
|
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
|
|
throw new Error(msg);
|
|
}
|
|
return res.json();
|
|
}
|
|
function flashCard(id) {
|
|
var el = document.querySelector('.card[data-id="' + id + '"]');
|
|
if (!el) return;
|
|
el.classList.add('flash');
|
|
setTimeout(function() { el.classList.remove('flash'); }, 900);
|
|
}
|
|
// Derive the addressed agent from a task title's "name:" prefix (delegation
|
|
// convention), matched case-insensitively against the known roster.
|
|
function agentFromTitle(id) {
|
|
var t = (lastTasks || []).find(function(x) { return x.id === id; });
|
|
if (!t || !t.title) return '';
|
|
var m = String(t.title).match(/^([A-Za-z][A-Za-z0-9_-]*)\\s*:/);
|
|
if (!m) return '';
|
|
var name = m[1].toLowerCase();
|
|
var hit = (AGENTS || []).find(function(a) { return String(a.name).toLowerCase() === name; });
|
|
return hit ? hit.name : '';
|
|
}
|
|
|
|
async function onDropColumn(id, status, assigned) {
|
|
var card = document.querySelector('.card[data-id="' + id + '"]');
|
|
var from = card ? card.dataset.status : '';
|
|
if (from === status) return;
|
|
try {
|
|
if (status === 'in_progress') {
|
|
// Claiming needs an agent: use the current assignee, else the one named
|
|
// in the title, else mark it as a manual board claim.
|
|
var titledAgent = assigned ? '' : agentFromTitle(id);
|
|
var agent = assigned || titledAgent || 'manual';
|
|
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
|
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
|
|
} else if (status === 'review') {
|
|
var reviewed = await patchTask(id, { status: 'review' });
|
|
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
|
|
} else if (status === 'open') {
|
|
await patchTask(id, { status: 'open' });
|
|
toast(id + ' \\u2192 reopened');
|
|
} else { return; }
|
|
await refresh(); await refreshBudget(); flashCard(id);
|
|
} catch (e) { toast(e.message || 'move failed', { error: true }); }
|
|
}
|
|
|
|
// ── Drag & drop wiring (event delegation) ───────────────────────────────
|
|
// Drag a CARD onto a COLUMN to change its status (in_progress needs an agent).
|
|
var dragKind = null, dragId = null, lastTarget = null;
|
|
function dropTargetOf(e) {
|
|
var n = e.target;
|
|
if (!n || !n.closest) return null;
|
|
if (dragKind === 'card') return n.closest('.column');
|
|
return null;
|
|
}
|
|
document.addEventListener('dragstart', function(e) {
|
|
// Don't start a card drag when interacting with the in-card console.
|
|
if (e.target.closest && e.target.closest('.card-console-wrap')) { e.preventDefault(); return; }
|
|
var card = e.target.closest && e.target.closest('.card');
|
|
if (card) {
|
|
dragKind = 'card'; dragId = card.dataset.id;
|
|
card.classList.add('dragging');
|
|
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', dragId); } catch (_) {} }
|
|
}
|
|
});
|
|
document.addEventListener('dragend', function(e) {
|
|
var el = e.target.closest && e.target.closest('.card');
|
|
if (el) {
|
|
el.classList.remove('dragging');
|
|
// Guard the trailing click so a drag doesn't also open the task detail.
|
|
el.classList.add('just-dragged');
|
|
setTimeout(function() { el.classList.remove('just-dragged'); }, 350);
|
|
}
|
|
if (lastTarget) { lastTarget.classList.remove('drop-active'); lastTarget = null; }
|
|
dragKind = null; dragId = null;
|
|
});
|
|
document.addEventListener('dragover', function(e) {
|
|
if (!dragKind) return;
|
|
var tgt = dropTargetOf(e);
|
|
if (!tgt) return;
|
|
e.preventDefault();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
if (lastTarget && lastTarget !== tgt) lastTarget.classList.remove('drop-active');
|
|
tgt.classList.add('drop-active');
|
|
lastTarget = tgt;
|
|
});
|
|
document.addEventListener('dragleave', function(e) {
|
|
var tgt = dropTargetOf(e);
|
|
if (tgt && e.relatedTarget && !tgt.contains(e.relatedTarget)) {
|
|
tgt.classList.remove('drop-active');
|
|
if (lastTarget === tgt) lastTarget = null;
|
|
}
|
|
});
|
|
document.addEventListener('drop', function(e) {
|
|
if (!dragKind) return;
|
|
var tgt = dropTargetOf(e);
|
|
if (!tgt) return;
|
|
e.preventDefault();
|
|
tgt.classList.remove('drop-active');
|
|
var card = document.querySelector('.card[data-id="' + dragId + '"]');
|
|
var assigned = card ? (card.dataset.assigned || '') : '';
|
|
onDropColumn(dragId, tgt.dataset.column, assigned);
|
|
});
|
|
// A drag that ends in a real drop still fires a click on the <a> card — swallow it.
|
|
document.addEventListener('click', function(e) {
|
|
var card = e.target.closest && e.target.closest('.card');
|
|
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
|
|
}, true);
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest && e.target.closest('.card-del, [data-console-toggle], .card-console, .card-console-wrap')) return;
|
|
var card = e.target.closest && e.target.closest('.card');
|
|
if (!card || card.classList.contains('just-dragged')) return;
|
|
e.preventDefault();
|
|
openTaskDetail(card.dataset.id);
|
|
});
|
|
|
|
// ── Delete a task (permanent) ───────────────────────────────────────────
|
|
async function deleteTask(id) {
|
|
try {
|
|
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'DELETE', headers: { accept: 'application/json' } });
|
|
if (!res.ok) {
|
|
var msg = 'delete failed (' + res.status + ')';
|
|
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
|
|
throw new Error(msg);
|
|
}
|
|
toast(id + ' deleted');
|
|
await refresh(); await refreshBudget();
|
|
} catch (e) { toast(e.message || 'delete failed', { error: true }); }
|
|
}
|
|
// Themed confirm dialog (replaces window.confirm). Resolves true on confirm.
|
|
function confirmModal(opts) {
|
|
opts = opts || {};
|
|
return new Promise(function(resolve) {
|
|
var modal = document.getElementById('confirmModal');
|
|
if (!modal) { resolve(window.confirm(opts.bodyText || 'Are you sure?')); return; }
|
|
var heading = document.getElementById('cfHeading');
|
|
var body = document.getElementById('cfBody');
|
|
var okBtn = document.getElementById('cfConfirm');
|
|
var cancelBtn = document.getElementById('cfCancel');
|
|
var closeBtn = document.getElementById('cfClose');
|
|
if (heading) heading.textContent = opts.title || 'Confirm';
|
|
if (body) body.innerHTML = opts.bodyHtml || esc(opts.bodyText || '');
|
|
if (okBtn) okBtn.textContent = opts.confirmLabel || 'Confirm';
|
|
var done = false;
|
|
function cleanup(result) {
|
|
if (done) return; done = true;
|
|
modal.hidden = true;
|
|
okBtn && okBtn.removeEventListener('click', onOk);
|
|
cancelBtn && cancelBtn.removeEventListener('click', onCancel);
|
|
closeBtn && closeBtn.removeEventListener('click', onCancel);
|
|
modal.removeEventListener('click', onBackdrop);
|
|
document.removeEventListener('keydown', onKey);
|
|
resolve(result);
|
|
}
|
|
function onOk() { cleanup(true); }
|
|
function onCancel() { cleanup(false); }
|
|
function onBackdrop(e) { if (e.target === modal) cleanup(false); }
|
|
function onKey(e) { if (e.key === 'Escape') cleanup(false); }
|
|
okBtn && okBtn.addEventListener('click', onOk);
|
|
cancelBtn && cancelBtn.addEventListener('click', onCancel);
|
|
closeBtn && closeBtn.addEventListener('click', onCancel);
|
|
modal.addEventListener('click', onBackdrop);
|
|
document.addEventListener('keydown', onKey);
|
|
modal.hidden = false;
|
|
if (okBtn) setTimeout(function() { okBtn.focus(); }, 20);
|
|
});
|
|
}
|
|
// Delete button lives inside the card <a> — stop the navigation + confirm.
|
|
document.addEventListener('click', function(e) {
|
|
var del = e.target.closest && e.target.closest('.card-del');
|
|
if (!del) return;
|
|
e.preventDefault(); e.stopPropagation();
|
|
var id = del.dataset.del;
|
|
if (!id) return;
|
|
confirmModal({
|
|
title: 'Delete ' + id + '?',
|
|
bodyHtml: 'This permanently removes <b>' + esc(id) + '</b> — its file and index entry. This cannot be undone.',
|
|
confirmLabel: 'Delete task',
|
|
}).then(function(ok) { if (ok) deleteTask(id); });
|
|
});
|
|
|
|
// Clicks/selection inside the console body must not navigate to the task page.
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
|
|
});
|
|
// Live-console toggle lives inside the card <a> — stop the navigation.
|
|
document.addEventListener('click', function(e) {
|
|
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
|
|
if (!tog) return;
|
|
e.preventDefault(); e.stopPropagation();
|
|
var id = tog.getAttribute('data-console-toggle');
|
|
var willOpen = !openConsoles[id];
|
|
openConsoles[id] = willOpen;
|
|
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
|
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
|
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
|
if (panel) panel.hidden = !willOpen;
|
|
if (willOpen) loadConsole(id);
|
|
});
|
|
|
|
// ── Task-detail modal ──────────────────────────────────────────────────
|
|
(function() {
|
|
var modal = document.getElementById('detailModal');
|
|
if (!modal) return;
|
|
var close = document.getElementById('dtClose');
|
|
if (close) close.addEventListener('click', closeTaskDetail);
|
|
modal.addEventListener('click', function(e) { if (e.target === modal) closeTaskDetail(); });
|
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeTaskDetail(); });
|
|
})();
|
|
|
|
// ── New-task modal ──────────────────────────────────────────────────────
|
|
(function() {
|
|
var btn = document.getElementById('newTaskBtn');
|
|
var modal = document.getElementById('taskModal');
|
|
var form = document.getElementById('tmForm');
|
|
if (!btn || !modal || !form) return;
|
|
var titleInput = document.getElementById('tmTitleInput');
|
|
function openModal() {
|
|
modal.hidden = false;
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
if (titleInput) setTimeout(function() { titleInput.focus(); }, 20);
|
|
}
|
|
function closeModal() {
|
|
modal.hidden = true;
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
form.reset();
|
|
}
|
|
btn.addEventListener('click', openModal);
|
|
document.getElementById('tmClose').addEventListener('click', closeModal);
|
|
document.getElementById('tmCancel').addEventListener('click', closeModal);
|
|
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
|
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeModal(); });
|
|
form.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
var title = (titleInput.value || '').trim();
|
|
if (!title) return;
|
|
var description = (document.getElementById('tmDesc').value || '').trim();
|
|
var priority = document.getElementById('tmPriority').value || undefined;
|
|
try {
|
|
// No assignee/role: the task lands open in the architect's lap to route.
|
|
var task = await postTask({ title: title, description: description, priority: priority });
|
|
toast(task.id + ' created');
|
|
closeModal();
|
|
await refresh(); await refreshBudget(); flashCard(task.id);
|
|
} catch (err) { toast(err.message || 'create failed', { error: true }); }
|
|
});
|
|
})();
|
|
|
|
loadStatusMeta();
|
|
restoreConn();
|
|
loadAgents();
|
|
refresh();
|
|
refreshBudget();
|
|
connectEvents();
|
|
setInterval(updateTimers, TIMER_MS);
|
|
setInterval(refreshBudget, 3000);
|
|
</script>
|
|
<script src="/card-assets/vendor/lottie.min.js"></script>
|
|
<script>
|
|
// Animated KPI icons, driven by task state (not just always-looping):
|
|
// • loop-while-active (open / in_progress / review): loops only while count > 0,
|
|
// frozen at frame 0 when the column is empty.
|
|
// • pulse-on-change (active / done / agents): sits static, plays once whenever the
|
|
// value changes (a task arrives, completes, an agent goes busy/free).
|
|
// Best-effort: if the player or a file is missing, the card just keeps its number.
|
|
(function () {
|
|
if (typeof lottie === 'undefined') { window.__setMetricIcon = function () {}; return; }
|
|
var SPEC = {
|
|
active: { file: 'all_tasks', mode: 'pulse' },
|
|
open: { file: 'open_tasks', mode: 'loop' },
|
|
in_progress: { file: 'in_progress_tasks', mode: 'loop' },
|
|
review: { file: 'in_review_tasks', mode: 'loop' },
|
|
done: { file: 'done_tasks', mode: 'pulse' },
|
|
agents: { file: 'agents', mode: 'pulse' }
|
|
};
|
|
var reg = {}; // metric -> { anim, mode, ready, playing, prev }
|
|
document.querySelectorAll('.metric-icon[data-anim]').forEach(function (el) {
|
|
var card = el.closest('.metric-card');
|
|
var vEl = card ? card.querySelector('[data-metric]') : null;
|
|
var name = vEl ? vEl.getAttribute('data-metric') : null;
|
|
var spec = (name && SPEC[name]) || { file: el.getAttribute('data-anim'), mode: 'loop' };
|
|
try {
|
|
var anim = lottie.loadAnimation({
|
|
container: el, renderer: 'svg', loop: false, autoplay: false,
|
|
path: '/card-assets/card_icons/' + spec.file + '.json'
|
|
});
|
|
var rec = { anim: anim, mode: spec.mode, ready: false, playing: false, prev: null };
|
|
anim.addEventListener('DOMLoaded', function () {
|
|
rec.ready = true;
|
|
anim.goToAndStop(0, true);
|
|
// Apply whatever is already on screen: loops start right away after load,
|
|
// pulse-mode just seeds its baseline so it doesn't fire on first paint.
|
|
var cur = vEl ? vEl.textContent : '0';
|
|
rec.prev = cur;
|
|
if (rec.mode === 'loop' && (parseInt(cur, 10) || 0) > 0) {
|
|
rec.playing = true; anim.loop = true; anim.play();
|
|
}
|
|
});
|
|
if (name) reg[name] = rec;
|
|
} catch (e) { /* icon optional — keep the card */ }
|
|
});
|
|
// Called by setMetric() after it writes the number.
|
|
window.__setMetricIcon = function (name, value) {
|
|
var rec = reg[name];
|
|
if (!rec) return;
|
|
if (!rec.ready) { rec.prev = value; return; }
|
|
if (rec.mode === 'loop') {
|
|
var n = parseInt(String(value), 10) || 0;
|
|
if (n > 0 && !rec.playing) { rec.playing = true; rec.anim.loop = true; rec.anim.goToAndPlay(0, true); }
|
|
else if (n <= 0 && rec.playing) { rec.playing = false; rec.anim.loop = false; rec.anim.goToAndStop(0, true); }
|
|
} else if (rec.prev !== null && String(value) !== String(rec.prev)) {
|
|
rec.anim.loop = false;
|
|
rec.anim.goToAndPlay(0, true); // one-shot; stops at the last frame
|
|
}
|
|
rec.prev = value;
|
|
};
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|