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:
müde 2026-05-15 19:36:29 +02:00
parent 0cc25d33d8
commit 211599c589
3 changed files with 96 additions and 2 deletions

View file

@ -124,6 +124,42 @@ pre.diff {
word-break: break-all;
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
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,

View file

@ -254,6 +254,54 @@
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
// 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.
@ -277,6 +325,11 @@
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
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
// common case is `online` polling itself — without this guard, the
// operator's <input value> gets clobbered every cycle.
@ -474,7 +527,7 @@
function handle(ev, opts) {
const fromHistory = !!(opts && opts.fromHistory);
if (ev.kind === 'turn_start') {
if (!fromHistory) setBannerActive(true);
if (!fromHistory) { setBannerActive(true); setState('thinking'); }
const block = row('turn-start', '◆ TURN ← ' + ev.from);
if (ev.unread > 0) {
const badge = document.createElement('span');
@ -489,7 +542,7 @@
return;
}
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';
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
// Login may have just landed (or session re-enters Online). Pull
@ -528,6 +581,7 @@
}
currentNoAnim = false;
for (let i = 0; i < openTurns; i++) setBannerActive(true);
if (openTurns > 0) setState('thinking');
if (events.length) row('note', '─── live (older above) ───');
else setPlaceholder('(connected — waiting for events)');
} catch (err) {

View file

@ -13,6 +13,10 @@
<p class="meta">loading…</p>
</div>
<div id="state-row">
<span id="state-badge" class="state-badge state-loading">… booting</span>
</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>