// 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: // // import { create, linkify } from '@hive/shared/terminal.js'; // // 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) => { /* live-only side effects (notif, state pokes) */ }, // onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in // both backfill replay and live — use for derived views that need // the full picture (e.g. a per-recipient inbox built from broker // events) */ }, // onBackfillDone: (count) => { /* one-shot after history replay */ }, // onStreamOpen: () => { /* fires on every EventSource (re)connect — // use to re-sync snapshot-derived state after a reconnect gap */ }, // pillAnchor: document.getElementById('msgflow').parentElement, // }); // // Renderers receive (ev, api) where api exposes: // // api.row(cls, text) → appends a flat
// api.details(cls, summary, body) → appends
// with a // 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. const NEAR_BOTTOM_PX = 48; // Snap-to-bottom animation duration (#400 + mara feedback). Browser // default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read // as "still smooth, but visibly slow." 140ms with ease-out is fast // enough to feel snap-y, slow enough that the row's destination // reads as motion (not a jump). Distances under SCROLL_SNAP_PX // short-circuit to instant — animating a 12px nudge is just jitter. const SCROLL_ANIM_MS = 140; const SCROLL_SNAP_PX = 24; export 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; // Sticky-bottom intent. True means "keep snapping to bottom on // any mutation"; false means "the operator scrolled up — leave // them alone". Updated synchronously from the scroll event // handler so both programmatic scrollTop assignments and // operator-driven wheel/drag stay in sync. let stickToBottom = true; // Guards scroll-event-handler from misreading the position while // our own animation is mid-flight (#400). The animation drives // scrollTop with rAF, which fires a stream of scroll events as // the position eases toward the target — the position passes // through "not near bottom" before settling. Without this gate, // the scroll handler flips `stickToBottom` to false mid-animation, // which then causes the MutationObserver to skip the next snap // and leaves the operator stranded mid-scroll. Set to the // animation's nominal end + small headroom; each fresh snap // re-arms it so back-to-back snaps stay gated. let smoothScrollingUntil = 0; // rAF id for the current snap animation. Cancelled when a new // snap starts so we never have two animations fighting over // scrollTop. let scrollAnimRaf = 0; function isNearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; } // Snap the log to the bottom with a brief eased animation // (#400 + mara: snappier than the browser's default 500ms smooth // scroll). Each call cancels the previous frame loop and starts a // fresh one, so a burst of mutations coalesces into one ride to // the latest bottom. Re-evaluates the target each frame so a // renderer mutation landing mid-animation extends the destination // without a visible jump. Falls back to instant scroll when // `currentNoAnim` is true (backfill replay — operator never sees // intermediate positions, animation is wasted frames) or when the // remaining distance is under SCROLL_SNAP_PX. function snapToBottom(immediate) { stickToBottom = true; if (scrollAnimRaf) { cancelAnimationFrame(scrollAnimRaf); scrollAnimRaf = 0; } const target = log.scrollHeight - log.clientHeight; const start = log.scrollTop; const distance = target - start; if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) { smoothScrollingUntil = 0; log.scrollTop = target; return; } smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80; const t0 = performance.now(); const easeOut = (t) => 1 - Math.pow(1 - t, 3); const step = (now) => { const elapsed = now - t0; const frac = Math.min(1, elapsed / SCROLL_ANIM_MS); // Re-read target each frame so mutations landing mid-animation // (the common case — a renderer appended badge / body bits // after api.row returned) extend the destination smoothly // rather than landing short. const currentTarget = log.scrollHeight - log.clientHeight; log.scrollTop = start + (currentTarget - start) * easeOut(frac); if (frac < 1) { scrollAnimRaf = requestAnimationFrame(step); } else { // Final exact settle on the as-of-now bottom. log.scrollTop = log.scrollHeight - log.clientHeight; scrollAnimRaf = 0; } }; scrollAnimRaf = requestAnimationFrame(step); } function ensurePill() { if (pill) return pill; pill = document.createElement('button'); pill.type = 'button'; pill.className = 'tail-pill'; pill.addEventListener('click', () => snapToBottom()); 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', () => { // Mid-smooth-scroll: ignore the intermediate scroll events. The // gate releases when the animation has had time to settle (or // when the next snap re-arms it). Without this, easing toward // bottom would flip `stickToBottom` false partway and the next // MO callback would skip the snap. if (Date.now() < smoothScrollingUntil) return; stickToBottom = isNearBottom(); if (stickToBottom) { unseen = 0; updatePill(); } }); // Post-append mutations (issue #393). Renderers commonly call // `api.row(cls, text)` to create the row shell, then mutate it // by appending more children (badges, multi-line bodies, tool // result panes) AFTER api.row returned. The afterAppend scroll // below only sees the row's INITIAL height — once the renderer // adds the body, the row's grown past the visible bottom and // the operator is left scrolled to the row's TOP, breaking // stick-to-bottom for every subsequent event. // // Fix: MutationObserver on the log subtree. Fires once per // microtask after each batch of synchronous mutations, so it // runs once per renderer call regardless of how many children // the renderer appends. When `stickToBottom` is true, snap to // bottom again — catches whatever the renderer added after the // afterAppend hop. Programmatic `scrollTop = scrollHeight` // assignments don't re-trigger MO (the scroll itself isn't a // DOM mutation), so no feedback loop. const mo = new MutationObserver(() => { if (stickToBottom) snapToBottom(); }); mo.observe(log, { childList: true, subtree: true, characterData: true }); // Auto-scroll decision uses the PRE-append scroll position // (issue #375). Checking after the append underestimates // "nearness" because the new row's own height has already pushed // `scrollHeight - scrollTop - clientHeight` past the threshold, // even when the user was visually at the bottom an instant ago. // Each row/details/detailsDiff captures `nearBottomBeforeAppend` // and hands it to afterAppend so the auto-scroll triggers // whenever the operator was at the bottom when the row landed. // (The MutationObserver above catches the AFTER-row mutations // too, but this initial scroll keeps the visual lag to one // frame instead of one microtask + frame.) function afterAppend(wasNearBottom) { if (currentNoAnim || wasNearBottom) { snapToBottom(); } 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 wasNearBottom = isNearBottom(); const e = document.createElement('div'); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.appendChild(linkify(text)); log.appendChild(e); afterAppend(wasNearBottom); return e; } function details(cls, summary, body) { clearPlaceholder(); const wasNearBottom = isNearBottom(); 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.appendChild(linkify(body)); d.appendChild(pre); log.appendChild(d); afterAppend(wasNearBottom); return d; } function detailsDiff(cls, summary, body) { clearPlaceholder(); const wasNearBottom = isNearBottom(); 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(wasNearBottom); return d; } function api(extra) { return Object.assign({ row, details, detailsDiff, placeholder, linkify, 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)); } if (opts.onAnyEvent) { try { opts.onAnyEvent(ev, { fromHistory }); } catch (err) { console.error('onAnyEvent threw', err); } } } // Subscribe → buffer → fetch history → dedupe → apply. // // Race the SSE subscription opens before the history fetch starts. // Live events that land before history resolves are buffered, not // rendered. Once the history response (`{ seq, events }`) arrives we: // 1. Replay `events` (fromHistory=true). // 2. Drop buffered events with `seq <= history.seq` — they're // already reflected in the history rows above. // 3. Apply remaining buffered events (fromHistory=false). // 4. Switch to live mode: each new SSE event dispatches immediately. // // Without this dance an event that fires between history-fetch and // SSE-subscribe goes missing; without seq dedupe the same event // shows twice (once via history, once via live buffer). Both bugs // were latent before. // // If `historyUrl` is unset we skip the dance: buffered events apply // as live the moment the buffer flushes (no dedupe possible without // a boundary seq). function start() { let live = false; let buffered = []; // #448: callers can supply a `streamFactory(url)` that returns an // EventSource-shaped object (must expose onmessage/onopen/onerror // + .close()). The dashboard pages pass a SharedWorker-backed // factory so all open hyperhive tabs share ONE upstream SSE // connection. Default keeps the direct `new EventSource(url)` // behaviour so non-dashboard consumers (per-agent UI) are unchanged. const es = opts.streamFactory ? opts.streamFactory(opts.streamUrl) : new EventSource(opts.streamUrl); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch (err) { row('note', '[parse err] ' + e.data); return; } if (!live) { buffered.push(ev); return; } dispatch(ev, false); if (opts.onLiveEvent) { try { opts.onLiveEvent(ev); } catch (err) { console.error('onLiveEvent threw', err); } } }; es.onerror = () => { // SharedWorker-backed facades expose `readyState` mirroring the // upstream EventSource state; the native EventSource exposes the // same. Either way the CONNECTING vs. closed distinction works. if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]'); else row('note', '[disconnected]'); }; es.onopen = () => { // Fires on the initial connect and on every automatic // reconnect. EventSource never replays events that fired // during a disconnect window, so a consumer with // snapshot-derived state (the dashboard's /api/state stores) // must re-sync here or it shows stale state until a manual // reload (issue #163). if (opts.onStreamOpen) { try { opts.onStreamOpen(); } catch (err) { console.error('onStreamOpen threw', err); } } }; function flushBuffered(boundarySeq, historyKinds) { const drained = buffered; buffered = []; live = true; for (const ev of drained) { // Seq-dedupe only events of a kind that actually appeared in // the history replay — those are the only ones that could // double (once via history, once via the live buffer). // Mutation events (approval/question/container/…) are never // carried by the history endpoint; deduping them against the // broker-history seq would wrongly drop ones that fired // between a consumer's own snapshot read and this history // fetch (issue #163). ev.seq absent/0 → no dedupe possible. if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq && historyKinds && historyKinds.has(ev.kind)) { continue; } dispatch(ev, false); if (opts.onLiveEvent) { try { opts.onLiveEvent(ev); } catch (err) { console.error('onLiveEvent threw', err); } } } } async function backfill() { if (!opts.historyUrl) { flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); return; } try { const resp = await fetch(opts.historyUrl); if (!resp.ok) { flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); return; } const body = await resp.json(); // Accept the envelope `{ seq, events }`. A bare array means // the server hasn't been updated to include seq yet — treat // it as "no dedupe possible." const events = Array.isArray(body) ? body : (body.events || []); const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null); // Kinds present in the history replay — the only kinds that // can double and therefore the only ones to seq-dedupe. const historyKinds = new Set(events.map((ev) => ev.kind)); 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)'); flushBuffered(boundarySeq, historyKinds); if (opts.onBackfillDone) opts.onBackfillDone(events.length); } catch (err) { console.warn('history backfill failed', err); flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); } } return backfill(); } const ready = start(); return { row, details, detailsDiff, placeholder, ready }; } // Build a DocumentFragment from `text`, turning bare http(s) URLs into // clickable links that open in a new tab. Non-URL text stays as plain // text nodes — no innerHTML, so this is XSS-safe. Trailing sentence // punctuation is kept out of the link. (issue #233) const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g; export function linkify(text) { const str = text == null ? '' : String(text); const frag = document.createDocumentFragment(); if (str.indexOf('://') === -1) { // fast path: no URLs if (str) frag.appendChild(document.createTextNode(str)); return frag; } let last = 0; let m; LINKIFY_URL_RE.lastIndex = 0; while ((m = LINKIFY_URL_RE.exec(str)) !== null) { let url = m[0]; // Don't swallow trailing punctuation that's really sentence text. const trail = url.match(/[.,;:!?)\]}'"]+$/); const tail = trail ? trail[0] : ''; if (tail) url = url.slice(0, -tail.length); if (m.index > last) { frag.appendChild(document.createTextNode(str.slice(last, m.index))); } if (!url.slice(url.indexOf('://') + 3)) { // Nothing past the scheme — not a real URL, emit verbatim. frag.appendChild(document.createTextNode(m[0])); } else { const a = document.createElement('a'); a.href = url; // regex only matches https?:// — safe a.textContent = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; frag.appendChild(a); if (tail) frag.appendChild(document.createTextNode(tail)); } last = m.index + m[0].length; } if (last < str.length) { frag.appendChild(document.createTextNode(str.slice(last))); } return frag; }