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.
609 lines
24 KiB
JavaScript
609 lines
24 KiB
JavaScript
// Per-agent web UI. Renders title + login/online view from `/api/state`,
|
|
// tails `/events/stream` for live claude events, drives async-form
|
|
// actions (send / login/* / dashboard rebuild).
|
|
|
|
(() => {
|
|
// ─── helpers ────────────────────────────────────────────────────────────
|
|
const $ = (id) => document.getElementById(id);
|
|
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
|
);
|
|
const el = (tag, attrs = {}, ...children) => {
|
|
const e = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (k === 'class') e.className = v;
|
|
else if (k === 'html') e.innerHTML = v;
|
|
else e.setAttribute(k, v);
|
|
}
|
|
for (const c of children) {
|
|
if (c == null) continue;
|
|
e.append(c.nodeType ? c : document.createTextNode(c));
|
|
}
|
|
return e;
|
|
};
|
|
|
|
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
|
document.addEventListener('submit', async (e) => {
|
|
const f = e.target;
|
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
|
e.preventDefault();
|
|
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
|
const btn = f.querySelector('button[type="submit"], button:not([type])');
|
|
const original = btn ? btn.innerHTML : '';
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
|
try {
|
|
const resp = await fetch(f.action, {
|
|
method: f.method || 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(new FormData(f)),
|
|
redirect: 'manual',
|
|
});
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
return;
|
|
}
|
|
// Clear text inputs the operator typed into (the form value was sent).
|
|
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
|
// Re-enable the button — refreshState() often skips re-rendering the
|
|
// form (status unchanged), so without this the spinner sticks and
|
|
// the operator can't submit again.
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
refreshState();
|
|
} catch (err) {
|
|
alert('action failed: ' + err);
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
}
|
|
});
|
|
|
|
// ─── state rendering ────────────────────────────────────────────────────
|
|
function setHeader(label, dashboardPort) {
|
|
$('banner').textContent =
|
|
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
|
|
const title = $('title');
|
|
title.textContent = `◆ ${label} ◆ `;
|
|
const btn = el('a', {
|
|
href: '#', class: 'btn-rebuild', id: 'rebuild-btn',
|
|
}, '↻ R3BU1LD');
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
|
|
const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`;
|
|
const f = document.createElement('form');
|
|
f.method = 'POST';
|
|
f.action = url;
|
|
document.body.appendChild(f);
|
|
f.submit();
|
|
});
|
|
title.append(btn);
|
|
document.title = `${label} // hyperhive`;
|
|
}
|
|
|
|
function renderOnline(_label, root) {
|
|
root.append(
|
|
el('p', { class: 'status-online' }, '● harness alive — turn loop running'),
|
|
);
|
|
}
|
|
|
|
function renderNeedsLoginIdle(root) {
|
|
root.append(
|
|
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.',
|
|
}),
|
|
);
|
|
const start = el('form', {
|
|
action: '/login/start', method: 'POST', 'data-async': '',
|
|
});
|
|
start.append(
|
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
|
|
);
|
|
root.append(start);
|
|
root.append(el('p', { class: 'meta', html:
|
|
'Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.',
|
|
}));
|
|
}
|
|
|
|
function renderLoginInProgress(s, root) {
|
|
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',
|
|
}, s.url);
|
|
root.append(el('p', {}, '▶ ', link));
|
|
root.append(el('p', { class: 'meta' },
|
|
'open this URL in a browser, complete the OAuth flow, paste the resulting code below.',
|
|
));
|
|
} else {
|
|
root.append(el('p', { class: 'meta' },
|
|
'waiting for claude to emit an OAuth URL on stdout… (output below)',
|
|
));
|
|
}
|
|
if (!s.finished) {
|
|
const code = el('form', {
|
|
action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '',
|
|
});
|
|
code.append(
|
|
el('input', {
|
|
name: 'code', placeholder: 'paste OAuth code here',
|
|
required: '', autocomplete: 'off',
|
|
}),
|
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'),
|
|
);
|
|
root.append(code);
|
|
}
|
|
const cancel = el('form', {
|
|
action: '/login/cancel', method: 'POST', 'data-async': '',
|
|
style: 'margin-top: 0.4em;',
|
|
});
|
|
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
|
|
root.append(cancel);
|
|
if (s.finished) {
|
|
root.append(el('p', { class: 'status-needs-login' },
|
|
`claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`,
|
|
));
|
|
}
|
|
root.append(el('h3', {}, 'output'));
|
|
root.append(el('pre', { class: 'diff' }, s.output || ''));
|
|
}
|
|
|
|
let headerSet = false;
|
|
let lastStatus = null;
|
|
let lastOutputLen = -1;
|
|
let pollTimer = null;
|
|
let termInputRendered = false;
|
|
// Filled in by the live-event IIFE below. Used by the slash-command
|
|
// dispatcher to print local-only rows ('help', errors) and to clear
|
|
// the terminal on `/clear`.
|
|
let termAPI = null;
|
|
|
|
const SLASH_COMMANDS = [
|
|
{ name: '/help', desc: 'list slash commands' },
|
|
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
|
];
|
|
|
|
function handleSlashCommand(line) {
|
|
if (!termAPI) return false;
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('/')) return false;
|
|
const [cmd] = trimmed.split(/\s+/);
|
|
switch (cmd) {
|
|
case '/help':
|
|
termAPI.row('note', '· /help');
|
|
for (const c of SLASH_COMMANDS) {
|
|
termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc);
|
|
}
|
|
return true;
|
|
case '/clear':
|
|
termAPI.clear();
|
|
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
|
|
return true;
|
|
default:
|
|
termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Cycle through commands when operator hits Tab on a `/…` prefix.
|
|
function completeSlash(prefix) {
|
|
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
|
|
if (!matches.length) return null;
|
|
// Cycle: when the current prefix already equals a command name,
|
|
// advance to the next match.
|
|
const idx = matches.findIndex((c) => c.name === prefix);
|
|
return matches[(idx + 1) % matches.length].name;
|
|
}
|
|
|
|
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': '',
|
|
});
|
|
const ta = el('textarea', {
|
|
name: 'body', placeholder: 'message ' + label + '…',
|
|
required: '', autocomplete: 'off', rows: '1',
|
|
});
|
|
// Enter submits, Shift+Enter inserts a newline. Auto-grow up to
|
|
// ~8 rows of content, then scroll inside the textarea.
|
|
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
|
|
const grow = () => {
|
|
ta.style.height = 'auto';
|
|
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
|
|
};
|
|
ta.addEventListener('input', grow);
|
|
ta.addEventListener('keydown', (e) => {
|
|
// Tab-complete slash commands when the buffer starts with `/`.
|
|
if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) {
|
|
const next = completeSlash(ta.value);
|
|
if (next) { e.preventDefault(); ta.value = next; return; }
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
e.preventDefault();
|
|
const line = ta.value;
|
|
if (!line.trim()) return;
|
|
// Intercept slash commands locally; never send them to the agent.
|
|
if (line.trim().startsWith('/')) {
|
|
if (handleSlashCommand(line)) {
|
|
ta.value = '';
|
|
grow();
|
|
return;
|
|
}
|
|
}
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
// Reset height after async submit clears the value.
|
|
form.addEventListener('submit', () => setTimeout(grow, 0));
|
|
form.append(
|
|
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
|
|
ta,
|
|
el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'),
|
|
);
|
|
slot.append(form);
|
|
termInputRendered = true;
|
|
}
|
|
slot.classList.toggle('disabled', !online);
|
|
const ta = slot.querySelector('textarea');
|
|
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.
|
|
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');
|
|
// 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.
|
|
const outLen = s.session?.output?.length ?? -1;
|
|
const dirty =
|
|
s.status !== lastStatus ||
|
|
(s.status === 'needs_login_in_progress' && outLen !== lastOutputLen);
|
|
if (dirty) {
|
|
const root = $('status');
|
|
root.innerHTML = '';
|
|
if (s.status === 'online') renderOnline(s.label, root);
|
|
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
|
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
|
|
lastStatus = s.status;
|
|
lastOutputLen = outLen;
|
|
}
|
|
// Only poll while a login is in flight — otherwise SSE turn_end
|
|
// events trigger a refresh, and the operator can type into the
|
|
// send form without it getting cleared every few seconds.
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
if (s.status === 'needs_login_in_progress') {
|
|
pollTimer = setTimeout(refreshState, 1500);
|
|
}
|
|
} catch (err) {
|
|
console.error('refreshState failed', err);
|
|
pollTimer = setTimeout(refreshState, 5000);
|
|
}
|
|
}
|
|
refreshState();
|
|
|
|
// ─── live event stream ──────────────────────────────────────────────────
|
|
(function() {
|
|
const log = $('live');
|
|
if (!log) return;
|
|
let placeholder = log.firstChild;
|
|
function setPlaceholder(text) {
|
|
log.innerHTML = '';
|
|
const span = document.createElement('div');
|
|
span.className = 'meta';
|
|
span.textContent = text;
|
|
log.appendChild(span);
|
|
placeholder = span;
|
|
}
|
|
function clearPlaceholder() {
|
|
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
|
}
|
|
// Backfill replays mark rows .no-anim so we don't stagger 100 fade-ins
|
|
// on page load. Set via `currentNoAnim` before the row helpers fire.
|
|
let currentNoAnim = false;
|
|
// Expose the panel API for slash commands (`/help`, `/clear`).
|
|
termAPI = {
|
|
row: (cls, text) => row(cls, text),
|
|
clear: () => { log.innerHTML = ''; placeholder = null; },
|
|
};
|
|
|
|
// Sticky-bottom auto-scroll. If the user is reading scrolled-up, new
|
|
// rows do NOT yank the view. A floating "↓ N new" pill appears in
|
|
// the bottom-right corner; clicking it jumps to bottom and clears
|
|
// the counter. Scrolling back near the bottom also clears it.
|
|
const NEAR_BOTTOM_PX = 48;
|
|
let unseen = 0;
|
|
let pill = null;
|
|
function isNearBottom() {
|
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
|
}
|
|
function ensurePill() {
|
|
if (pill) return pill;
|
|
pill = document.createElement('button');
|
|
pill.type = 'button';
|
|
pill.className = 'tail-pill';
|
|
pill.addEventListener('click', () => {
|
|
log.scrollTop = log.scrollHeight;
|
|
});
|
|
log.parentElement.appendChild(pill);
|
|
return pill;
|
|
}
|
|
function updatePill() {
|
|
if (unseen <= 0) {
|
|
if (pill) pill.classList.remove('visible');
|
|
return;
|
|
}
|
|
ensurePill();
|
|
pill.textContent = '↓ ' + unseen + ' new';
|
|
pill.classList.add('visible');
|
|
}
|
|
log.addEventListener('scroll', () => {
|
|
if (isNearBottom()) {
|
|
unseen = 0;
|
|
updatePill();
|
|
}
|
|
});
|
|
function afterAppend() {
|
|
if (currentNoAnim || isNearBottom()) {
|
|
log.scrollTop = log.scrollHeight;
|
|
} else {
|
|
unseen += 1;
|
|
updatePill();
|
|
}
|
|
}
|
|
function row(cls, text) {
|
|
clearPlaceholder();
|
|
const e = document.createElement('div');
|
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
|
e.textContent = text;
|
|
log.appendChild(e);
|
|
afterAppend();
|
|
return e;
|
|
}
|
|
function details(cls, summary, body) {
|
|
clearPlaceholder();
|
|
const d = document.createElement('details');
|
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
|
const s = document.createElement('summary');
|
|
s.textContent = summary;
|
|
d.appendChild(s);
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'tool-body';
|
|
pre.textContent = body;
|
|
d.appendChild(pre);
|
|
log.appendChild(d);
|
|
afterAppend();
|
|
return d;
|
|
}
|
|
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
|
// Pretty-print a tool call: per-known-tool format, fallback to JSON
|
|
// for unknown tools.
|
|
function fmtToolUse(c) {
|
|
const name = c.name || '';
|
|
const input = c.input || {};
|
|
const short = name.startsWith('mcp__hyperhive__')
|
|
? name.slice('mcp__hyperhive__'.length) + '*' : name;
|
|
switch (name) {
|
|
case 'Read': return short + ' ' + (input.file_path || '');
|
|
case 'Write': return short + ' ' + (input.file_path || '');
|
|
case 'Edit': return short + ' ' + (input.file_path || '');
|
|
case 'Glob': return short + ' ' + (input.pattern || '');
|
|
case 'Grep': return short + ' ' + (input.pattern || '');
|
|
case 'Bash': return short + ' $ ' + (input.command || '');
|
|
case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)';
|
|
case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': '
|
|
+ JSON.stringify(input.body || '').slice(0, 80);
|
|
case 'mcp__hyperhive__recv': return short + '()';
|
|
case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || '');
|
|
case 'mcp__hyperhive__kill': return short + ' ' + (input.name || '');
|
|
case 'mcp__hyperhive__request_apply_commit':
|
|
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
|
|
default: return name + ' ' + trim(JSON.stringify(input), 200);
|
|
}
|
|
}
|
|
function renderToolResult(c) {
|
|
const txt = Array.isArray(c.content)
|
|
? c.content.map(p => p.text || '').join('')
|
|
: (c.content || '');
|
|
const summary = '← ' + (() => {
|
|
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
|
if (!trimmed) return '(empty)';
|
|
if (trimmed.length <= 120) return trimmed;
|
|
const lines = txt.split('\n').filter(l => l.length).length;
|
|
const headline = trimmed.slice(0, 90) + '…';
|
|
return `${lines}L · ${headline}`;
|
|
})();
|
|
// For empty / short results, render as a flat row (no expand).
|
|
if (!txt.trim() || txt.length <= 120) {
|
|
row('tool-result', summary);
|
|
} else {
|
|
details('tool-result-block', summary, txt);
|
|
}
|
|
}
|
|
function renderStream(v) {
|
|
// Drop session init, claude's result line, rate-limit — they're
|
|
// noise. TurnEnd communicates pass/fail; session init data isn't
|
|
// actionable.
|
|
if (v.type === 'system' && v.subtype === 'init') return;
|
|
if (v.type === 'rate_limit_event') return;
|
|
if (v.type === 'result') return;
|
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'text' && c.text && c.text.trim()) row('text', c.text);
|
|
else if (c.type === 'thinking') {
|
|
const txt = (c.thinking || c.text || '').trim();
|
|
row('thinking', txt ? '· ' + txt : '· thinking …');
|
|
}
|
|
else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c));
|
|
}
|
|
return;
|
|
}
|
|
if (v.type === 'user' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'tool_result') renderToolResult(c);
|
|
}
|
|
return;
|
|
}
|
|
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
|
}
|
|
function handle(ev, opts) {
|
|
const fromHistory = !!(opts && opts.fromHistory);
|
|
if (ev.kind === 'turn_start') {
|
|
if (!fromHistory) { setBannerActive(true); setState('thinking'); }
|
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
|
if (ev.unread > 0) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'unread-badge';
|
|
badge.textContent = '· ' + ev.unread + ' unread';
|
|
block.appendChild(badge);
|
|
}
|
|
const body = document.createElement('div');
|
|
body.className = 'turn-body';
|
|
body.textContent = ev.body;
|
|
block.appendChild(body);
|
|
return;
|
|
}
|
|
if (ev.kind === 'turn_end') {
|
|
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
|
|
// fresh state so the form view reflects it.
|
|
if (!fromHistory) refreshState();
|
|
return;
|
|
}
|
|
if (ev.kind === 'note') {
|
|
row('note', '· ' + ev.text);
|
|
return;
|
|
}
|
|
if (ev.kind === 'stream') {
|
|
const v = Object.assign({}, ev); delete v.kind;
|
|
renderStream(v);
|
|
return;
|
|
}
|
|
row('note', JSON.stringify(ev));
|
|
}
|
|
|
|
// 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;
|
|
currentNoAnim = true;
|
|
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);
|
|
}
|
|
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) {
|
|
// 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.
|
|
void escText;
|
|
})();
|