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

@ -82,29 +82,15 @@
document.title = `${label} // hyperhive`;
}
function renderOnline(label, root) {
function renderOnline(_label, root) {
root.append(
el('p', { class: 'status-online' }, '▓█▓▒░ harness alive — turn loop running ▓█▓▒░'),
el('p', { class: 'status-online' }, '● harness alive — turn loop running'),
);
const form = el('form', {
action: '/send', method: 'POST', class: 'sendform', 'data-async': '',
});
form.append(
el('input', {
name: 'body', placeholder: `message ${label} as operator…`,
required: '', autocomplete: 'off',
}),
el('button', { type: 'submit', class: 'btn btn-send' }, '◆ S3ND'),
);
root.append(form);
root.append(el('p', { class: 'meta', html:
'enqueued with <code>from: operator</code> on this agent\'s inbox; the next turn picks it up.',
}));
}
function renderNeedsLoginIdle(root) {
root.append(
el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'),
el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'),
el('p', { html:
'No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.',
}),
@ -122,7 +108,7 @@
}
function renderLoginInProgress(s, root) {
root.append(el('p', { class: 'status-needs-login' }, '▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░'));
root.append(el('p', { class: 'status-needs-login' }, '◌ L0G1N 1N PR0GRESS'));
if (s.url) {
const link = el('a', {
href: s.url, target: '_blank', rel: 'noreferrer',
@ -168,12 +154,56 @@
let lastStatus = null;
let lastOutputLen = -1;
let pollTimer = null;
let termInputRendered = false;
function renderTermInput(label, online) {
const slot = $('term-input');
if (!slot) return;
if (!termInputRendered) {
slot.innerHTML = '';
const form = el('form', {
action: '/send', method: 'POST',
class: 'sendform-term', 'data-async': '',
});
form.append(
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
el('input', {
name: 'body', placeholder: 'message ' + label + '…',
required: '', autocomplete: 'off',
}),
el('span', { class: 'submit-hint' }, 'enter ↵'),
);
slot.append(form);
termInputRendered = true;
}
slot.classList.toggle('disabled', !online);
const input = slot.querySelector('input');
if (input) input.disabled = !online;
}
// Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare
// but happens on tight wake cycles), so we count rather than toggle.
let activeTurns = 0;
function setBannerActive(on) {
const banner = $('banner');
if (!banner) return;
if (on) {
activeTurns += 1;
banner.classList.add('active');
} else {
activeTurns = Math.max(0, activeTurns - 1);
if (activeTurns === 0) banner.classList.remove('active');
}
}
async function refreshState() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
renderTermInput(s.label, s.status === 'online');
// Skip the re-render if nothing structurally changed. The most
// common case is `online` polling itself — without this guard, the
// operator's <input value> gets clobbered every cycle.
@ -315,8 +345,10 @@
}
row('sys', '· ' + trim(JSON.stringify(v), 200));
}
function handle(ev) {
function handle(ev, opts) {
const fromHistory = !!(opts && opts.fromHistory);
if (ev.kind === 'turn_start') {
if (!fromHistory) setBannerActive(true);
const block = row('turn-start', '◆ TURN ← ' + ev.from);
if (ev.unread > 0) {
const badge = document.createElement('span');
@ -331,11 +363,12 @@
return;
}
if (ev.kind === 'turn_end') {
if (!fromHistory) setBannerActive(false);
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
// Login may have just landed (or session re-enters Online). Pull
// fresh state so the form view reflects it.
refreshState();
if (!fromHistory) refreshState();
return;
}
if (ev.kind === 'note') {
@ -349,16 +382,44 @@
}
row('note', JSON.stringify(ev));
}
const es = new EventSource('/events/stream');
es.onopen = () => setPlaceholder('(connected — waiting for events)');
es.onmessage = (e) => {
try { handle(JSON.parse(e.data)); }
catch (err) { row('note', '[parse err] ' + e.data); }
};
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
else row('note', '[disconnected]');
};
// Backfill the last N events before subscribing live. Walk through
// turn_start/turn_end to leave the banner-active counter in the right
// state: if the history's last turn never closed, we *do* want the
// banner shimmer to be on. fromHistory=true on the replay; we apply
// the final activity state in one pass at the end.
async function backfill() {
try {
const resp = await fetch('/events/history');
if (!resp.ok) return;
const events = await resp.json();
let openTurns = 0;
for (const ev of events) {
handle(ev, { fromHistory: true });
if (ev.kind === 'turn_start') openTurns += 1;
else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1);
}
for (let i = 0; i < openTurns; i++) setBannerActive(true);
if (events.length) row('note', '─── live (older above) ───');
else setPlaceholder('(connected — waiting for events)');
} catch (err) {
// Best effort; SSE will catch up.
console.warn('history backfill failed', err);
}
}
backfill().then(() => {
const es = new EventSource('/events/stream');
es.onopen = () => { /* no placeholder — backfill already painted */ };
es.onmessage = (e) => {
try { handle(JSON.parse(e.data)); }
catch (err) { row('note', '[parse err] ' + e.data); }
};
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
};
});
})();
// Avoid unused-var lint while keeping `escText` available for future use.