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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue