dashboard: remove the flow page and the endpoints only it used
The flow page showed the raw broker message stream and had a compose box. This removes the page and its bundle, its link in the pages menu, and the styles only it used. The history endpoint that backfilled it and the operator-send endpoint behind the compose box had no other caller, so they go too, along with the broker query behind the history endpoint. The stream endpoint stays: the dashboard tabs still subscribe to it. The docs lose the FL0W section and the references to the two endpoints.
This commit is contained in:
parent
acf82beba3
commit
fa131b46a6
24 changed files with 62 additions and 1121 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
|
||||
// dashboard at `/`, flow/stats at their own
|
||||
// dashboard at `/`, stats at its own
|
||||
// `.html`), each named after its own entry point below — see the
|
||||
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
||||
// avoid this comment drifting out of sync with the real build steps.
|
||||
|
|
@ -29,7 +29,7 @@ mkdirSync(staticDir(""), { recursive: true });
|
|||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src("tabs.js"), src("flow.js"), src("stats.js")],
|
||||
entryPoints: [src("tabs.js"), src("stats.js")],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
|
|
@ -58,7 +58,7 @@ await build({
|
|||
|
||||
// Stream-worker entry. Lives in a separate bundle: SharedWorker
|
||||
// scripts run in a different global (`self` is the worker scope, no
|
||||
// `window`) so they can't be inlined into tabs.js / flow.js. Output is
|
||||
// `window`) so they can't be inlined into tabs.js / stats.js. Output is
|
||||
// at `static/stream-worker.js`; common.js's `openStream` references
|
||||
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
|
||||
// matches the classic-script load (`new SharedWorker(url, name)` with
|
||||
|
|
@ -89,7 +89,6 @@ for (const entry of [
|
|||
"theme.css",
|
||||
"common.css",
|
||||
"dashboard.css",
|
||||
"flow.css",
|
||||
"stats.css",
|
||||
]) {
|
||||
await build({
|
||||
|
|
@ -101,7 +100,7 @@ for (const entry of [
|
|||
});
|
||||
}
|
||||
|
||||
for (const html of ["index.html", "flow.html", "stats.html"]) {
|
||||
for (const html of ["index.html", "stats.html"]) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -588,7 +588,7 @@ function buildContainerLi(c, opts) {
|
|||
export function renderContainers(s) {
|
||||
const root = $("containers-section");
|
||||
// #containers-section only exists on the dashboard. tabs.js is the
|
||||
// bundle for that page only (flow.html loads flow.js instead).
|
||||
// bundle for that page only.
|
||||
// Belt-and-suspenders for any future page that adds tabs.js
|
||||
// without a #containers-section — matches the
|
||||
// no-op-when-target-absent convention the other renderers
|
||||
|
|
|
|||
|
|
@ -337,148 +337,9 @@ code {
|
|||
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
|
||||
at the top of this file. */
|
||||
|
||||
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
|
||||
msgrow / msg-* rules below power the /flow.html full-page terminal. */
|
||||
.live .msgrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
padding: 0.1em 0;
|
||||
text-indent: 0;
|
||||
}
|
||||
.live .msgrow .msg-body {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.live .msgrow.sent .msg-arrow {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.live .msgrow.delivered .msg-arrow {
|
||||
color: var(--green);
|
||||
}
|
||||
/* Reply-thread rendering: indented border-left + muted reply tag. */
|
||||
.live .msgrow.msg-reply {
|
||||
padding-left: 1.2em;
|
||||
border-left: 2px solid var(--border);
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.msg-reply-tag {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
white-space: nowrap;
|
||||
order: -1;
|
||||
}
|
||||
.msg-reply-tag a {
|
||||
color: var(--muted);
|
||||
text-shadow: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
.msg-reply-tag a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
@keyframes msg-highlight-fade {
|
||||
from {
|
||||
background: color-mix(in srgb, var(--purple) 18%, transparent);
|
||||
}
|
||||
to {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
.msg-highlight {
|
||||
animation: msg-highlight-fade 1.5s ease-out forwards;
|
||||
}
|
||||
.msg-ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.msg-arrow {
|
||||
font-weight: bold;
|
||||
}
|
||||
.msg-from {
|
||||
color: var(--amber);
|
||||
}
|
||||
.msg-sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
.msg-to {
|
||||
color: var(--pink);
|
||||
}
|
||||
.msg-body {
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ─── operator compose box ──────────────────────────────────────────
|
||||
Sits inside `.terminal-wrap` on both /flow.html and the dashboard
|
||||
CALL tab. Dashed top separator mirrors the agent terminal prompt. */
|
||||
.op-compose {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
padding: 0.55em 0.8em;
|
||||
border-top: 1px dashed var(--purple-dim);
|
||||
}
|
||||
.op-compose-prompt {
|
||||
color: var(--purple);
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
padding-top: 0.15em;
|
||||
}
|
||||
.op-compose-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
max-height: 8em;
|
||||
padding: 0;
|
||||
}
|
||||
.op-compose-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.op-compose-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.op-compose-suggest {
|
||||
position: absolute;
|
||||
left: 3em;
|
||||
bottom: 100%;
|
||||
z-index: 50;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
min-width: 12em;
|
||||
max-height: 14em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.op-compose-suggest .item {
|
||||
padding: 0.35em 0.9em;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.op-compose-suggest .item.active,
|
||||
.op-compose-suggest .item:hover {
|
||||
background: var(--border);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
/* ─── side panel ─────────────────────────────────────────────────
|
||||
Long content (file previews, diffs, applied config) opens in
|
||||
a drawer that swipes in from the right — used by both /flow.html and
|
||||
a drawer that swipes in from the right on
|
||||
the dashboard. The drawer chrome itself (backdrop, drawer, resize
|
||||
handle, header, title, close button) is now the shared
|
||||
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left
|
||||
|
|
@ -566,8 +427,8 @@ hive-side-panel .md td {
|
|||
carries the horizontal gutter. Full-width chrome (the tab strip, the
|
||||
footer) then spans edge-to-edge for free,
|
||||
with no negative-margin breakout hacks. Only matches on pages that opt
|
||||
in by adding the wrapper (index.html); FL0W / ST4TS
|
||||
are full-bleed with their own `.<page>-main` padding. */
|
||||
in by adding the wrapper (index.html); ST4TS
|
||||
is full-bleed with its own `.<page>-main` padding. */
|
||||
.page-content {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shared dashboard helpers used by both index.html (./tabs.js) and
|
||||
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
||||
// and the path-link / file-preview infrastructure for the side panel.
|
||||
// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
|
||||
// the side-panel singleton, and the path-link / file-preview infrastructure
|
||||
// for the side panel.
|
||||
|
||||
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
|
|
|
|||
|
|
@ -180,8 +180,6 @@ body.dashboard-shell {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* FL0W is a separate page (`/flow.html`) — its full-viewport
|
||||
styling lives in flow.css. */
|
||||
.banner {
|
||||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
/* ─── /flow.html — full-page chat ─────────────────────────────────
|
||||
See docs/web-ui/dashboard.md::FL0W page for the page-vs-pane rationale.
|
||||
Shape mirrors the per-agent live page (frosted-glass header +
|
||||
composer, full-viewport terminal). */
|
||||
|
||||
:root {
|
||||
/* Approximate height of the flow chrome (the slim back-link header).
|
||||
The slug banner lives at the page footer on the dashboard, omitted
|
||||
on /flow.html since the full-viewport terminal has no normal-flow
|
||||
footer position. */
|
||||
--flow-header-h: 3.6em;
|
||||
--flow-composer-h: 3.6em;
|
||||
--flow-frost-bg: color-mix(in srgb, var(--bg) 74%, transparent);
|
||||
--flow-frost-blur: blur(12px) saturate(140%);
|
||||
}
|
||||
|
||||
body.flow-shell {
|
||||
/* Override the dashboard's centred-max-width layout — flow owns
|
||||
the whole viewport. */
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.flow-main {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* flow.html now has a slim header (back link only) instead of the full
|
||||
tabbar — reduce the top padding to match the smaller header height. */
|
||||
body.flow-shell .flow-main-slim {
|
||||
padding-top: calc(var(--flow-header-h) + 0.4em);
|
||||
}
|
||||
.flow-main .terminal-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.flow-main .live {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding-top: calc(var(--flow-header-h) + 0.8em);
|
||||
padding-bottom: calc(var(--flow-composer-h) + 0.8em);
|
||||
scroll-padding-top: calc(var(--flow-header-h) + 0.8em);
|
||||
scroll-padding-bottom: calc(var(--flow-composer-h) + 0.8em);
|
||||
overflow: auto;
|
||||
}
|
||||
/* Tail pill (↓ N new): bottom offset clears the floating composer.
|
||||
Pill is anchored on .flow-main (not .terminal-wrap) so the
|
||||
backdrop-filter stacking context doesn't trap its z-index — see
|
||||
docs/web-ui/agent.md::Per-agent page (Terminal-wrap) for the same
|
||||
gotcha on the agent page. */
|
||||
.flow-main .tail-pill {
|
||||
bottom: calc(var(--flow-composer-h) + 0.6em);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.flow-composer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-height: var(--flow-composer-h);
|
||||
background: var(--flow-frost-bg);
|
||||
-webkit-backdrop-filter: var(--flow-frost-blur);
|
||||
backdrop-filter: var(--flow-frost-blur);
|
||||
border-top: 1px solid var(--purple-dim);
|
||||
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
|
||||
padding: 0.4em 1em;
|
||||
}
|
||||
|
||||
/* Agent filter: a select in the FL0W header narrows the
|
||||
timeline to messages involving one agent. Non-matching rows get
|
||||
`.flow-hidden`. */
|
||||
.flow-filter {
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
background: var(--bg-elev);
|
||||
color: var(--amber);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.25em 0.5em;
|
||||
margin-left: auto;
|
||||
}
|
||||
.flow-filter:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.msgrow.flow-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // FL0W</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/flow.css" />
|
||||
</head>
|
||||
<body class="flow-shell">
|
||||
<!-- Minimal chrome: just a back link to the dashboard (served at /).
|
||||
No full tabbar — the flow page is a dedicated full-viewport
|
||||
terminal surface; navigating back is the only chrome needed. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← dashboard</a>
|
||||
<span class="page-title">FL0W</span>
|
||||
<select
|
||||
id="flow-agent-filter"
|
||||
class="flow-filter"
|
||||
title="filter timeline by agent"
|
||||
aria-label="filter timeline by agent"
|
||||
>
|
||||
<option value="">all agents</option>
|
||||
</select>
|
||||
</header>
|
||||
|
||||
<!-- Main content: the full-viewport terminal. Padded for the
|
||||
overlay header + composer so the first/last rows stay
|
||||
reachable. -->
|
||||
<main class="flow-main flow-main-slim">
|
||||
<div class="terminal-wrap">
|
||||
<div id="msgflow" class="live terminal">
|
||||
<div class="meta">connecting…</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
|
||||
as the header — symmetric framing, terminal goes edge-to-edge
|
||||
between them. -->
|
||||
<footer class="flow-composer">
|
||||
<div id="op-compose" class="op-compose">
|
||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||
<textarea
|
||||
id="op-compose-input"
|
||||
class="op-compose-input"
|
||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Flow-specific bundle. Contains the broker terminal init + the
|
||||
@-mention composer. Tab renderers etc. live in
|
||||
`/static/tabs.js` which /flow.html doesn't load. -->
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
// /flow.html entry point. Owns the full-page broker terminal and the
|
||||
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
|
||||
// side panel, path linkification) from `./common.js`. FL0W is the pure
|
||||
// event firehose.
|
||||
//
|
||||
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
||||
// the dashboard. The flow page runs purely on the broker stream + an
|
||||
// initial /api/state fetch (compose autocomplete needs the live
|
||||
// container list).
|
||||
|
||||
import { create as termCreate } from "@hive/shared/terminal.js";
|
||||
import { $, appendLinkified, openStream, initStickyTop } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { epochSec } from "./util.js";
|
||||
|
||||
(() => {
|
||||
initStickyTop();
|
||||
|
||||
// ─── local containers cache (for compose autocomplete) ──────────────────
|
||||
// The compose box's @-mention completion suggests known agent names.
|
||||
// the dashboard (tabs.js) maintains the canonical `containersState`
|
||||
// from /api/state + SSE; here we keep a small local mirror updated
|
||||
// by the same `container_state_changed` / `container_removed` events
|
||||
// the dashboard would handle.
|
||||
const flowContainers = new Map();
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
populateAgentFilter();
|
||||
})
|
||||
.catch(() => {
|
||||
/* graceful: compose just shows `*` and nothing else */
|
||||
});
|
||||
|
||||
// ─── agent filter ───────────────────────────────────────────────
|
||||
// A select in the FL0W header narrows the timeline to messages involving
|
||||
// one agent (matched on `from` OR `to`). Each rendered row carries
|
||||
// `data-from` / `data-to`; non-matching rows get `.flow-hidden`. New rows
|
||||
// pick up the active filter at render time (see renderMsg); changing the
|
||||
// filter re-scans existing rows. Selection persists in localStorage so a
|
||||
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
|
||||
// so it works regardless of the message-flow IIFE's local scope.
|
||||
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
|
||||
function rowMatchesFilter(from, to) {
|
||||
return !agentFilter || from === agentFilter || to === agentFilter;
|
||||
}
|
||||
function applyAgentFilter() {
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
for (const row of flow.children) {
|
||||
const { from, to } = row.dataset;
|
||||
if (from === undefined && to === undefined) continue; // non-message row
|
||||
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
|
||||
}
|
||||
}
|
||||
function populateAgentFilter() {
|
||||
const sel = $("flow-agent-filter");
|
||||
if (!sel) return;
|
||||
const names = [...flowContainers.keys()].sort();
|
||||
sel.replaceChildren();
|
||||
sel.append(el("option", { value: "" }, "all agents"));
|
||||
for (const n of names) sel.append(el("option", { value: n }, n));
|
||||
// Preserve a saved selection even if that agent isn't in the live
|
||||
// container list (yet / anymore) so the filter doesn't silently reset.
|
||||
if (agentFilter && !names.includes(agentFilter)) {
|
||||
sel.append(el("option", { value: agentFilter }, agentFilter));
|
||||
}
|
||||
sel.value = agentFilter;
|
||||
}
|
||||
{
|
||||
const sel = $("flow-agent-filter");
|
||||
if (sel) {
|
||||
sel.addEventListener("change", () => {
|
||||
agentFilter = sel.value;
|
||||
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
|
||||
else localStorage.removeItem("flow-agent-filter");
|
||||
applyAgentFilter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
||||
// What stays here is the broker-message renderer + the page-local
|
||||
// side effect (banner pulse).
|
||||
(() => {
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
flow.replaceChildren();
|
||||
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
|
||||
// Pulse the page banner whenever a broker event lands. The
|
||||
// `.banner` element lives in the dashboard's <footer> rather than
|
||||
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
||||
// there's no element to find. Kept for parity if a future chrome
|
||||
// change reintroduces a banner.
|
||||
const banner = document.querySelector(".banner");
|
||||
let bannerOffTimer = null;
|
||||
function pulseBanner() {
|
||||
if (!banner) return;
|
||||
banner.classList.add("active");
|
||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||
bannerOffTimer = setTimeout(
|
||||
() => banner.classList.remove("active"),
|
||||
4000,
|
||||
);
|
||||
}
|
||||
// Map of broker row id → rendered row element. Lets reply rows add
|
||||
// a visual "↳ in reply to" indicator that links back to the parent.
|
||||
// Bounded by the history window (~200 msgs from /dashboard/history),
|
||||
// well within normal memory.
|
||||
const msgRowMap = new Map();
|
||||
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const isReply = ev.in_reply_to != null;
|
||||
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
|
||||
const row = api.row(cls, "");
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement("span");
|
||||
ts.className = "msg-ts";
|
||||
ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "msg-arrow";
|
||||
arrow.textContent = glyph;
|
||||
const from = document.createElement("span");
|
||||
from.className = "msg-from";
|
||||
from.textContent = ev.from;
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "msg-sep";
|
||||
sep.textContent = "→";
|
||||
const to = document.createElement("span");
|
||||
to.className = "msg-to";
|
||||
to.textContent = ev.to;
|
||||
const body = document.createElement("span");
|
||||
body.className = "msg-body";
|
||||
appendLinkified(body, ev.body, ev.file_refs);
|
||||
// Reply thread indicator: a small "↳ reply to <from>" hint that
|
||||
// shows which message this is responding to. If we have the parent
|
||||
// in our row map, clicking scrolls it into view.
|
||||
if (isReply) {
|
||||
const replyTag = document.createElement("span");
|
||||
replyTag.className = "msg-reply-tag";
|
||||
const parentRow = msgRowMap.get(ev.in_reply_to);
|
||||
if (parentRow) {
|
||||
const link = document.createElement("a");
|
||||
link.href = "#";
|
||||
link.textContent = "↳ reply";
|
||||
link.title = "scroll to parent message";
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
parentRow.classList.add("msg-highlight");
|
||||
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
|
||||
});
|
||||
replyTag.append(link);
|
||||
} else {
|
||||
replyTag.textContent = "↳ reply";
|
||||
}
|
||||
row.prepend(replyTag);
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
} else {
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
}
|
||||
// Tag with the participants so the agent filter can match
|
||||
// on `from`/`to`, and hide the row up-front if a filter is active.
|
||||
row.dataset.from = ev.from;
|
||||
row.dataset.to = ev.to;
|
||||
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
|
||||
// Register this row so future replies can reference it.
|
||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
// Sent→Delivered collapse. When a message directly wakes its
|
||||
// recipient the broker emits `sent` then `delivered` for the same row
|
||||
// id back-to-back, rendering two near-identical lines. We track each
|
||||
// recent `sent` row and, when its `delivered` lands within
|
||||
// COLLAPSE_SECS, upgrade that row in place (✓) instead of adding a
|
||||
// second line. A delivery that arrives *later* (recipient was busy)
|
||||
// stays a separate row so the latency remains visible.
|
||||
const COLLAPSE_SECS = 3;
|
||||
const recentSent = new Map(); // broker row id → { row, at }
|
||||
function rememberSent(ev, row) {
|
||||
if (ev.id == null || ev.id <= 0) return;
|
||||
recentSent.set(ev.id, { row, at: epochSec(ev.at) });
|
||||
// Bound the map — drop the oldest entries once it grows past a
|
||||
// page of un-collapsed sends (insertion order = oldest first).
|
||||
while (recentSent.size > 256) {
|
||||
recentSent.delete(recentSent.keys().next().value);
|
||||
}
|
||||
}
|
||||
// Returns true if the delivered event was folded into its sent row.
|
||||
function collapseDelivered(ev) {
|
||||
if (ev.id == null || ev.id <= 0) return false;
|
||||
const s = recentSent.get(ev.id);
|
||||
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
|
||||
const arrow = s.row.querySelector(".msg-arrow");
|
||||
if (arrow) arrow.textContent = "✓";
|
||||
// Re-style the row as delivered (green ✓) — the collapsed line now
|
||||
// represents the delivered state; it was sent + delivered as one.
|
||||
s.row.classList.remove("sent");
|
||||
s.row.classList.add("delivered");
|
||||
s.row.title = "sent + delivered";
|
||||
recentSent.delete(ev.id);
|
||||
return true;
|
||||
}
|
||||
// Anchor the `↓ N new` pill in `.flow-main` rather than the
|
||||
// default `.terminal-wrap` parent — see docs/web-ui/agent.md::Per-agent
|
||||
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
||||
// gotcha (same shape on the flow page).
|
||||
const flowMain = document.querySelector(".flow-main");
|
||||
termCreate({
|
||||
logEl: flow,
|
||||
pillAnchor: flowMain,
|
||||
historyUrl: "/api/dashboard/history",
|
||||
// Server-side filter — only the kinds this page actually renders
|
||||
// or routes (sent/delivered → broker terminal,
|
||||
// container_state_changed/_removed → local autocomplete cache).
|
||||
// Backend pre-parses the allow-list at subscribe time so the
|
||||
// per-frame hot path is one HashSet::contains and the
|
||||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||
// since it routes every mutation kind into its derived stores.
|
||||
streamUrl:
|
||||
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
|
||||
// Route through the SharedWorker — see docs/web-ui/shape.md (SSE
|
||||
// multiplexing paragraph). Worker keys on the full URL incl.
|
||||
// query string, so this filtered subscribe is its own upstream
|
||||
// and won't accidentally share with tabs.js's wider subscribe.
|
||||
streamFactory: openStream,
|
||||
renderers: {
|
||||
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
|
||||
delivered: (ev, api) => {
|
||||
// Fold into the matching sent row when it just happened;
|
||||
// otherwise render the delivery as its own line.
|
||||
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
|
||||
},
|
||||
// Maintain the local containers cache from the same stream
|
||||
// (compose autocomplete reads from `flowContainers`). The
|
||||
// dashboard's tab renderers aren't on this page, so we don't
|
||||
// need to dispatch to applyContainerStateChanged etc. — just
|
||||
// keep the autocomplete list current.
|
||||
container_state_changed: (ev) => {
|
||||
if (ev.container && ev.container.name) {
|
||||
flowContainers.set(ev.container.name, ev.container);
|
||||
populateAgentFilter(); // keep the filter dropdown current
|
||||
}
|
||||
},
|
||||
container_removed: (ev) => {
|
||||
flowContainers.delete(ev.name);
|
||||
populateAgentFilter();
|
||||
},
|
||||
// Drop every other mutation kind silently — without this
|
||||
// they'd fall through to the terminal module's default
|
||||
// renderer and clutter the log with JSON dumps. The dashboard
|
||||
// tabs handle these on the dashboard via tabs.js.
|
||||
_default: () => {},
|
||||
},
|
||||
// Re-sync the local containers cache on every SSE (re)connect.
|
||||
// Live mutation events that fired during a disconnect window
|
||||
// are never replayed, so without this the compose autocomplete
|
||||
// could drift stale.
|
||||
onStreamOpen: () => {
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
onLiveEvent: () => pulseBanner(),
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── compose: @-mention with sticky recipient ───────────────────────────
|
||||
(() => {
|
||||
const input = $("op-compose-input");
|
||||
const prompt = $("op-compose-prompt");
|
||||
const suggest = $("op-compose-suggest");
|
||||
if (!input || !prompt || !suggest) return;
|
||||
const STORAGE_KEY = "hyperhive:op-compose:to";
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
|
||||
let suggestActive = -1;
|
||||
function renderPrompt() {
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
|
||||
}
|
||||
function knownAgents() {
|
||||
// Read live from the flow-local containers cache so newly-spawned
|
||||
// agents become addressable without a manual reload.
|
||||
const names = Array.from(flowContainers.values()).map((c) => c.name);
|
||||
// `*` fans out to every registered agent (server-side
|
||||
// broadcast_send).
|
||||
names.unshift("*");
|
||||
return names;
|
||||
}
|
||||
function autosize() {
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${input.scrollHeight}px`;
|
||||
}
|
||||
/// Parse "@name body…" — return {to, body} when the input opens
|
||||
/// with a known @-mention, otherwise null.
|
||||
function parseAddressed(raw) {
|
||||
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
|
||||
if (!m) return null;
|
||||
return { to: m[1], body: m[2] };
|
||||
}
|
||||
function hideSuggest() {
|
||||
suggest.hidden = true;
|
||||
suggest.replaceChildren();
|
||||
suggestActive = -1;
|
||||
}
|
||||
function renderSuggest(matches) {
|
||||
suggest.replaceChildren();
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "item" + (i === suggestActive ? " active" : "");
|
||||
item.textContent = "@" + matches[i];
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(matches[i]);
|
||||
});
|
||||
suggest.append(item);
|
||||
}
|
||||
suggest.hidden = false;
|
||||
}
|
||||
function applySuggestion(name) {
|
||||
// Replace the partial @-token at the start with the full name.
|
||||
const v = input.value;
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (m) {
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
|
||||
} else {
|
||||
input.value = `@${name} ` + v;
|
||||
}
|
||||
hideSuggest();
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
autosize();
|
||||
}
|
||||
function updateSuggest() {
|
||||
const v = input.value;
|
||||
// Only suggest while the cursor is still inside the @-token at the
|
||||
// very start of the input — switching recipient is always "redirect
|
||||
// this whole line." Once whitespace follows the token the recipient
|
||||
// is chosen: the list must close, or it swallows the Enter that
|
||||
// should send. Mid-message @-mentions stay literal.
|
||||
const m = v.match(/^@(\S*)$/);
|
||||
if (!m) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
const partial = m[1].toLowerCase();
|
||||
const matches = knownAgents().filter((n) =>
|
||||
n.toLowerCase().startsWith(partial),
|
||||
);
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
if (suggestActive < 0 || suggestActive >= matches.length)
|
||||
suggestActive = 0;
|
||||
renderSuggest(matches);
|
||||
}
|
||||
async function submit() {
|
||||
const raw = input.value.trim();
|
||||
if (!raw) return;
|
||||
let to;
|
||||
let body;
|
||||
const addressed = parseAddressed(raw);
|
||||
if (addressed) {
|
||||
to = addressed.to;
|
||||
body = addressed.body.trim();
|
||||
} else if (stickyTo) {
|
||||
to = stickyTo;
|
||||
body = raw;
|
||||
} else {
|
||||
flashError("no recipient — start with @name to address a message");
|
||||
return;
|
||||
}
|
||||
if (!body) return;
|
||||
const fd = new FormData();
|
||||
fd.append("to", to);
|
||||
fd.append("body", body);
|
||||
input.disabled = true;
|
||||
try {
|
||||
// /op-send returns 200. The SSE channel carries the resulting
|
||||
// MessageEvent → the terminal renders the sent row on its own;
|
||||
// no /api/state refetch needed.
|
||||
const resp = await fetch("/api/op-send", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
flashError(`send failed: http ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
flashError(`send failed: ${err}`);
|
||||
return;
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
stickyTo = to;
|
||||
localStorage.setItem(STORAGE_KEY, to);
|
||||
input.value = "";
|
||||
autosize();
|
||||
renderPrompt();
|
||||
input.focus();
|
||||
}
|
||||
function flashError(msg) {
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msgrow meta";
|
||||
row.textContent = "✗ " + msg;
|
||||
// Append at the bottom so the error is visible — the terminal
|
||||
// renders newest-last, so inserting before firstChild would place
|
||||
// the error at the top (oldest end) and hide it from view.
|
||||
flow.append(row);
|
||||
// Scroll the terminal wrap so the error is in view.
|
||||
const wrap = flow.parentElement;
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
autosize();
|
||||
updateSuggest();
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (!suggest.hidden) {
|
||||
if (e.key === "ArrowDown") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive + 1) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive - 1 + items.length) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
const active = suggest.querySelector(".item.active");
|
||||
if (active) {
|
||||
applySuggestion(active.textContent.slice(1));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
hideSuggest();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
// Defer so a click on a suggestion item (mousedown) lands first.
|
||||
setTimeout(hideSuggest, 100);
|
||||
});
|
||||
renderPrompt();
|
||||
autosize();
|
||||
})();
|
||||
})();
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
button / shared links keep you on the same view. JS owns
|
||||
the show/hide. -->
|
||||
<header class="dashboard-chrome">
|
||||
<!-- Pages menu: the standalone pages (flow, stats, API docs) and the
|
||||
<!-- Pages menu: the standalone pages (stats, API docs) and the
|
||||
swarm UI. Filled by links-menu.js from /api/state. The pages link
|
||||
back to `/`, which is this dashboard. -->
|
||||
<div class="links-menu" id="links-menu"></div>
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||
under 4G3NTS. See tab-pane-agents.) -->
|
||||
|
||||
<!-- FL0W and ST4TS are standalone pages reached from the pages
|
||||
<!-- ST4TS is a standalone page reached from the pages
|
||||
menu at the left of this strip, not tabs — the strip holds only
|
||||
in-page tabs. -->
|
||||
</nav>
|
||||
|
|
@ -216,8 +216,8 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ST4TS and FL0W live on their own pages (`/stats.html`,
|
||||
`/flow.html`). Their DOM only exists there — when tabs.js boots
|
||||
<!-- ST4TS lives on its own page (`/stats.html`).
|
||||
Its DOM only exists there — when tabs.js boots
|
||||
on this page the corresponding renderers no-op silently (each
|
||||
guard is `if (!el) return`). -->
|
||||
</main>
|
||||
|
|
@ -271,7 +271,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||
flow.html has its own flow.js bundle. esbuild folds
|
||||
esbuild folds
|
||||
@hive/shared/terminal.js and the marked npm package into
|
||||
tabs.js. -->
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Pages menu in the dashboard chrome: one button that opens a popover
|
||||
// linking the standalone pages (flow, stats, API docs) and the swarm UI.
|
||||
// linking the standalone pages (stats, API docs) and the swarm UI.
|
||||
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
|
||||
// when `/api/state` carries `swarm_public_url`, so a hive without a
|
||||
// configured swarm domain shows no dead link.
|
||||
|
|
@ -10,7 +10,6 @@ import { el } from "@hive/shared/dom.js";
|
|||
/** Menu entries for a state snapshot. `external` opens in a new tab. */
|
||||
export function menuLinks(state) {
|
||||
const links = [
|
||||
{ icon: "📡", label: "flow", url: "/flow.html" },
|
||||
{ icon: "📊", label: "stats", url: "/stats.html" },
|
||||
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
|
||||
Extracted from the dashboard ST4TS tab. Same minimal-
|
||||
chrome pattern as /flow.html: the back-link header +
|
||||
Extracted from the dashboard ST4TS tab. Minimal chrome:
|
||||
the back-link header +
|
||||
title (.page-header / .page-back / .page-title) live in
|
||||
@hive/shared/chrome.css.
|
||||
`.hive-stats-table` lives in common.css. Only the ST4TS-specific
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<link rel="stylesheet" href="/static/stats.css" />
|
||||
</head>
|
||||
<body class="stats-shell">
|
||||
<!-- Minimal chrome: back link + title. Same pattern as flow.html —
|
||||
<!-- Minimal chrome: back link + title —
|
||||
no full dashboard tabbar. Back link points to the
|
||||
dashboard (served at /). -->
|
||||
<header class="page-header">
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
||||
//
|
||||
// Pure helpers (DOM, side panel, path linkification)
|
||||
// live in `./common.js`; the flow-page surface (broker
|
||||
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
||||
// `index.html` each load their own bundle.
|
||||
// live in `./common.js`.
|
||||
// 4G3NTS (containers) domain lives in `./agents.js`.
|
||||
|
||||
import { marked } from "marked";
|
||||
|
|
@ -46,7 +44,7 @@ import { renderLinksMenu } from "./links-menu.js";
|
|||
|
||||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||
// panel preview path. Set it here on the dashboard entry so file
|
||||
// previews work; flow.js does the same for the flow-page entry.
|
||||
// previews work.
|
||||
window.marked = marked;
|
||||
|
||||
(() => {
|
||||
|
|
@ -206,12 +204,10 @@ window.marked = marked;
|
|||
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
||||
// `sent` / `delivered`, anything new the backend adds) silently
|
||||
// no-op. On (re)connect we kick a refreshState() to recover events
|
||||
// lost during the disconnect window (same pattern as flow.js's
|
||||
// onStreamOpen).
|
||||
// lost during the disconnect window.
|
||||
//
|
||||
// Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
|
||||
// and filter client-side — the dashboard ignores broker traffic
|
||||
// and FL0W ignores mutation events.
|
||||
// The dashboard subscribes to `/api/dashboard/stream` and ignores broker
|
||||
// traffic client-side.
|
||||
const MUTATION_HANDLERS = {
|
||||
transient_set: applyTransientSet,
|
||||
transient_cleared: applyTransientCleared,
|
||||
|
|
@ -276,11 +272,9 @@ window.marked = marked;
|
|||
// ─── tab routing ───────────────────────────────────────────────────────
|
||||
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
|
||||
// activate the matching pane on the dashboard. Empty
|
||||
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
|
||||
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
||||
// the pages menu (links-menu.js). Tab routing only
|
||||
// applies when the tab DOM is present (e.g. not on the flow page
|
||||
// itself, where these elements don't exist and the loop no-ops).
|
||||
// hash defaults to 4G3NTS. ST4TS is NOT a tab — it is a separate page
|
||||
// (`/stats.html`) reached from the pages menu (links-menu.js). Tab routing
|
||||
// only applies when the tab DOM is present.
|
||||
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
||||
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
||||
// per-tab side-effects via the strip's onShow. Constructed below (after
|
||||
|
|
@ -337,7 +331,4 @@ window.marked = marked;
|
|||
// — no per-renderer hookup needed.
|
||||
refreshTabCounts();
|
||||
setInterval(refreshTabCounts, 1000);
|
||||
|
||||
// Flow-specific code (broker terminal init + the @-mention composer)
|
||||
// lives in ./flow.js — loaded only by /flow.html.
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* ─── page chrome: back-link header ────────────────────────────────
|
||||
The minimal sticky "frosted bar" header used by every standalone page
|
||||
reached from the dashboard's pages menu — /flow, /stats. A back
|
||||
reached from the dashboard's pages menu — /stats. A back
|
||||
link to the dashboard on the left, then a page title and, on /stats, a
|
||||
sub-tab nav. The operator dashboard uses its own richer
|
||||
sticky chrome (`.dashboard-chrome`); these classes cover the simple
|
||||
|
|
|
|||
Loading…
Reference in a new issue