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:
parent
2770630f33
commit
d943bddd9e
7 changed files with 227 additions and 58 deletions
|
|
@ -21,12 +21,29 @@ body {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.banner {
|
.banner {
|
||||||
color: var(--purple);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
font-size: 0.95em;
|
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;
|
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; }
|
||||||
}
|
}
|
||||||
h2, h3 {
|
h2, h3 {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
|
|
@ -34,12 +51,6 @@ h2, h3 {
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||||
}
|
}
|
||||||
.divider {
|
|
||||||
color: var(--purple-dim);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.meta { color: var(--muted); font-size: 0.85em; }
|
.meta { color: var(--muted); font-size: 0.85em; }
|
||||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||||
|
|
@ -113,20 +124,59 @@ pre.diff {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
}
|
}
|
||||||
/* Terminal-ish look for the live panel. Crust as bg (almost-black),
|
/* Terminal-ish wrapper holding the live output + prompt input as one
|
||||||
slightly inset, mauve phosphor glow. */
|
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. */
|
||||||
.live.terminal {
|
.terminal-wrap {
|
||||||
background: #11111b;
|
background: #11111b;
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.8em 1em;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 32em;
|
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
color: #cdd6f4;
|
color: #cdd6f4;
|
||||||
|
margin-top: 0.6em;
|
||||||
}
|
}
|
||||||
|
.live.terminal {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.8em 1em 0.4em;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 32em;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.term-input { padding: 0.4em 1em 0.8em; }
|
||||||
|
.term-input .sendform-term {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
border-top: 1px dashed var(--purple-dim);
|
||||||
|
padding-top: 0.5em;
|
||||||
|
}
|
||||||
|
.term-input .prompt {
|
||||||
|
color: var(--green);
|
||||||
|
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
|
||||||
|
user-select: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.term-input input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
caret-color: var(--green);
|
||||||
|
}
|
||||||
|
.term-input input::placeholder { color: var(--muted); }
|
||||||
|
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
|
||||||
|
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
||||||
|
.term-input.disabled input { color: var(--muted); }
|
||||||
.live {
|
.live {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
|
|
|
||||||
|
|
@ -82,29 +82,15 @@
|
||||||
document.title = `${label} // hyperhive`;
|
document.title = `${label} // hyperhive`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOnline(label, root) {
|
function renderOnline(_label, root) {
|
||||||
root.append(
|
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) {
|
function renderNeedsLoginIdle(root) {
|
||||||
root.append(
|
root.append(
|
||||||
el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'),
|
el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'),
|
||||||
el('p', { html:
|
el('p', { html:
|
||||||
'No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.',
|
'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) {
|
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) {
|
if (s.url) {
|
||||||
const link = el('a', {
|
const link = el('a', {
|
||||||
href: s.url, target: '_blank', rel: 'noreferrer',
|
href: s.url, target: '_blank', rel: 'noreferrer',
|
||||||
|
|
@ -168,12 +154,56 @@
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
let lastOutputLen = -1;
|
let lastOutputLen = -1;
|
||||||
let pollTimer = null;
|
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() {
|
async function refreshState() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/state');
|
const resp = await fetch('/api/state');
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
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
|
// Skip the re-render if nothing structurally changed. The most
|
||||||
// common case is `online` polling itself — without this guard, the
|
// common case is `online` polling itself — without this guard, the
|
||||||
// operator's <input value> gets clobbered every cycle.
|
// operator's <input value> gets clobbered every cycle.
|
||||||
|
|
@ -315,8 +345,10 @@
|
||||||
}
|
}
|
||||||
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
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 (ev.kind === 'turn_start') {
|
||||||
|
if (!fromHistory) setBannerActive(true);
|
||||||
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
||||||
if (ev.unread > 0) {
|
if (ev.unread > 0) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
|
|
@ -331,11 +363,12 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.kind === 'turn_end') {
|
if (ev.kind === 'turn_end') {
|
||||||
|
if (!fromHistory) setBannerActive(false);
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
||||||
// Login may have just landed (or session re-enters Online). Pull
|
// Login may have just landed (or session re-enters Online). Pull
|
||||||
// fresh state so the form view reflects it.
|
// fresh state so the form view reflects it.
|
||||||
refreshState();
|
if (!fromHistory) refreshState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.kind === 'note') {
|
if (ev.kind === 'note') {
|
||||||
|
|
@ -349,16 +382,44 @@
|
||||||
}
|
}
|
||||||
row('note', JSON.stringify(ev));
|
row('note', JSON.stringify(ev));
|
||||||
}
|
}
|
||||||
const es = new EventSource('/events/stream');
|
|
||||||
es.onopen = () => setPlaceholder('(connected — waiting for events)');
|
// Backfill the last N events before subscribing live. Walk through
|
||||||
es.onmessage = (e) => {
|
// turn_start/turn_end to leave the banner-active counter in the right
|
||||||
try { handle(JSON.parse(e.data)); }
|
// state: if the history's last turn never closed, we *do* want the
|
||||||
catch (err) { row('note', '[parse err] ' + e.data); }
|
// banner shimmer to be on. fromHistory=true on the replay; we apply
|
||||||
};
|
// the final activity state in one pass at the end.
|
||||||
es.onerror = () => {
|
async function backfill() {
|
||||||
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
|
try {
|
||||||
else row('note', '[disconnected]');
|
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.
|
// Avoid unused-var lint while keeping `escText` available for future use.
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,16 @@
|
||||||
<body>
|
<body>
|
||||||
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
|
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
|
||||||
<h2 id="title">◆ … ◆</h2>
|
<h2 id="title">◆ … ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
||||||
|
|
||||||
<h3>live</h3>
|
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
|
||||||
|
|
||||||
<div id="status">
|
<div id="status">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-wrap">
|
||||||
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
|
<div id="term-input" class="term-input"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,17 @@
|
||||||
//! future events; the dashboard JS deals with the cold-start case by
|
//! future events; the dashboard JS deals with the cold-start case by
|
||||||
//! showing "connecting…" until the first event arrives.
|
//! showing "connecting…" until the first event arrives.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
const CHANNEL_CAPACITY: usize = 256;
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
/// Max `LiveEvent`s the `Bus` keeps in its ring buffer. The web UI fetches
|
||||||
|
/// this on page load to backfill the terminal so the operator sees the
|
||||||
|
/// last turn(s) without having to wait for the next one.
|
||||||
|
const HISTORY_CAPACITY: usize = 500;
|
||||||
|
|
||||||
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
|
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -43,16 +48,27 @@ pub enum LiveEvent {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Bus {
|
pub struct Bus {
|
||||||
tx: Arc<broadcast::Sender<LiveEvent>>,
|
tx: Arc<broadcast::Sender<LiveEvent>>,
|
||||||
|
history: Arc<Mutex<VecDeque<LiveEvent>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bus {
|
impl Bus {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
Self { tx: Arc::new(tx) }
|
Self {
|
||||||
|
tx: Arc::new(tx),
|
||||||
|
history: Arc::new(Mutex::new(VecDeque::with_capacity(HISTORY_CAPACITY))),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit(&self, event: LiveEvent) {
|
pub fn emit(&self, event: LiveEvent) {
|
||||||
|
{
|
||||||
|
let mut h = self.history.lock().unwrap();
|
||||||
|
if h.len() == HISTORY_CAPACITY {
|
||||||
|
h.pop_front();
|
||||||
|
}
|
||||||
|
h.push_back(event.clone());
|
||||||
|
}
|
||||||
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
||||||
let _ = self.tx.send(event);
|
let _ = self.tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +76,13 @@ impl Bus {
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
|
pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
|
||||||
self.tx.subscribe()
|
self.tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the in-memory event ring buffer, oldest first. Drives the
|
||||||
|
/// terminal pre-fill when the operator opens the agent page.
|
||||||
|
#[must_use]
|
||||||
|
pub fn history(&self) -> Vec<LiveEvent> {
|
||||||
|
self.history.lock().unwrap().iter().cloned().collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Bus {
|
impl Default for Bus {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ pub async fn serve(
|
||||||
.route("/static/app.js", get(serve_app_js))
|
.route("/static/app.js", get(serve_app_js))
|
||||||
.route("/api/state", get(api_state))
|
.route("/api/state", get(api_state))
|
||||||
.route("/events/stream", get(events_stream))
|
.route("/events/stream", get(events_stream))
|
||||||
|
.route("/events/history", get(events_history))
|
||||||
.route("/send", post(post_send))
|
.route("/send", post(post_send))
|
||||||
.route("/login/start", post(post_login_start))
|
.route("/login/start", post(post_login_start))
|
||||||
.route("/login/code", post(post_login_code))
|
.route("/login/code", post(post_login_code))
|
||||||
|
|
@ -206,6 +207,12 @@ async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn events_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> axum::Json<Vec<crate::events::LiveEvent>> {
|
||||||
|
axum::Json(state.bus.history())
|
||||||
|
}
|
||||||
|
|
||||||
async fn events_stream(
|
async fn events_stream(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!s.containers.length && !s.transients.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,8 +123,6 @@
|
||||||
const url = `http://${s.hostname}:${c.port}/`;
|
const url = `http://${s.hostname}:${c.port}/`;
|
||||||
const li = el('li');
|
const li = el('li');
|
||||||
li.append(
|
li.append(
|
||||||
el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'),
|
|
||||||
' ',
|
|
||||||
el('a', { href: url }, c.name),
|
el('a', { href: url }, c.name),
|
||||||
' ',
|
' ',
|
||||||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
||||||
|
|
@ -180,7 +178,7 @@
|
||||||
const root = $('questions-section');
|
const root = $('questions-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
if (!s.questions || !s.questions.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
@ -223,7 +221,7 @@
|
||||||
const root = $('inbox-section');
|
const root = $('inbox-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
if (!s.operator_inbox || !s.operator_inbox.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
@ -245,7 +243,7 @@
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
if (!s.approvals.length) {
|
if (!s.approvals.length) {
|
||||||
root.append(el('p', { class: 'empty' }, '▓ queue empty ▓'));
|
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ul = el('ul', { class: 'approvals' });
|
const ul = el('ul', { class: 'approvals' });
|
||||||
|
|
@ -321,9 +319,21 @@
|
||||||
const es = new EventSource('/messages/stream');
|
const es = new EventSource('/messages/stream');
|
||||||
const MAX_ROWS = 200;
|
const MAX_ROWS = 200;
|
||||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
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) => {
|
es.onmessage = (e) => {
|
||||||
let m;
|
let m;
|
||||||
try { m = JSON.parse(e.data); } catch { return; }
|
try { m = JSON.parse(e.data); } catch { return; }
|
||||||
|
pulseBanner();
|
||||||
// Live-update the inbox when claude sends to operator.
|
// Live-update the inbox when claude sends to operator.
|
||||||
if (m.kind === 'sent' && m.to === 'operator') refreshState();
|
if (m.kind === 'sent' && m.to === 'operator') refreshState();
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,29 @@ body {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.banner {
|
.banner {
|
||||||
color: var(--purple);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
font-size: 0.95em;
|
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;
|
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 {
|
h1, h2 {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue