From f60a90d75296dcfc54ff35e54fff154959e8b76a Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 15:14:30 +0200 Subject: [PATCH] shared terminal + docs: migrate sticky-scroll + backfill prose (#714 batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantial prose migration from @hive/shared/terminal.js (the shared HiveTerminal factory backing #msgflow + #live across both dashboard and per-agent UIs) into a new docs subsection. Added to docs/web-ui.md as a new ### Shared terminal pane subsection under ## Shape (shared by both) — ~78 lines of new substantive prose: - **api shape**: row / details / detailsDiff factory contract - **Sticky-bottom + snap animation**: stickToBottom semantics + 140ms ease-out vs 500ms browser default + 24px short-circuit; per-frame target re-eval extends destination through mid-animation mutations - **Mid-animation scroll-event guard**: smoothScrollingUntil timestamp swallows the rAF-driven scroll events so the eased positions don't flip stickToBottom false partway - **Post-append MutationObserver**: catches renderer mutations after api.row returns (badges, multi-line bodies, tool panes) + why programmatic scrollTop writes don't feedback-loop - **Backfill + SSE**: history/stream envelope shape (seq, events), kind-aware seq dedupe at the boundary, .no-anim during replay, optional streamFactory for SharedWorker integration - **linkify**: text-node-only autolink, XSS-safe by construction, trailing-punctuation strip Collapsed in terminal.js (cookies en passant): - #400 (snap animation timing — closed) × 3 → docs pointers - #393 (post-append MutationObserver — closed) × 1 → docs pointer - #375 (pre-append nearBottom snapshot — closed) × 1 → docs pointer - #448 (streamFactory SharedWorker hook — closed) × 1 → docs pointer - #163 (seq dedupe + onStreamOpen resync — closed) × 2 → drop cookies; substance lives in docs - #233 (linkify) × 1 → docs pointer + terminal.css cookie scrub terminal.js: 8 → 0 #NNN cookies (100% reduction). terminal.css: 1 → 0 issue-ref cookies (remaining 1 match is a hex color literal). Net effect: ~50 lines of substantive WHY-prose moved out of shared frontend into docs/web-ui.md, where it documents the factory's contract for both consumer pages. --- docs/web-ui.md | 78 +++++++++++++++++ frontend/packages/shared/src/terminal.css | 2 +- frontend/packages/shared/src/terminal.js | 101 +++++++--------------- 3 files changed, 110 insertions(+), 71 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 9f91d8c2..2f089862 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -72,6 +72,84 @@ since #753). Both are SPAs — `GET /` returns a static shell, bfcache-restore uses). Recovery is per-tab; pings are invisible on the healthy path. +### Shared terminal pane + +Both surfaces' scrollable log streams (`#msgflow` on the dashboard, +`#live` on the per-agent page) are backed by the shared terminal +factory in `@hive/shared/terminal.js`. The factory wires up +sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and +SSE replay. Pages register a `kind → renderer` map; unknown kinds +fall through to a JSON-dump note row. The factory ships three row +shapes the renderers call: + +- `api.row(cls, text)` — single-line row with an inline `linkify` + pass over the text. +- `api.details(cls, summary, body)` — collapsible `
` with + a `
` body (used by long tool-results and stack traces).
+- `api.detailsDiff(cls, summary, body)` — same shape, splits the
+  body on newlines and tags each line as `diff-add` / `diff-del` /
+  `diff-ctx` so the renderer's diff bodies get coloured without
+  emitting raw HTML.
+
+**Sticky-bottom + snap animation.** `stickToBottom` is the
+operator's intent: true means "keep snapping to bottom on every
+mutation", false means "I scrolled up, leave me alone". The flag
+flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
+from the bottom. New rows then either snap to bottom (when sticky)
+or bump the unseen-count and surface the "↓ N new" pill. The snap
+is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
+default `behavior: 'smooth'` ~500ms reads as "still smooth, but
+visibly slow"; 140ms feels snap-y while still reading as motion
+rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
+short-circuit to instant — animating a 12px nudge would just be
+jitter. Each new snap cancels the previous `requestAnimationFrame`
+so a burst of mutations coalesces into one ride to the latest
+bottom; the per-frame step re-reads `scrollHeight - clientHeight`
+so mutations landing mid-animation extend the destination smoothly
+rather than land short.
+
+**Mid-animation scroll-event guard.** The scroll handler's
+`isNearBottom` check would flip `stickToBottom` false mid-snap as
+the smooth animation eases through positions that are technically
+"not near bottom yet", which would strand the operator partway. A
+`smoothScrollingUntil` timestamp gates the scroll handler — set to
+the animation end + ~80ms headroom, re-armed on each fresh snap.
+Programmatic `scrollTop` writes (the animation's per-frame update)
+fire scroll events that the gate swallows.
+
+**Post-append `MutationObserver`.** Renderers commonly call
+`api.row(cls, text)` to create the row shell then append more
+children (badges, multi-line bodies, tool result panes) after the
+factory returned. The initial sticky-snap fires off the row's
+empty shape; the renderer's later appends grow the row past the
+visible bottom. A `MutationObserver` on the log subtree fires once
+per microtask after each batch of synchronous mutations and snaps
+again when `stickToBottom` is true. Programmatic `scrollTop`
+writes don't re-trigger the observer (scroll isn't a DOM
+mutation), so no feedback loop. The pre-append
+`nearBottomBeforeAppend` snapshot is still useful — it keeps the
+initial visual lag to one frame instead of one microtask + frame.
+
+**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
+subscribes to `streamUrl` (live tail). Both endpoints return
+`{ seq, events }` so the client can dedupe — events with
+`seq <= snapshot.seq` from the SSE stream are dropped silently
+(the snapshot already covers them). History rows render with a
+`.no-anim` class so they don't stagger in like live events. The
+optional `streamFactory(url)` callback lets the dashboard hand
+the factory a `SharedWorker`-backed `EventSource` facade (so
+multiple tabs share one upstream connection — see *SSE
+multiplexing* above); when omitted, the factory falls back to a
+plain `new EventSource(url)`.
+
+**`linkify` (text-node based).** Bare `http(s)://` URLs in row
+text get wrapped in ``
+inside a fresh text node, so the autolinker never touches
+`innerHTML` and untrusted row content can't smuggle markup. The
+trailing-punctuation strip keeps `.,;:` outside the link surface.
+Markdown bodies go through `marked` separately and get the same
+target rewrite.
+
 The JS app handles all `form[data-async]` submissions via a delegated
 listener: read `data-confirm`, swap the button to a spinner, POST
 `application/x-www-form-urlencoded`, re-enable the button on success
diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css
index b28449a1..1aeb0e5c 100644
--- a/frontend/packages/shared/src/terminal.css
+++ b/frontend/packages/shared/src/terminal.css
@@ -213,7 +213,7 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
   border-radius: 0;
 }
 .live .row .md a { color: var(--cyan); text-decoration: underline; }
