agent: route terminal scroll+backfill+SSE through hive-fr0nt::TERMINAL_JS
This commit is contained in:
parent
0b9e7cbcf6
commit
f27108aecf
5 changed files with 308 additions and 215 deletions
217
hive-fr0nt/assets/terminal.js
Normal file
217
hive-fr0nt/assets/terminal.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
|
||||
// backfill + live SSE. Pages provide a kind→renderer map; this module
|
||||
// owns scroll behaviour, animation suppression on backfill, and the
|
||||
// EventSource lifecycle.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// HiveTerminal.create({
|
||||
// logEl: document.getElementById('msgflow'),
|
||||
// historyUrl: '/messages/history?limit=200', // optional
|
||||
// streamUrl: '/messages/stream',
|
||||
// renderers: {
|
||||
// sent: (ev, api) => api.row('msgrow sent', ...),
|
||||
// delivered: (ev, api) => api.row('msgrow delivered', ...),
|
||||
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
|
||||
// },
|
||||
// onLiveEvent: (ev) => { /* side effects: notifications, state pokes */ },
|
||||
// onBackfillDone: (count) => { /* one-shot after history replay */ },
|
||||
// pillAnchor: document.getElementById('msgflow').parentElement,
|
||||
// });
|
||||
//
|
||||
// Renderers receive (ev, api) where api exposes:
|
||||
//
|
||||
// api.row(cls, text) → appends a flat <div class="row cls">
|
||||
// api.details(cls, summary, body) → appends <details class="row cls">
|
||||
// with a <pre.tool-body>
|
||||
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
|
||||
// leading "+ " / "- " prefix
|
||||
// api.placeholder(text) → replaces log content with a single
|
||||
// muted "(placeholder)" row, cleared
|
||||
// on the next real row
|
||||
// api.fromHistory → true while backfill is replaying
|
||||
//
|
||||
// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall
|
||||
// through to `renderers._default` (which itself defaults to a JSON-dump
|
||||
// note row). The convention is that the SSE/history endpoints emit
|
||||
// objects with a `kind` field.
|
||||
//
|
||||
// Backfill is best-effort: if `historyUrl` is unset or the fetch fails,
|
||||
// we skip straight to SSE. The optional `onBackfillDone(count)` hook
|
||||
// fires after replay finishes (or after a failed/skipped fetch with
|
||||
// count=0); pages use it to set state flags from the replayed history.
|
||||
|
||||
(function () {
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
|
||||
function create(opts) {
|
||||
const log = opts.logEl;
|
||||
if (!log) throw new Error('HiveTerminal.create: logEl is required');
|
||||
const renderers = opts.renderers || {};
|
||||
const defaultRender = renderers._default
|
||||
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
||||
const pillAnchor = opts.pillAnchor || log.parentElement || log;
|
||||
|
||||
let placeholderEl = null;
|
||||
let pill = null;
|
||||
let unseen = 0;
|
||||
let currentNoAnim = false;
|
||||
|
||||
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; });
|
||||
pillAnchor.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 clearPlaceholder() {
|
||||
if (placeholderEl && placeholderEl.parentElement === log) {
|
||||
log.removeChild(placeholderEl);
|
||||
}
|
||||
placeholderEl = null;
|
||||
}
|
||||
function placeholder(text) {
|
||||
clearPlaceholder();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row note';
|
||||
e.textContent = text;
|
||||
log.appendChild(e);
|
||||
placeholderEl = e;
|
||||
}
|
||||
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 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';
|
||||
for (const line of String(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 api(extra) {
|
||||
return Object.assign({
|
||||
row, details, detailsDiff, placeholder,
|
||||
fromHistory: false,
|
||||
}, extra || {});
|
||||
}
|
||||
function dispatch(ev, fromHistory) {
|
||||
const r = renderers[ev.kind] || defaultRender;
|
||||
try {
|
||||
r(ev, api({ fromHistory }));
|
||||
} catch (err) {
|
||||
console.error('terminal renderer threw', ev, err);
|
||||
row('note', '[render err] ' + (err && err.message ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
if (!opts.historyUrl) {
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(opts.historyUrl);
|
||||
if (!resp.ok) {
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
const events = await resp.json();
|
||||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
currentNoAnim = false;
|
||||
if (events.length) row('note', '─── live (older above) ───');
|
||||
else placeholder('(connected — waiting for events)');
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
||||
} catch (err) {
|
||||
console.warn('history backfill failed', err);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
const es = new EventSource(opts.streamUrl);
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); }
|
||||
catch (err) { row('note', '[parse err] ' + e.data); return; }
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]');
|
||||
else row('note', '[disconnected]');
|
||||
};
|
||||
return es;
|
||||
}
|
||||
|
||||
const ready = backfill().then(subscribe);
|
||||
return { row, details, detailsDiff, placeholder, ready };
|
||||
}
|
||||
|
||||
window.HiveTerminal = { create };
|
||||
})();
|
||||
|
|
@ -31,3 +31,4 @@
|
|||
|
||||
pub const BASE_CSS: &str = include_str!("../assets/base.css");
|
||||
pub const TERMINAL_CSS: &str = include_str!("../assets/terminal.css");
|
||||
pub const TERMINAL_JS: &str = include_str!("../assets/terminal.js");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue