agent ui: input lives in terminal section, banner shimmer on activity

agent page restructure:
- send form moves into the terminal panel as a prompt-style row beneath
  the live tail (status line stays above so it still reads as a header).
- live panel + prompt share a single bordered 'terminal-wrap' box.
- harness-alive / login-state status lines drop their decorative ascii
  bookends; just a leading dot/glyph remains.
- banner gradient is now a real css gradient with a shimmer animation
  toggled by an .active class. turn_start adds it, turn_end removes it.
  dashboard side mirrors this: each broker sse event nudges a 4s
  shimmer window.
- dashboard container rows drop their static ▓█▓▒░ / ▒░▒░░ glyph
  prefixes; the role chips already disambiguate m1nd vs ag3nt.
- empty-state placeholders drop the ▓ bookends.

terminal pre-fill: hive-ag3nt::events::Bus grows a 500-event ring
buffer; new GET /events/history endpoint returns it. The agent JS
fetches history before opening the SSE stream so opening the page mid-
turn shows the last N events instead of a blank panel. The replay
walks turn_start/turn_end pairs to seed the banner-active state
correctly if a turn was still open.
This commit is contained in:
müde 2026-05-15 18:54:19 +02:00
parent 2770630f33
commit d943bddd9e
7 changed files with 227 additions and 58 deletions

View file

@ -114,7 +114,7 @@
}
if (!s.containers.length && !s.transients.length) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
@ -123,8 +123,6 @@
const url = `http://${s.hostname}:${c.port}/`;
const li = el('li');
li.append(
el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'),
' ',
el('a', { href: url }, c.name),
' ',
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
@ -180,7 +178,7 @@
const root = $('questions-section');
root.innerHTML = '';
if (!s.questions || !s.questions.length) {
root.append(el('p', { class: 'empty' }, 'no pending questions'));
root.append(el('p', { class: 'empty' }, 'no pending questions'));
return;
}
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
@ -223,7 +221,7 @@
const root = $('inbox-section');
root.innerHTML = '';
if (!s.operator_inbox || !s.operator_inbox.length) {
root.append(el('p', { class: 'empty' }, 'no messages'));
root.append(el('p', { class: 'empty' }, 'no messages'));
return;
}
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
@ -245,7 +243,7 @@
const root = $('approvals-section');
root.innerHTML = '';
if (!s.approvals.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
const ul = el('ul', { class: 'approvals' });
@ -321,9 +319,21 @@
const es = new EventSource('/messages/stream');
const MAX_ROWS = 200;
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
// Animate the banner whenever a broker event lands. Each event nudges
// the shimmer window; if traffic stops, the class falls off after the
// grace timer.
const banner = document.querySelector('.banner');
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
}
es.onmessage = (e) => {
let m;
try { m = JSON.parse(e.data); } catch { return; }
pulseBanner();
// Live-update the inbox when claude sends to operator.
if (m.kind === 'sent' && m.to === 'operator') refreshState();
const row = document.createElement('div');

View file

@ -24,12 +24,29 @@ body {
line-height: 1.6;
}
.banner {
color: var(--purple);
text-align: center;
margin: 0 0 1em 0;
font-size: 0.95em;
text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25);
overflow-x: auto;
background: linear-gradient(
90deg,
var(--purple-dim) 0%,
var(--purple) 50%,
var(--purple-dim) 100%
);
background-size: 200% 100%;
background-position: 50% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
}
.banner.active {
animation: banner-shimmer 1.8s linear infinite;
}
@keyframes banner-shimmer {
from { background-position: 200% 0; }
to { background-position: -100% 0; }
}
h1, h2 {
color: var(--purple);