feat(board): v3 kanban + animated Lottie KPI icons
Board-v3 (TSK-0099, codex, architect-reviewed 7/7): - kanban columns fill viewport height, scroll inside the column (no page scroll) - padding/margin between task cards - bar-chart HTML-escape bug fixed (est marker inserted raw, not esc()) - half-donut center text centered - task detail opens in a modal (title/description/activity, empty sections hidden) instead of navigating to /tasks - header-jitter on page switch fixed via scrollbar-gutter: stable - SSE connection-leak/freeze fixed (EventSource closed on pagehide/beforeunload) Animated KPI icons (architect): - vendored lottie-web player + Lottie card icons under assets/, served via a path-traversal-guarded /card-assets/* route (offline-safe, no CDN) - state-driven: loop-while-active (open/in_progress/review), pulse-on-change (active/done/agents); light KPI card bg so the black-line icons read clearly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QMN96FH9rJDY2bZnEBdPfA
This commit is contained in:
parent
1749f1c58c
commit
2fb9db15b1
1
assets/card_icons/agents.json
Normal file
1
assets/card_icons/agents.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/all_tasks.json
Normal file
1
assets/card_icons/all_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/all_tasks_old.json
Normal file
1
assets/card_icons/all_tasks_old.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/done_tasks.json
Normal file
1
assets/card_icons/done_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/impact.json
Normal file
1
assets/card_icons/impact.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/in_progress_tasks.json
Normal file
1
assets/card_icons/in_progress_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/in_review_tasks.json
Normal file
1
assets/card_icons/in_review_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/card_icons/open_tasks.json
Normal file
1
assets/card_icons/open_tasks.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/vendor/lottie.min.js
vendored
Normal file
1
assets/vendor/lottie.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -78,12 +78,17 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; min-height: 100%; }
|
html, body { margin: 0; min-height: 100%; height: 100%; scrollbar-gutter: stable; }
|
||||||
|
html { overflow: hidden; }
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 14px/1.5 var(--sans);
|
font: 14px/1.5 var(--sans);
|
||||||
padding: 96px 20px 32px;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 82px 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
button, a, .card { cursor: pointer; }
|
button, a, .card { cursor: pointer; }
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
@ -174,6 +179,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
min-width: 116px;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font: 12px/1 var(--mono);
|
font: 12px/1 var(--mono);
|
||||||
@ -194,6 +200,13 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.metrics {
|
.metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -245,17 +258,34 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
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 {
|
.workbench {
|
||||||
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: stretch;
|
align-items: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.board-area {
|
.board-area {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -263,7 +293,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: clamp(420px, calc(100vh - 210px), 760px);
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
.column {
|
.column {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -306,13 +337,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
.cards {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding-right: 2px;
|
padding: 2px 4px 2px 2px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -565,6 +597,22 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
.modal-danger:hover { background: rgba(248,81,73,.26); }
|
.modal-danger:hover { background: rgba(248,81,73,.26); }
|
||||||
.modal-danger:active { transform: translateY(1px); }
|
.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 ─────────────────────────────────────── */
|
/* ── 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])) {
|
||||||
@ -618,6 +666,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 82px;
|
top: 82px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -683,8 +733,15 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
.donut-tabs button.active { background: var(--raised); color: var(--text); }
|
.donut-tabs button.active { background: var(--raised); color: var(--text); }
|
||||||
.half-donut { position: relative; min-height: 132px; display: grid; place-items: center; }
|
.half-donut {
|
||||||
.half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; }
|
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 {
|
.donut-segment {
|
||||||
animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both;
|
animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both;
|
||||||
}
|
}
|
||||||
@ -693,11 +750,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
.donut-center {
|
.donut-center {
|
||||||
|
grid-area: 1 / 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
top: 66.7%;
|
||||||
top: 58%;
|
width: min(180px, 82%);
|
||||||
transform: translateY(-50%);
|
transform: translate(-50%, -50%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@ -767,10 +825,12 @@ 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; }
|
||||||
.board { height: auto; }
|
.board { height: 100%; }
|
||||||
.column { max-height: 64vh; }
|
.column { max-height: none; }
|
||||||
.cards { grid-template-columns: 1fr; }
|
.cards { grid-template-columns: 1fr; }
|
||||||
.card { min-height: 142px; }
|
.card { min-height: 142px; }
|
||||||
|
.budget { max-height: 34vh; }
|
||||||
|
.detail-activity-row { grid-template-columns: 1fr; gap: 3px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@ -815,12 +875,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|||||||
<main>
|
<main>
|
||||||
<section class="dashboard" aria-label="Dashboard metrics">
|
<section class="dashboard" aria-label="Dashboard metrics">
|
||||||
<div class="metrics" id="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" style="color:var(--accent)"></span></div>
|
<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" style="color:var(--status-open)"></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" style="color:var(--status-in_progress)"></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" style="color:var(--status-review)"></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" style="color:var(--status-done)"></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" style="color:var(--green)"></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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -898,6 +958,16 @@ ${columnSkeleton()}
|
|||||||
</div>
|
</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>
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -908,6 +978,7 @@ ${columnSkeleton()}
|
|||||||
var PROJECT_NAME = ${initialProjectName};
|
var PROJECT_NAME = ${initialProjectName};
|
||||||
var lastTasks = [];
|
var lastTasks = [];
|
||||||
var openConsoles = {}; // taskId -> true when its in-card console is expanded
|
var openConsoles = {}; // taskId -> true when its in-card console is expanded
|
||||||
|
var CONN_CACHE_KEY = 'agenthub-connection-state';
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
@ -933,6 +1004,9 @@ ${columnSkeleton()}
|
|||||||
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
function taskPath(id) {
|
||||||
|
return '/tasks/' + encodeURIComponent(id);
|
||||||
|
}
|
||||||
function statusLabel(status) {
|
function statusLabel(status) {
|
||||||
return String(status || 'open').replace(/_/g, ' ');
|
return String(status || 'open').replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
@ -1000,6 +1074,7 @@ ${columnSkeleton()}
|
|||||||
function setMetric(name, value) {
|
function setMetric(name, value) {
|
||||||
var el = document.querySelector('[data-metric="' + name + '"]');
|
var el = document.querySelector('[data-metric="' + name + '"]');
|
||||||
if (el) el.textContent = String(value);
|
if (el) el.textContent = String(value);
|
||||||
|
if (window.__setMetricIcon) window.__setMetricIcon(name, value);
|
||||||
}
|
}
|
||||||
function statusCounts(tasks) {
|
function statusCounts(tasks) {
|
||||||
var counts = { open: 0, in_progress: 0, review: 0, done: 0, cancelled: 0 };
|
var counts = { open: 0, in_progress: 0, review: 0, done: 0, cancelled: 0 };
|
||||||
@ -1109,9 +1184,59 @@ ${columnSkeleton()}
|
|||||||
body.innerHTML = entries.map(consoleLineHtml).join('');
|
body.innerHTML = entries.map(consoleLineHtml).join('');
|
||||||
body.scrollTop = body.scrollHeight;
|
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) {
|
async function loadConsole(id) {
|
||||||
try {
|
try {
|
||||||
var data = await getJSON('/tasks/' + encodeURIComponent(id) + '/log');
|
var data = await getJSON(taskPath(id) + '/log');
|
||||||
renderConsole(id, data && data.log ? data.log : []);
|
renderConsole(id, data && data.log ? data.log : []);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@ -1136,8 +1261,18 @@ ${columnSkeleton()}
|
|||||||
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');
|
||||||
|
if (!el || !text) return;
|
||||||
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
|
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
|
||||||
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
|
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) {
|
function applyProjectName(name) {
|
||||||
PROJECT_NAME = name || PROJECT_NAME;
|
PROJECT_NAME = name || PROJECT_NAME;
|
||||||
@ -1165,6 +1300,7 @@ ${columnSkeleton()}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var eventSourceReady = false;
|
var eventSourceReady = false;
|
||||||
|
var eventSource = null;
|
||||||
var fallbackPollTimer = null;
|
var fallbackPollTimer = null;
|
||||||
function startFallbackPoll() {
|
function startFallbackPoll() {
|
||||||
if (fallbackPollTimer) return;
|
if (fallbackPollTimer) return;
|
||||||
@ -1181,7 +1317,9 @@ ${columnSkeleton()}
|
|||||||
startFallbackPoll();
|
startFallbackPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
var source = new EventSource('/events');
|
var source = new EventSource('/events');
|
||||||
|
eventSource = source;
|
||||||
source.onopen = function() {
|
source.onopen = function() {
|
||||||
eventSourceReady = true;
|
eventSourceReady = true;
|
||||||
stopFallbackPoll();
|
stopFallbackPoll();
|
||||||
@ -1207,6 +1345,15 @@ ${columnSkeleton()}
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function closeEvents() {
|
||||||
|
stopFallbackPoll();
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', closeEvents);
|
||||||
|
window.addEventListener('beforeunload', closeEvents);
|
||||||
|
|
||||||
// ── Roster ──────────────────────────────────────────────────────────────
|
// ── Roster ──────────────────────────────────────────────────────────────
|
||||||
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
|
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
|
||||||
@ -1407,7 +1554,7 @@ ${columnSkeleton()}
|
|||||||
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</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>' +
|
'<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-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 class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
function renderAgentBars(active) {
|
function renderAgentBars(active) {
|
||||||
@ -1632,6 +1779,13 @@ ${columnSkeleton()}
|
|||||||
var card = e.target.closest && e.target.closest('.card');
|
var card = e.target.closest && e.target.closest('.card');
|
||||||
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
|
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
|
||||||
}, true);
|
}, 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) ───────────────────────────────────────────
|
// ── Delete a task (permanent) ───────────────────────────────────────────
|
||||||
async function deleteTask(id) {
|
async function deleteTask(id) {
|
||||||
@ -1717,6 +1871,16 @@ ${columnSkeleton()}
|
|||||||
if (willOpen) loadConsole(id);
|
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 ──────────────────────────────────────────────────────
|
// ── New-task modal ──────────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var btn = document.getElementById('newTaskBtn');
|
var btn = document.getElementById('newTaskBtn');
|
||||||
@ -1756,6 +1920,7 @@ ${columnSkeleton()}
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
loadStatusMeta();
|
loadStatusMeta();
|
||||||
|
restoreConn();
|
||||||
loadAgents();
|
loadAgents();
|
||||||
refresh();
|
refresh();
|
||||||
refreshBudget();
|
refreshBudget();
|
||||||
@ -1763,6 +1928,67 @@ ${columnSkeleton()}
|
|||||||
setInterval(updateTimers, TIMER_MS);
|
setInterval(updateTimers, TIMER_MS);
|
||||||
setInterval(refreshBudget, 3000);
|
setInterval(refreshBudget, 3000);
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -20,9 +20,16 @@ import { renderTaskDetailHtml } from './taskDetail.js';
|
|||||||
import { eventBus, emitChange } from './events.js';
|
import { eventBus, emitChange } from './events.js';
|
||||||
import type { AgentHubEvent } from './events.js';
|
import type { AgentHubEvent } from './events.js';
|
||||||
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
|
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join, extname, normalize } from 'node:path';
|
||||||
|
|
||||||
const SSE_KEEPALIVE_MS = 10_000;
|
const SSE_KEEPALIVE_MS = 10_000;
|
||||||
|
|
||||||
|
// KPI-card animated icons + vendored lottie player live in the repo `assets/` dir
|
||||||
|
// (../../assets relative to the compiled dist/server/routes.js).
|
||||||
|
const CARD_ASSETS_DIR = fileURLToPath(new URL('../../assets/', import.meta.url));
|
||||||
|
|
||||||
function notFound(reply: FastifyReply, resource: string) {
|
function notFound(reply: FastifyReply, resource: string) {
|
||||||
return reply.status(404).send({ error: `${resource} not found` });
|
return reply.status(404).send({ error: `${resource} not found` });
|
||||||
}
|
}
|
||||||
@ -53,6 +60,25 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
||||||
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
||||||
|
|
||||||
|
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the
|
||||||
|
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
|
||||||
|
app.get('/card-assets/*', async (request, reply) => {
|
||||||
|
const rel = normalize((request.params as Record<string, string>)['*'] || '');
|
||||||
|
if (!rel || rel.startsWith('..') || rel.includes('\0')) return reply.status(404).send('not found');
|
||||||
|
const full = join(CARD_ASSETS_DIR, rel);
|
||||||
|
if (!full.startsWith(CARD_ASSETS_DIR)) return reply.status(404).send('not found');
|
||||||
|
try {
|
||||||
|
const buf = await readFile(full);
|
||||||
|
const ext = extname(full).toLowerCase();
|
||||||
|
const type = ext === '.json' ? 'application/json; charset=utf-8'
|
||||||
|
: ext === '.js' ? 'text/javascript; charset=utf-8'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
return reply.type(type).header('Cache-Control', 'public, max-age=3600').send(buf);
|
||||||
|
} catch {
|
||||||
|
return reply.status(404).send('not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Team hierarchy page: roles tree with per-agent free/busy state.
|
// Team hierarchy page: roles tree with per-agent free/busy state.
|
||||||
app.get('/team', async (_request, reply) => {
|
app.get('/team', async (_request, reply) => {
|
||||||
const teamHtml = renderTeamHtml(cwd);
|
const teamHtml = renderTeamHtml(cwd);
|
||||||
@ -99,13 +125,31 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
raw.flushHeaders();
|
raw.flushHeaders();
|
||||||
raw.write(': connected\n\n');
|
raw.write(': connected\n\n');
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let keepAliveTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
if (keepAliveTimer) clearInterval(keepAliveTimer);
|
||||||
|
eventBus.off('change', listener);
|
||||||
|
eventBus.off('log', logListener);
|
||||||
|
};
|
||||||
|
const writeSse = (chunk: string) => {
|
||||||
|
if (closed || raw.destroyed || raw.writableEnded) return;
|
||||||
|
try {
|
||||||
|
raw.write(chunk);
|
||||||
|
} catch {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const listener = (event: AgentHubEvent) => {
|
const listener = (event: AgentHubEvent) => {
|
||||||
// Server-side role filter: skip tasks that belong to a different role.
|
// Server-side role filter: skip tasks that belong to a different role.
|
||||||
// Handoffs, decisions and memory always pass through.
|
// Handoffs, decisions and memory always pass through.
|
||||||
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
|
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
writeSse(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
eventBus.on('change', listener);
|
eventBus.on('change', listener);
|
||||||
@ -113,20 +157,20 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
|
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
|
||||||
// onmessage handler ignores them; only the task-detail live console listens.
|
// onmessage handler ignores them; only the task-detail live console listens.
|
||||||
const logListener = (payload: unknown) => {
|
const logListener = (payload: unknown) => {
|
||||||
raw.write(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
|
writeSse(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||||
};
|
};
|
||||||
eventBus.on('log', logListener);
|
eventBus.on('log', logListener);
|
||||||
|
|
||||||
const keepAliveTimer = setInterval(() => {
|
keepAliveTimer = setInterval(() => {
|
||||||
raw.write(': keepalive\n\n');
|
writeSse(': keepalive\n\n');
|
||||||
}, SSE_KEEPALIVE_MS);
|
}, SSE_KEEPALIVE_MS);
|
||||||
|
|
||||||
// Clean up when the client disconnects (or the server closes).
|
// Clean up when the client disconnects (or the server closes).
|
||||||
request.raw.on('close', () => {
|
request.raw.once('close', cleanup);
|
||||||
clearInterval(keepAliveTimer);
|
request.raw.once('error', cleanup);
|
||||||
eventBus.off('change', listener);
|
raw.once('close', cleanup);
|
||||||
eventBus.off('log', logListener);
|
raw.once('error', cleanup);
|
||||||
});
|
raw.once('finish', cleanup);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Presence ──────────────────────────────────────────────────────────────
|
// ─── Presence ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -289,7 +289,23 @@ export function renderTeamHtml(cwd: string): string {
|
|||||||
var parentOf = {}; // nodeId -> parent nodeId
|
var parentOf = {}; // nodeId -> parent nodeId
|
||||||
var taskState = {}, baseline = false;
|
var taskState = {}, baseline = false;
|
||||||
|
|
||||||
function setConn(ok) { if (!connBox) return; connBox.classList.remove('stale', 'down'); if (!ok) connBox.classList.add('down'); if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting'; }
|
var CONN_CACHE_KEY = 'agenthub-connection-state';
|
||||||
|
function setConn(ok) {
|
||||||
|
if (!connBox) return;
|
||||||
|
connBox.classList.remove('stale', 'down');
|
||||||
|
if (!ok) connBox.classList.add('down');
|
||||||
|
if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting';
|
||||||
|
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: ok ? 'ok' : 'down', label: ok ? 'connected' : 'reconnecting', at: Date.now() })); } catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var cachedConn = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
|
||||||
|
if (cachedConn && cachedConn.at && Date.now() - cachedConn.at < 30000) {
|
||||||
|
connBox && connBox.classList.remove('stale', 'down');
|
||||||
|
if (cachedConn.state === 'down') connBox && connBox.classList.add('down');
|
||||||
|
if (cachedConn.state === 'stale') connBox && connBox.classList.add('stale');
|
||||||
|
if (connLabel) connLabel.textContent = cachedConn.label || 'connected';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/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<24) return h+'h'; return Math.floor(h/24)+'d'; }
|
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/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<24) return h+'h'; return Math.floor(h/24)+'d'; }
|
||||||
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
|
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
|
||||||
@ -548,12 +564,15 @@ export function renderTeamHtml(cwd: string): string {
|
|||||||
window.addEventListener('resize', relayout);
|
window.addEventListener('resize', relayout);
|
||||||
layoutEdges(); centerScroll();
|
layoutEdges(); centerScroll();
|
||||||
setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
|
setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
|
||||||
sync(); setInterval(sync, 1500);
|
sync(); var syncTimer = setInterval(sync, 1500);
|
||||||
if ('EventSource' in window) {
|
if ('EventSource' in window) {
|
||||||
var es = new EventSource('/events');
|
var es = new EventSource('/events');
|
||||||
es.onopen = function(){ setConn(true); };
|
es.onopen = function(){ setConn(true); };
|
||||||
es.onmessage = function(){ sync(); };
|
es.onmessage = function(){ sync(); };
|
||||||
es.onerror = function(){ setConn(false); };
|
es.onerror = function(){ setConn(false); };
|
||||||
|
function closeEvents() { if (es) { es.close(); es = null; } clearInterval(syncTimer); }
|
||||||
|
window.addEventListener('pagehide', closeEvents);
|
||||||
|
window.addEventListener('beforeunload', closeEvents);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export function designTokensCss(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; height: 100%; }
|
html, body { margin: 0; height: 100%; scrollbar-gutter: stable; }
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@ -317,7 +317,7 @@ export function appHeaderCss(): string {
|
|||||||
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
|
.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:active { transform: translateY(1px); }
|
||||||
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
|
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
|
||||||
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
|
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-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); }
|
.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(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
|
.sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
|
||||||
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
|
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
|
||||||
@ -447,9 +447,26 @@ export function connectionStatusJs(): string {
|
|||||||
(function(){
|
(function(){
|
||||||
var box=document.getElementById('sseStatus'), label=document.getElementById('sseLabel');
|
var box=document.getElementById('sseStatus'), label=document.getElementById('sseLabel');
|
||||||
if(!box) return;
|
if(!box) return;
|
||||||
function setConn(state,text){ box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text; }
|
var KEY='agenthub-connection-state';
|
||||||
|
function setConn(state,text){
|
||||||
|
box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text;
|
||||||
|
try{ sessionStorage.setItem(KEY, JSON.stringify({state:state,label:text,at:Date.now()})); }catch(_){}
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
var cached=JSON.parse(sessionStorage.getItem(KEY)||'null');
|
||||||
|
if(cached&&cached.at&&Date.now()-cached.at<30000) setConn(cached.state||'ok', cached.label||'connected');
|
||||||
|
}catch(_){}
|
||||||
if(!('EventSource' in window)){ setConn('stale','polling'); return; }
|
if(!('EventSource' in window)){ setConn('stale','polling'); return; }
|
||||||
try { var s=new EventSource('/events'); s.onopen=function(){ setConn('ok','connected'); }; s.onerror=function(){ setConn('down','reconnecting'); }; }
|
try {
|
||||||
|
var s=null, timer=setTimeout(function(){
|
||||||
|
s=new EventSource('/events');
|
||||||
|
s.onopen=function(){ setConn('ok','connected'); };
|
||||||
|
s.onerror=function(){ setConn('down','reconnecting'); };
|
||||||
|
}, 500);
|
||||||
|
function closeEvents(){ if(s){ s.close(); s=null; } }
|
||||||
|
window.addEventListener('pagehide', function(){ clearTimeout(timer); closeEvents(); });
|
||||||
|
window.addEventListener('beforeunload', function(){ clearTimeout(timer); closeEvents(); });
|
||||||
|
}
|
||||||
catch(_){ setConn('down','offline'); }
|
catch(_){ setConn('down','offline'); }
|
||||||
})();
|
})();
|
||||||
</script>`;
|
</script>`;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user