frontend: move shared/terminal.js+css into one-dir terminal/

Same pure structural move as the previous commit, applied to the one
other remaining genuine component in shared/src (a self-contained
widget with its own behaviour + CSS, same class as hive-btn/hive-
dialog/hive-toast/hive-menu/side-panel/tabs) -- not the CSS-foundation
files (colors/theme/base/chrome.css) or the utility modules (forms.js,
dom.js, modal.js, shadow-css.js), which aren't components and don't
fit the one-dir-per-component convention.

External callers resolve terminal.js/terminal.css only through
@hive/shared's exports map, so again the two exports targets are the
only external-facing change. index.js's own internal re-export uses a
relative path within the package, so that needed updating too. Zero
call-site changes outside @hive/shared. Verified the built dashboard
(flow.js/common.css) and agent (app.js/agent.css) bundles still
resolve both files.
This commit is contained in:
iris 2026-08-01 01:09:19 +02:00 committed by mara
commit d50bea588a
4 changed files with 3 additions and 3 deletions

View file

@ -0,0 +1,318 @@
/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill.
Pages wrap their stream container in `.terminal-wrap` and give the log
itself the `.live` class; renderer JS appends `.row` (flat line) or
`details.row` (collapsible body) elements. Row-kind classes
(`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event
colour; pages that don't emit a given kind simply never produce that
class the unused rule sits in the bundle harmlessly.
`.terminal-wrap` provides the crust-on-black phosphor chrome that
makes the agent page feel like a terminal. Pages can opt in by
wrapping a block in this class; or skip it and the rows still render
with their class colours, just without the frame.
No `.term-input` here composers are a separate concern, owned by
each page's own CSS. Row taxonomy + layout contract documented in
`docs/web-ui.md::Shared terminal pane` and
`docs/terminal-rendering.md`. */
.terminal-wrap {
position: relative;
background: color-mix(in srgb, var(--crust) 78%, transparent);
-webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%);
border: 1px solid var(--purple-dim);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.92em;
color: var(--fg);
margin-top: 0.6em;
}
.live {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
overflow-y: auto;
max-height: 32em;
font-family: inherit;
/* Disable browser scroll anchoring the terminal manages scroll
position manually (snapToBottom + loadMore compensation). Scroll
anchoring would double-compensate scrollTop during loadMore()
prepends, causing an erratic jump. */
overflow-anchor: none;
}
.live.terminal {
background: transparent;
border: 0;
box-shadow: none;
border-radius: 0;
padding: 0.8em 1em 0.4em;
overflow-y: auto;
height: min(72vh, 60em);
max-height: none;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.live .row,
.live details.row {
animation: row-fade-in 220ms ease-out both;
}
.live .row.no-anim,
.live details.row.no-anim {
animation: none;
}
@keyframes row-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Unified prefix column for every row kind. The glyph (` · !`)
is the first character of the row's text content; `padding-left` reserves
the column and `text-indent: -1.4em` pulls the glyph back into it. Wrapped
continuation lines then start under the body, not under the glyph, so
wraps don't blur into the next row. `details.row` summaries reuse the
same metrics below. */
.live .row {
white-space: pre-wrap;
word-break: break-word;
padding: 0.05em 0;
line-height: 1.45;
border-left: 2px solid transparent;
padding-left: 1.9em;
text-indent: -1.4em;
margin: 0.1em 0;
}
.live .row + .row { border-top: 0; }
/* Fixed-width icon column. Rows built with an `icon` (see terminal.js
`row()` / `details()`) put it in a `.row-glyph` element instead of as a
bare first character. `inline-block` with a fixed `width` means the icon
occupies one constant-width cell regardless of the glyph's rendered width
(emoji differ; some carry a variation selector), so every icon's left edge
lines up a flat-row `🧠` and a `details` summary's `🖥` share the column.
It's the first inline box, so the row's `text-indent: -1.4em` pulls it into
the reserved prefix slot exactly like a bare glyph; the following text then
starts at the `padding-left` (1.9em) where wrapped lines also hang. */
.live .row-glyph {
display: inline-block;
width: 1.4em;
}
/* Row-kind colours. Pages register renderers that emit these classes;
any class no page emits is just dead CSS, which is fine. Turn-framing
classes carry their signal entirely on the coloured border-left rule
no bold, no top/bottom margins, no background tint. The chrome was
overweight for what's just a "this is a boundary" marker. */
.live .turn-start { color: var(--amber); border-left-color: var(--amber); }
/* turn-body is a child block under turn-start carrying the wake-prompt
body; reset text-indent so wrapped content stays under its own column
instead of pulling back into the parent's prefix. */
.live .turn-body { color: var(--fg); text-indent: 0; margin-top: 0.15em; }
/* Any child block (markdown body, nested details) resets the parent
row's hanging indent so the content lays out from column 0 of the
body area. */
.live .row .md, .live .row > details { text-indent: 0; }
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
/* Wall-clock time (+ duration on turn-end) appended to the turn-start /
turn-end rows. Dim + smaller so the boundary glyph stays the focus and
the timestamp reads as metadata. */
.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; }
.live .text { color: var(--fg); }
.live .thinking { color: var(--muted); font-style: italic; }
.live .tool-use { color: var(--cyan); }
.live .tool-result { color: var(--muted); }
.live .tool-result.error { color: var(--red); }
.live .tool-result-block.error { color: var(--red); }
.live .result { color: var(--green); }
.live .note { color: var(--muted); }
/* Distinguish stderr lines (orange) and operator-initiated notes
(mauve, lightly emphasised) from ambient harness chatter so the
eye picks out anomalies + operator actions in the scrollback. */
.live .note.stderr { color: var(--amber); }
.live .note.op { color: var(--purple); font-style: italic; }
/* The .sys catch-all fires when renderStream landed an event shape it
couldn't classify. Make it visually loud so silently-dropped event
types surface for follow-up. */
.live .sys { color: var(--amber); }
.live .unread-badge {
color: var(--amber);
font-weight: normal;
margin-left: 0.6em;
font-size: 0.85em;
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
animation: badge-pulse 1.4s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
50% { opacity: 0.7; text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent); }
}
/* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. Positioned by the wrapper's
`position: relative` (terminal-wrap supplies it; pages that skip the
wrapper must add their own positioned ancestor). */
.tail-pill {
position: absolute;
right: 1em;
bottom: 4.2em;
background: var(--amber);
color: var(--crust);
font-family: inherit;
font-size: 0.8em;
font-weight: bold;
letter-spacing: 0.08em;
border: 0;
border-radius: 999px;
padding: 0.35em 0.9em;
cursor: pointer;
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--amber) 85%, transparent);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.tail-pill.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tail-pill:hover { filter: brightness(1.1); }
/* "↑ load older" pill: sits inline at the top of the log (not
absolutely positioned) so it scrolls with the content. Appears
when `has_more` is true after initial history load. */
.load-more-pill {
display: block;
width: 100%;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 0.8em;
font-weight: bold;
letter-spacing: 0.08em;
border: 0;
border-bottom: 1px dashed var(--purple-dim);
padding: 0.4em 1em;
cursor: pointer;
text-align: left;
transition: color 120ms ease, background 120ms ease;
}
.load-more-pill:hover:not(:disabled) {
color: var(--fg);
background: var(--border);
}
.load-more-pill:disabled {
cursor: default;
opacity: 0.6;
}
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
negative text-indent). The summary's icon (when present) sits in the
shared `.row-glyph` column same cell as a flat row's icon so a
`details` summary's `🖥️` lines up under a flat row's `🧠`. The
disclosure caret (` / `) leads the `.summary-text` (not the icon) via
`::before`, so it sits where the summary text starts rather than shoving
the icon out of the prefix column. Icon-less summaries have no `.row-glyph`,
so the caret falls back into the prefix column like the old directional
glyph. The summary text carries no ` / `; the row colour (cyan =
outbound tool, muted = inbound result) carries the direction. */
details.row {
white-space: normal;
}
/* Two-column layout for expandable summary rows.
Left col: fixed-width icon cell. Right col: disclosure chevron +
text, wrapping within itself so no continuation line bleeds under
the icon. Overrides the flat-row hanging-indent metrics (.live .row
sets padding-left: 1.9em; text-indent: -1.4em) with explicit flex
geometry. The negative margin-left on summary cancels the details
container's inherited padding-left so the icon lands at the same
horizontal position as flat-row icons. */
details.row > summary {
cursor: pointer;
list-style: none;
white-space: pre-wrap;
word-break: break-word;
display: flex;
align-items: baseline;
margin-left: -1.9em;
padding-left: 0.5em;
text-indent: 0;
}
/* Icon column: 2em accommodates wide emoji without ink bleeding into
the chevron column. Overrides the inline-block + width: 1.4em set
on .live .row-glyph for the shared flat-row context. */
details.row > summary > .row-glyph {
flex: 0 0 2em;
width: auto;
text-align: center;
}
/* Content column: chevron (::before) + text, wraps within the cell. */
details.row > summary > .summary-text {
flex: 1;
min-width: 0;
}
/* Icon-less summaries: the CSS-generated / acts as the hanging
marker. Hanging indent keeps wrapped lines under the text body,
not under the chevron. Mirrors the flat-row text-indent metric. */
details.row > summary > .summary-text:only-child {
padding-left: 1.4em;
text-indent: -1.4em;
}
details.row > summary > .summary-text::before {
content: '▸ ';
color: inherit;
}
details.row[open] > summary > .summary-text::before { content: '▾ '; }
details.row > pre.diff-body,
details.row > pre.tool-body {
margin: 0.3em 0 0.4em 0;
padding: 0.4em 0.6em;
text-indent: 0;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--purple-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 22em;
overflow-y: auto;
}
details.row > pre.tool-body { color: var(--fg); }
details.row > pre.diff-body .diff-add { color: var(--green); }
details.row > pre.diff-body .diff-del { color: var(--red); }
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
/* Markdown body inside a row (assistant text, send/recv/ask/answer
message bodies). Inline elements get muted accents; block elements
reset the parent row's hanging indent so content lays out cleanly. */
.live .row .md p { margin: 0.2em 0; }
.live .row .md p:first-child { margin-top: 0; }
.live .row .md p:last-child { margin-bottom: 0; }
.live .row .md code {
background: rgba(255, 255, 255, 0.06);
padding: 0.05em 0.3em;
border-radius: 3px;
font-size: 0.95em;
}
.live .row .md pre {
margin: 0.3em 0;
padding: 0.4em 0.6em;
background: rgba(255, 255, 255, 0.04);
border-left: 2px solid var(--purple-dim);
text-indent: 0;
white-space: pre-wrap;
word-break: break-word;
}
.live .row .md pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.live .row .md a { color: var(--cyan); text-decoration: underline; }
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; }
.live .row .md em { color: inherit; font-style: italic; }
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
.live .row .md li { margin: 0.05em 0; }
.live .row .md blockquote {
margin: 0.2em 0;
padding-left: 0.6em;
border-left: 2px solid var(--purple-dim);
color: var(--muted);
}

View file

@ -0,0 +1,550 @@
// 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.
//
// create(opts) — full options list + renderer api (api.row, api.details,
// api.detailsDiff, api.placeholder, api.fromHistory) + behavioral notes:
// docs/web-ui/shape.md §Shared terminal pane.
const NEAR_BOTTOM_PX = 48;
// Scroll distance from the top of the log that triggers an automatic
// "load older" fetch — fires via the scroll event handler so the operator
// never has to click the pill; the pill stays as a visual indicator.
const LOAD_MORE_SCROLL_PX = 80;
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
// 500ms-browser-default + 24px short-circuit rationale.
const SCROLL_ANIM_MS = 140;
const SCROLL_SNAP_PX = 24;
export function create(opts) {
// `log` is `let` not `const` so loadMore() can temporarily redirect
// row/details/etc. into a detached temp element while prepending older
// history (restored before any scrollTop adjustments).
let log = opts.logEl;
if (!log) throw new Error('HiveTerminal.create: logEl is required');
const rootLog = log; // always the real DOM element — never reassigned
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;
// Pagination state for the "load older" feature.
let histMinId = null;
let histHasMore = false;
let histLoading = false;
let loadMoreBtn = null;
// 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;
// Scroll-handler gate during in-flight snap animations — see
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
// guard) for the eased-through-not-near-bottom rationale.
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. Cancels
// any in-flight frame loop so back-to-back snaps coalesce; falls
// back to instant scroll during `currentNoAnim` backfill replay
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
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', () => {
// Sticky-bottom intent tracking. Outside an animation this is
// straightforward — stickToBottom = isNearBottom(). During a
// smooth-snap animation we swallow most of the event to avoid a
// feedback loop (programmatic scrollTop changes → scroll events →
// new snap → cancels current rAF), but we MUST still let the user
// break out of sticky mode: if the user scrolls away from the
// bottom while a snap animation is in flight, honour that intent
// immediately so the MutationObserver stops re-firing snapToBottom()
// and the animation-guard window can expire naturally. Without this,
// live events arriving < 220ms apart permanently block scroll-to-top
// and loadMore() never fires. See docs/web-ui.md::Shared terminal
// pane (Mid-animation scroll-event guard).
const inAnim = Date.now() < smoothScrollingUntil;
const nearBottom = isNearBottom();
if (!inAnim) {
stickToBottom = nearBottom;
} else if (!nearBottom) {
// User scrolled up during an animation — break sticky mode so the
// MO stops calling snapToBottom() and the gate expires.
stickToBottom = false;
}
if (stickToBottom) { unseen = 0; updatePill(); }
if (inAnim) return;
// Auto-fetch older history when the user scrolls near the top — no
// click required; the load-more pill stays as a visual indicator.
if (rootLog.scrollTop <= LOAD_MORE_SCROLL_PX && histHasMore && !histLoading) {
loadMore();
}
});
// Post-append mutation snap — catches renderer mutations that land
// after `api.row` returns (badges, multi-line bodies, tool
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why the pre-append `afterAppend` hop
// alone isn't enough.
const mo = new MutationObserver(() => {
if (stickToBottom) snapToBottom();
});
mo.observe(log, { childList: true, subtree: true, characterData: true });
// Pre-append nearBottom snapshot drives the initial snap decision —
// see docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why we need both this and the MO.
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;
}
// A leading icon (`→ ← 🧠 🖥️ …`) goes in a fixed-width `.row-glyph`
// element so every row's icon lands in one column regardless of the
// glyph's rendered width (emoji vary; some carry variation selectors).
// Optional: callers that pass no `icon` keep the bare first-character
// prefix the older rows rely on.
function glyphSpan(icon) {
const g = document.createElement('span');
g.className = 'row-glyph';
g.textContent = icon;
return g;
}
// Build a <summary> whose icon (if any) sits in the shared `.row-glyph`
// column and whose text lives in a `.summary-text` span — the disclosure
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
// so the icon stays aligned with flat-row icons.
function buildSummary(summary, icon) {
const s = document.createElement('summary');
if (icon != null && icon !== '') s.appendChild(glyphSpan(icon));
const st = document.createElement('span');
st.className = 'summary-text';
st.textContent = summary;
s.appendChild(st);
return s;
}
function row(cls, text, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend(wasNearBottom);
return e;
}
// Like row(), but returns [element, textNode] so the caller can update
// the text in place via textNode.nodeValue. Use for rows whose content
// changes after initial render (e.g. live-updating counters).
// Text is stored as a plain text node — no linkify, no innerHTML.
function mutableRow(cls, text, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
const tn = document.createTextNode(text == null ? '' : String(text));
e.appendChild(tn);
log.appendChild(e);
afterAppend(wasNearBottom);
return [e, tn];
}
function details(cls, summary, body, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
d.appendChild(buildSummary(summary, icon));
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, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
d.appendChild(buildSummary(summary, icon));
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, mutableRow, 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); }
}
}
// ── Load-older machinery ───────────────────────────────────────
//
// When the initial backfill response includes `has_more: true`, a
// "↑ load older" button appears at the top of the log. Clicking it
// fetches the next page (`?before=<min_id>`) and prepends the events
// while holding the viewport steady so the operator's reading position
// doesn't jump.
function updateLoadMoreBtn() {
if (!histHasMore || !opts.historyUrl) {
if (loadMoreBtn && loadMoreBtn.parentElement) {
loadMoreBtn.parentElement.removeChild(loadMoreBtn);
}
loadMoreBtn = null;
return;
}
if (!loadMoreBtn) {
loadMoreBtn = document.createElement('button');
loadMoreBtn.type = 'button';
loadMoreBtn.className = 'load-more-pill';
loadMoreBtn.addEventListener('click', loadMore);
rootLog.prepend(loadMoreBtn);
}
loadMoreBtn.textContent = '↑ load older';
loadMoreBtn.disabled = false;
}
async function loadMore() {
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null) return;
histLoading = true;
if (loadMoreBtn) { loadMoreBtn.textContent = '↑ loading…'; loadMoreBtn.disabled = true; }
try {
const sep = opts.historyUrl.includes('?') ? '&' : '?';
const url = opts.historyUrl + sep + 'before=' + histMinId;
const resp = await fetch(url);
if (!resp.ok) { updateLoadMoreBtn(); return; }
const body = await resp.json();
const events = Array.isArray(body) ? body : (body.events || []);
histHasMore = body.has_more || false;
if (typeof body.min_id === 'number') histMinId = body.min_id;
// Resolve load-more button state before capturing the scroll
// baseline so that any button removal is already reflected in
// beforeH — otherwise the button's height would be missing from
// the delta and the viewport would drift up by that amount.
updateLoadMoreBtn();
if (events.length > 0) {
// Render into a detached element; `log` is temporarily redirected
// so that row/details/etc. append there instead of rootLog.
const tempEl = document.createElement('div');
log = tempEl;
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
currentNoAnim = false;
log = rootLog;
// Separator to mark the boundary between loaded-older and newer.
const sepEl = document.createElement('div');
sepEl.className = 'row note no-anim';
sepEl.textContent = '─── older above ───';
tempEl.appendChild(sepEl);
// Insert before the "live" divider (i.e. right after the load-more
// button if present, else at the very top of rootLog).
const anchor = loadMoreBtn ? loadMoreBtn.nextSibling : rootLog.firstChild;
const beforeH = rootLog.scrollHeight;
while (tempEl.firstChild) rootLog.insertBefore(tempEl.firstChild, anchor);
// Compensate scroll so the viewport stays on the same content.
// overflow-anchor: none on .live ensures the browser does not
// also auto-adjust scrollTop (which would double the delta).
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
}
} catch (err) {
console.warn('loadMore failed', err);
updateLoadMoreBtn();
} finally {
histLoading = false;
}
}
// 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 = [];
// Optional streamFactory(url) → EventSource-shaped facade. Lets
// the dashboard hand the factory a SharedWorker-backed source so
// open hyperhive tabs share one upstream — see
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
// falls back to `new EventSource(url)`.
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.
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. 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, min_id?, has_more? }`.
// A bare array means the server hasn't been updated — treat it
// as "no dedupe possible, no pagination."
const events = Array.isArray(body) ? body : (body.events || []);
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
// Pagination cursors — set on the outer load-more state.
if (!Array.isArray(body)) {
histHasMore = body.has_more || false;
if (typeof body.min_id === 'number') histMinId = body.min_id;
}
// 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);
// Show load-older button if the server reports more history.
updateLoadMoreBtn();
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, mutableRow, details, detailsDiff, placeholder, ready };
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. See docs/web-ui.md::Shared
// terminal pane (linkify) for the text-node-only / no-innerHTML
// XSS-safety + trailing-punctuation strip.
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;
}