-/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
+/* 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; }
diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js
index b47b08c2..2ce101ce 100644
--- a/frontend/packages/shared/src/terminal.js
+++ b/frontend/packages/shared/src/terminal.js
@@ -50,12 +50,9 @@
 // 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.
+// 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;
 
@@ -77,16 +74,9 @@ export function create(opts) {
   // 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.
+  // 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
@@ -96,16 +86,10 @@ export function create(opts) {
   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.
+  // 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) {
@@ -161,48 +145,26 @@ export function create(opts) {
     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.
+    // Swallow scroll events during smooth-snap animations — see
+    // docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
+    // guard).
     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.
+  // 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 });
 
-  // 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.)
+  // 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();
@@ -318,12 +280,11 @@ export function create(opts) {
     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.
+    // 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);
@@ -351,7 +312,7 @@ export function create(opts) {
       // 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).
+      // reload.
       if (opts.onStreamOpen) {
         try { opts.onStreamOpen(); }
         catch (err) { console.error('onStreamOpen threw', err); }
@@ -370,7 +331,7 @@ export function create(opts) {
         // 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.
+        // fetch. ev.seq absent/0 → no dedupe possible.
         if (boundarySeq != null
             && typeof ev.seq === 'number' && ev.seq <= boundarySeq
             && historyKinds && historyKinds.has(ev.kind)) {
@@ -427,9 +388,9 @@ export function create(opts) {
 }
 
 // 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)
+// 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);