new TurnState { Idle, Thinking, Compacting } on hive_ag3nt::events::Bus
with set_state + state_snapshot. the turn loops in hive-ag3nt and
hive-m1nd flip Thinking before drive_turn and Idle after; the
web_ui's /api/compact handler flips Compacting around compact_session.
per-agent /api/state grows turn_state + turn_state_since (unix
seconds). frontend prefers the server-reported state over the
client-derived one — setStateAbs takes the absolute since-time so
the 'last turn' chip reads the actual server-side duration instead
of the client's perceived gap between SSE events. SSE turn_start /
turn_end still drive state instantly between renders; /api/state
re-anchors on each turn_end refresh.
new compacting state gets its own purple badge with pulse
animation (mirrors thinking's amber). napping will slot in the
same way once the nap tool lands.
766 lines
30 KiB
JavaScript
766 lines
30 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} ◆ `;
|
|
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
|
|
// tab to keep the agent page anchored where the operator is.
|
|
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
|
|
title.append(
|
|
el('a', {
|
|
href: dashUrl, target: '_blank', rel: 'noopener',
|
|
class: 'btn-dashlink', title: 'host dashboard',
|
|
}, '↑ DASHB04RD'),
|
|
' ',
|
|
);
|
|
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 f = document.createElement('form');
|
|
f.method = 'POST';
|
|
f.action = `${dashUrl}rebuild/${label}`;
|
|
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)' },
|
|
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
|
|
{ name: '/compact', desc: 'compact the persistent claude session' },
|
|
];
|
|
|
|
async function postSimple(url, label) {
|
|
try {
|
|
const resp = await fetch(url, { method: 'POST', redirect: 'manual' });
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok && termAPI) {
|
|
termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status);
|
|
}
|
|
} catch (err) {
|
|
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
|
}
|
|
}
|
|
const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
|
|
const postCompact = () => postSimple('/api/compact', '/compact');
|
|
|
|
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;
|
|
case '/cancel':
|
|
postCancelTurn();
|
|
return true;
|
|
case '/compact':
|
|
postCompact();
|
|
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' },
|
|
compacting: { glyph: '📦', text: 'compacting' },
|
|
};
|
|
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;
|
|
const cancelBtn = $('cancel-btn');
|
|
if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
|
|
}
|
|
function setState(next) {
|
|
setStateAbs(next, Math.floor(Date.now() / 1000));
|
|
}
|
|
/// Set state with an authoritative since-unix from the server. Lets
|
|
/// `last turn` track the actual server-side duration rather than
|
|
/// whatever the client perceived between SSE events.
|
|
function setStateAbs(next, sinceUnix) {
|
|
if (next === stateName && sinceUnix * 1000 === stateSince) return;
|
|
if (stateName === 'thinking' && next !== 'thinking') {
|
|
const elapsedMs = Date.now() - stateSince;
|
|
renderLastTurn(elapsedMs);
|
|
}
|
|
const flashing = next !== stateName;
|
|
stateName = next;
|
|
stateSince = sinceUnix * 1000;
|
|
const badge = $('state-badge');
|
|
if (badge && flashing) {
|
|
badge.classList.remove('state-just-changed');
|
|
void badge.offsetWidth;
|
|
badge.classList.add('state-just-changed');
|
|
}
|
|
renderStateBadge();
|
|
}
|
|
function renderInbox(rows) {
|
|
const root = $('inbox-section');
|
|
const list = $('inbox-list');
|
|
const summary = $('inbox-summary');
|
|
if (!root || !list || !summary) return;
|
|
if (!rows.length) {
|
|
root.hidden = true;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
summary.textContent = 'inbox · ' + rows.length;
|
|
list.innerHTML = '';
|
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
|
for (const m of rows) {
|
|
const li = el('li');
|
|
li.append(
|
|
el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
|
|
el('span', { class: 'inbox-from' }, m.from), ' ',
|
|
el('span', { class: 'inbox-sep' }, '→'), ' ',
|
|
el('span', { class: 'inbox-body' }, m.body),
|
|
);
|
|
list.append(li);
|
|
}
|
|
}
|
|
function renderLastTurn(ms) {
|
|
const el_ = $('last-turn');
|
|
if (!el_) return;
|
|
let s = '';
|
|
if (ms < 1000) s = ms + 'ms';
|
|
else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's';
|
|
else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's';
|
|
el_.textContent = '· last turn ' + s;
|
|
el_.hidden = false;
|
|
}
|
|
function startStateTicker() {
|
|
if (stateTickTimer) return;
|
|
stateTickTimer = setInterval(renderStateBadge, 1000);
|
|
}
|
|
startStateTicker();
|
|
|
|
// Wire the cancel-turn button (visible only while state === thinking).
|
|
(() => {
|
|
const btn = $('cancel-btn');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
btn.disabled = true;
|
|
postCancelTurn().finally(() => { btn.disabled = false; });
|
|
});
|
|
})();
|
|
|
|
// 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');
|
|
renderInbox(s.inbox || []);
|
|
// Authoritative state comes from the harness via /api/state.
|
|
// Login-not-yet → 'offline'; otherwise use the server-reported
|
|
// turn_state (idle / thinking / compacting). stateSince in
|
|
// unix-seconds is converted to a client-side Date.now() anchor.
|
|
if (s.status !== 'online') {
|
|
setState('offline');
|
|
} else if (s.turn_state) {
|
|
setStateAbs(s.turn_state, s.turn_state_since);
|
|
}
|
|
// 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);
|
|
}
|
|
}
|
|
// Build a tool_use row for Write/Edit as a collapsed <details>
|
|
// showing the actual change. Returns null for any other tool so
|
|
// the caller falls back to the flat-row path.
|
|
// Write: every input.content line is "+".
|
|
// Edit: old_string lines as "-", new_string lines as "+".
|
|
// Not a true diff algorithm — claude's Edit blocks are already a
|
|
// contiguous old/new pair, so a literal -/+ rendering is honest.
|
|
function renderFileWriteEdit(c) {
|
|
const name = c.name || '';
|
|
const input = c.input || {};
|
|
if (name !== 'Write' && name !== 'Edit') return null;
|
|
const path = input.file_path || '?';
|
|
let body;
|
|
let plus = 0;
|
|
let minus = 0;
|
|
if (name === 'Write') {
|
|
const content = String(input.content || '');
|
|
const lines = content.split('\n');
|
|
plus = lines.length;
|
|
body = lines.map(l => '+ ' + l).join('\n');
|
|
} else {
|
|
const oldLines = String(input.old_string || '').split('\n');
|
|
const newLines = String(input.new_string || '').split('\n');
|
|
minus = oldLines.length;
|
|
plus = newLines.length;
|
|
body = oldLines.map(l => '- ' + l).join('\n')
|
|
+ '\n'
|
|
+ newLines.map(l => '+ ' + l).join('\n');
|
|
}
|
|
const summary = '→ ' + name + ' ' + path + ' · '
|
|
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
|
|
return detailsDiff('tool-use', summary, body);
|
|
}
|
|
function detailsDiff(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 diff-body';
|
|
// Color each line by its leading +/-.
|
|
for (const line of body.split('\n')) {
|
|
const span = document.createElement('span');
|
|
if (line.startsWith('+ ')) span.className = 'diff-add';
|
|
else if (line.startsWith('- ')) span.className = 'diff-del';
|
|
else span.className = 'diff-ctx';
|
|
span.textContent = line + '\n';
|
|
pre.appendChild(span);
|
|
}
|
|
d.appendChild(pre);
|
|
log.appendChild(d);
|
|
afterAppend();
|
|
return d;
|
|
}
|
|
|
|
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') {
|
|
// Write/Edit get a collapsed +/- diff body; everything
|
|
// else stays as the flat row produced by fmtToolUse.
|
|
if (!renderFileWriteEdit(c)) {
|
|
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;
|
|
})();
|