From f9c7c2ae3c04b43c231924c6e2d9fc78a31cea00 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 12 Jul 2026 02:21:41 +0200 Subject: [PATCH] fix(board): stop board twitch + card overlap; KPI pulse; SVG donut center (TSK-0153/0154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of twitch + overlap: source.onmessage ran an unconditional full board rerender (cardsEl.innerHTML=) on EVERY change event, and the enter animation was on a global `.card` selector so it replayed on all cards each rerender. 1+2) SSE onmessage now parses event.type and only rerenders the board on type==='task' (message/ask/decision/memory/handoff/agent no longer trigger a board rerender; budget still refreshes — it has its own diff/throttle). renderBoard() rebuilt as a per-card diff-patch (patchColumn/cardSig/cardNode): untouched cards are kept + reordered, only new/changed cards are (re)built. Enter animation scoped to a one-shot .card-new class. min-height added to the .card transition list so the console-open growth is smooth. Open live consoles preserved (reapplyConsoles unchanged; kept nodes retain their console DOM). 3) setMetric() pulses the .metric-card (.metric-flash + @keyframes metricFlash) only when the value actually changes from a previously-seen value. 4) Donut center readout moved from a CSS top:66.7% div to SVG-native in viewBox coords (x=110, value y=102, label y=120) so it scales and sits in the arc opening. .donut-value/.donut-label restyled with fill. TSK-0154 politur: .cc-line.level-bridge .cc-text color. tsc clean; vitest 36 files / 184 tests green. Visual points are Screenshot/ manual (no board.test.ts). No console regression. Co-Authored-By: Claude Opus 4.8 --- src/server/board.ts | 130 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/src/server/board.ts b/src/server/board.ts index c7faa3b..587dc7a 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -272,6 +272,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .metric-card { background: #eef1f6; border-color: #d7dce6; } .metric-card .metric-label { color: #5b6472; } .metric-card .metric-value { color: #0b1220; } + /* Subtle background pulse when a KPI value actually changes (setMetric). */ + .metric-card.metric-flash { animation: metricFlash 900ms ease; } + @keyframes metricFlash { + 0% { background: #dbe4f5; box-shadow: 0 0 0 2px rgba(88, 166, 255, .35); } + 100% { background: #eef1f6; box-shadow: 0 0 0 0 rgba(88, 166, 255, 0); } + } .workbench { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); @@ -359,7 +365,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { display: flex; flex-direction: column; user-select: none; - transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease, min-height 200ms 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); } @@ -643,6 +649,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .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-line.level-bridge .cc-text { color: #c4b5fd; } .cc-empty { color: var(--muted); font-style: italic; } /* ── Drag & drop ────────────────────────────────────────────────────── */ @@ -653,7 +660,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { 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); } + /* Enter animation only on genuinely new card nodes — NOT every rerender + (a global .card selector replayed it on all cards → visible overlap). */ + .card-new { 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); } @@ -749,18 +758,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { 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); } + /* Center readout is now SVG-native positioned in viewBox coords + (cx=110, cy=122), so it scales with the donut and sits vertically in the + arc opening — replaces the old CSS percent-offset that read too high. */ + .donut-value { fill: var(--text); font: 800 28px var(--mono); letter-spacing: 0; } + .donut-label { fill: var(--muted); font: 10px 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); } @@ -978,6 +980,7 @@ ${columnSkeleton()} var PROJECT_NAME = ${initialProjectName}; var lastTasks = []; var openConsoles = {}; // taskId -> true when its in-card console is expanded + var metricValues = {}; // KPI name -> last rendered value (drives change-pulse) var CONN_CACHE_KEY = 'agenthub-connection-state'; function esc(s) { @@ -1073,7 +1076,22 @@ ${columnSkeleton()} } function setMetric(name, value) { var el = document.querySelector('[data-metric="' + name + '"]'); - if (el) el.textContent = String(value); + var next = String(value); + if (el) { + // Pulse only when the value truly changed from a previously-seen value — + // never on first paint (no entry yet), so the KPIs don't all flash on load. + var changed = Object.prototype.hasOwnProperty.call(metricValues, name) && metricValues[name] !== next; + el.textContent = next; + if (changed) { + var card = el.closest('.metric-card'); + if (card) { + card.classList.remove('metric-flash'); + void card.offsetWidth; // restart the animation if it was mid-flight + card.classList.add('metric-flash'); + } + } + } + metricValues[name] = next; if (window.__setMetricIcon) window.__setMetricIcon(name, value); } function statusCounts(tasks) { @@ -1145,6 +1163,68 @@ ${columnSkeleton()} function byStatus(status) { return COLUMNS.indexOf(status) >= 0 ? status : 'open'; } + // Signature of everything that changes a card's rendered CONTENT — but NOT + // its console open/closed state (that lives in the DOM + openConsoles and + // must survive a patch). Identical signature ⇒ the card node is left alone. + function cardSig(t) { + return [byStatus(t.status), t.title || '', t.assignedTo || '', t.reviewer || '', t.role || '', t.createdAt || '', t.updatedAt || ''].join('\\u0001'); + } + function cardNode(t, sig, isNew) { + var tmp = document.createElement('div'); + tmp.innerHTML = taskCard(t); + var node = tmp.firstElementChild; + if (!node) return null; + node.setAttribute('data-sig', sig); + if (isNew) { + node.classList.add('card-new'); + // One-shot marker: drop it once the enter animation has played, so a + // later reorder (insertBefore) can never replay the animation. + setTimeout(function() { node.classList.remove('card-new'); }, 300); + } + return node; + } + // Reconcile ONE column's DOM against its desired task list WITHOUT a full + // innerHTML= replace (which re-created every node on every event → board + // twitch + card overlap from the global enter animation). Only genuinely + // new/changed cards are (re)built; untouched cards are kept and merely + // reordered, so open live consoles + hover/animation state survive. + function patchColumn(cardsEl, list) { + if (!list.length) { + if (cardsEl.children.length !== 1 || !cardsEl.querySelector('.empty')) { + cardsEl.innerHTML = '
none
'; + } + return; + } + var placeholder = cardsEl.querySelector('.empty'); + if (placeholder) placeholder.remove(); + var existing = {}; + cardsEl.querySelectorAll('.card[data-id]').forEach(function(node) { + existing[node.getAttribute('data-id')] = node; + }); + var seen = {}; + var prev = null; + list.forEach(function(t) { + seen[t.id] = true; + var sig = cardSig(t); + var old = existing[t.id]; + var node; + if (old && old.getAttribute('data-sig') === sig) { + node = old; // unchanged — keep node + its console + } else { + node = cardNode(t, sig, !old); // new-to-this-column ⇒ enter animation + if (old) old.remove(); // drop the stale version + if (!node) return; + } + // Place node immediately after the previous card so DOM order == list. + var anchor = prev ? prev.nextSibling : cardsEl.firstChild; + if (node !== anchor) cardsEl.insertBefore(node, anchor); + prev = node; + }); + // Remove cards that left this column (moved elsewhere or were deleted). + Object.keys(existing).forEach(function(id) { + if (!seen[id]) existing[id].remove(); + }); + } function renderBoard(tasks) { lastTasks = tasks || []; renderMetrics(lastTasks); @@ -1160,7 +1240,7 @@ ${columnSkeleton()} 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('') : '
none
'; + patchColumn(cardsEl, list); }); updateTimers(); reapplyConsoles(); @@ -1327,11 +1407,19 @@ ${columnSkeleton()} refresh(); refreshBudget(); }; - source.onmessage = function() { + source.onmessage = function(ev) { eventSourceReady = true; stopFallbackPoll(); setConn('ok', 'connected'); - refresh(); + // Only task lifecycle changes affect the board. message / ask / decision / + // memory / handoff / agent-presence events must NOT trigger a board + // rerender — the old unconditional refresh() on every change was the root + // cause of the board twitch + card overlap. The budget panel has its own + // diff + throttle (shouldRenderDonut / FLIP bars), so it can refresh on + // any change without churn. + var type = ''; + try { type = (JSON.parse(ev && ev.data) || {}).type || ''; } catch (_) {} + if (type === 'task') refresh(); refreshBudget(); }; source.onerror = function() { @@ -1502,8 +1590,10 @@ ${columnSkeleton()} '

' + esc(title) + '

' + '
' + '
' + - '' + paths + '' + - '
0' + esc(centerLabel) + '
' + + '' + paths + + '0' + + '' + esc(centerLabel) + '' + + '' + '
' + '
' + legend + '
' + '';