agent state badge: idle / thinking / offline + age timer
new badge between the status line and the terminal. shows current
state with a glyph + label + age suffix (e.g. '🧠 thinking · 12s').
state transitions are driven from existing SSE turn_start/turn_end —
no harness changes needed. on page load, history backfill detects an
in-flight turn (turn_start without matching turn_end) and starts in
thinking. state-just-changed flash kicks in on each transition. age
timer ticks client-side every 1s.
compacting/napping states will be added when /compact and nap land —
their slots are reserved in the state enum, just unused for now.
This commit is contained in:
parent
0cc25d33d8
commit
211599c589
3 changed files with 96 additions and 2 deletions
|
|
@ -124,6 +124,42 @@ pre.diff {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
}
|
}
|
||||||
|
#state-row {
|
||||||
|
margin: 0.4em 0 0.2em;
|
||||||
|
}
|
||||||
|
.state-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: color 280ms ease, border-color 280ms ease,
|
||||||
|
box-shadow 280ms ease, background 280ms ease;
|
||||||
|
}
|
||||||
|
.state-badge.state-loading {
|
||||||
|
color: var(--muted); border-color: var(--purple-dim);
|
||||||
|
}
|
||||||
|
.state-badge.state-offline {
|
||||||
|
color: var(--muted); border-color: var(--muted);
|
||||||
|
}
|
||||||
|
.state-badge.state-idle {
|
||||||
|
color: var(--cyan); border-color: var(--cyan);
|
||||||
|
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
|
||||||
|
}
|
||||||
|
.state-badge.state-thinking {
|
||||||
|
color: var(--amber); border-color: var(--amber);
|
||||||
|
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
||||||
|
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.state-badge.state-just-changed {
|
||||||
|
animation: state-flash 600ms ease-out;
|
||||||
|
}
|
||||||
|
@keyframes state-flash {
|
||||||
|
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||||
|
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
|
||||||
|
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||||
|
}
|
||||||
/* Terminal-ish wrapper holding the live output + prompt input as one
|
/* Terminal-ish wrapper holding the live output + prompt input as one
|
||||||
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
|
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
|
||||||
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
|
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,54 @@
|
||||||
if (ta) ta.disabled = !online;
|
if (ta) ta.disabled = !online;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Granular state badge: idle / thinking / offline. Driven from SSE
|
||||||
|
// turn_start/turn_end. Age timer ticks client-side; badge re-renders
|
||||||
|
// each second so the "· 12s" suffix stays current. State changes
|
||||||
|
// trigger a short flash animation via .state-just-changed.
|
||||||
|
const STATE_LABELS = {
|
||||||
|
loading: { glyph: '…', text: 'booting' },
|
||||||
|
offline: { glyph: '○', text: 'offline' },
|
||||||
|
idle: { glyph: '💤', text: 'idle' },
|
||||||
|
thinking: { glyph: '🧠', text: 'thinking' },
|
||||||
|
};
|
||||||
|
let stateName = 'loading';
|
||||||
|
let stateSince = Date.now();
|
||||||
|
let stateTickTimer = null;
|
||||||
|
function fmtAge(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h + 'h ' + (m % 60) + 'm';
|
||||||
|
}
|
||||||
|
function renderStateBadge() {
|
||||||
|
const badge = $('state-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
const def = STATE_LABELS[stateName] || STATE_LABELS.loading;
|
||||||
|
const age = fmtAge(Date.now() - stateSince);
|
||||||
|
badge.textContent = def.glyph + ' ' + def.text + ' · ' + age;
|
||||||
|
badge.className = 'state-badge state-' + stateName;
|
||||||
|
}
|
||||||
|
function setState(next) {
|
||||||
|
if (next === stateName) return;
|
||||||
|
stateName = next;
|
||||||
|
stateSince = Date.now();
|
||||||
|
const badge = $('state-badge');
|
||||||
|
if (badge) {
|
||||||
|
// Re-add the flash class so the animation replays.
|
||||||
|
badge.classList.remove('state-just-changed');
|
||||||
|
void badge.offsetWidth;
|
||||||
|
badge.classList.add('state-just-changed');
|
||||||
|
}
|
||||||
|
renderStateBadge();
|
||||||
|
}
|
||||||
|
function startStateTicker() {
|
||||||
|
if (stateTickTimer) return;
|
||||||
|
stateTickTimer = setInterval(renderStateBadge, 1000);
|
||||||
|
}
|
||||||
|
startStateTicker();
|
||||||
|
|
||||||
// Track banner activity by reference-counting in-flight turns. A turn
|
// Track banner activity by reference-counting in-flight turns. A turn
|
||||||
// can begin while the previous turn_end is still in the pipeline (rare
|
// 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.
|
// but happens on tight wake cycles), so we count rather than toggle.
|
||||||
|
|
@ -277,6 +325,11 @@
|
||||||
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');
|
renderTermInput(s.label, s.status === 'online');
|
||||||
|
// Drive the state badge from the harness status. Live SSE events
|
||||||
|
// override to 'thinking' / 'idle' as turns start/end; this only
|
||||||
|
// kicks in for the not-online (offline) case and the initial seed.
|
||||||
|
if (s.status !== 'online') setState('offline');
|
||||||
|
else if (stateName === 'loading' || stateName === 'offline') setState('idle');
|
||||||
// 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.
|
||||||
|
|
@ -474,7 +527,7 @@
|
||||||
function handle(ev, opts) {
|
function handle(ev, opts) {
|
||||||
const fromHistory = !!(opts && opts.fromHistory);
|
const fromHistory = !!(opts && opts.fromHistory);
|
||||||
if (ev.kind === 'turn_start') {
|
if (ev.kind === 'turn_start') {
|
||||||
if (!fromHistory) setBannerActive(true);
|
if (!fromHistory) { setBannerActive(true); setState('thinking'); }
|
||||||
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');
|
||||||
|
|
@ -489,7 +542,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.kind === 'turn_end') {
|
if (ev.kind === 'turn_end') {
|
||||||
if (!fromHistory) setBannerActive(false);
|
if (!fromHistory) { setBannerActive(false); setState('idle'); }
|
||||||
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
|
||||||
|
|
@ -528,6 +581,7 @@
|
||||||
}
|
}
|
||||||
currentNoAnim = false;
|
currentNoAnim = false;
|
||||||
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
||||||
|
if (openTurns > 0) setState('thinking');
|
||||||
if (events.length) row('note', '─── live (older above) ───');
|
if (events.length) row('note', '─── live (older above) ───');
|
||||||
else setPlaceholder('(connected — waiting for events)');
|
else setPlaceholder('(connected — waiting for events)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="state-row">
|
||||||
|
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="terminal-wrap">
|
<div class="terminal-wrap">
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue