Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: drop the path-link and side-panel code the flow page was the only user of

appendLinkified had one caller, the flow page, and it pulled in the
path-link and file-preview chain, the side-panel singleton, the marked
and DOMPurify wiring and the css only they used. The docs stop
describing the dashboard side panel and the client reading file_refs.
This commit is contained in:
iris 2026-10-21 23:06:53 +02:00 • committed by mara
commit e7f977b99e
7 changed files with 9 additions and 437 deletions

View file

@ -84,7 +84,7 @@ JS renders — no full-page reloads.
side panel, slash commands, and per-agent endpoints. side panel, slash commands, and per-agent endpoints.
- **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE - **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE
multiplexing, terminal pane, listener bind, per-agent relative multiplexing, terminal pane, listener bind, per-agent relative
paths, `data-async` form pattern, side panel, atomic repaint. paths, `data-async` form pattern, atomic repaint.
- **[CSS theme variables](css-vars.md)** — the Catppuccin Mocha custom - **[CSS theme variables](css-vars.md)** — the Catppuccin Mocha custom
properties declared once in `base.css` and the rule that per-page properties declared once in `base.css` and the rule that per-page
stylesheets reference (never redeclare) them. stylesheets reference (never redeclare) them.

View file

@ -564,19 +564,14 @@ below — some endpoints aren't in it yet.
and the host form. Canonicalises + verifies the path stays and the host form. Canonicalises + verifies the path stays
inside the allow-list, refuses anything but a regular file, inside the allow-list, refuses anything but a regular file,
refuses `/agents/<n>/claude` / `config` subtrees, truncates refuses `/agents/<n>/claude` / `config` subtrees, truncates
bodies at 1 MiB. Selection-time backing for the inline path-link bodies at 1 MiB. No dashboard page calls it since the flow page
preview. stopped existing.
<!-- vale write-good.Passive = NO --> The broker forwarder still detects path tokens **server-side at
The broker forwarder detects which tokens *are* path links broker-message ingest**: it calls `scan_validated_paths(body)` —
**server-side at broker-message ingest**, not client-side:
it calls `scan_validated_paths(body)` —
same allow-list helper the read endpoint uses — and attaches same allow-list helper the read endpoint uses — and attaches
the verified file tokens to the event as `file_refs: Vec<String>`. the verified file tokens to the event as `file_refs: Vec<String>`.
The client trusts that list and linkifies only those tokens, No dashboard page reads that list either.
so directories, missing files, and forbidden subtrees never
become anchors. No probe endpoint, no client-side regex
heuristics.
- `GET /api/reminders` — list pending reminders for the - `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel. dashboard's queued-reminders panel.
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide - `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide

View file

@ -243,9 +243,7 @@ values rather than per-row caches.
**`<details>` open-state preservation:** any collapsible element **`<details>` open-state preservation:** any collapsible element
tagged with `data-restore-key="<stable-key>"` survives the tagged with `data-restore-key="<stable-key>"` survives the
refresh. `snapshotOpenDetails()` walks managed sections before refresh. `snapshotOpenDetails()` walks managed sections before
render, `restoreOpenDetails()` re-applies after. Long-content render, `restoreOpenDetails()` re-applies after. The
drill-ins (file previews, diffs) now open in the
**side panel** (see below) rather than expanding inline. The
dashboard's own answered-questions history list was the only dashboard's own answered-questions history list was the only
`<details data-restore-key>` consumer and is gone `<details data-restore-key>` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have — `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
@ -254,35 +252,6 @@ that needs it rather than ripped out along with their one caller.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
**Side panel (dashboard):** long content opens in a drawer that
swipes in from the right — a singleton `#side-panel` with a
titled header, a close button, and a scrollable body. Closes on
the button, selecting the backdrop, or `Escape`. `Panel.open(title,
node)` swaps the body; the JS builder for file previews
renders into it. **The
drawer width is drag-to-resize**: a thin 6px hit-strip on
the left edge captures pointer events, resizes the drawer in
real-time (pointer capture keeps dragging even if the cursor
outpaces the handle), and persists the chosen width to
`localStorage` (key `hyperhive:side-panel-width`) so it
survives page reload. CSS clamps the width between `min-width: 320px`
and `max-width: 96vw`; the viewport-resize handler re-clamps
persisted values after a window shrink. File
previews are type-aware:
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
tabbed view: `rendered` (default) is the vendored `marked`
bundle (`GET /static/marked.js`), `plain` is the raw source.
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
`rendered` shows the image via an `<img>` `data:` URI (the
browser's secure static mode, so an untrusted SVG can't run
scripts), `source` shows the raw markup.
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
`.bmp` / `.ico` / `.avif`) — render as an `<img>` pointed at
`/api/state-file`, which serves them as binary with their
real content-type (text files stay UTF-8-lossy `text/plain`).
- **Everything else** — raw text in a `<pre>`.
## Listener bind ## Listener bind
Both bind their TCP listener with `SO_REUSEADDR` via Both bind their TCP listener with `SO_REUSEADDR` via

View file

@ -265,162 +265,6 @@ code {
border-color: var(--amber); border-color: var(--amber);
} }
/* ─── file-preview / diff panel (common.js Panel) ───────────────────
Side-panel content rendered by openFilePanel / buildTabbedPreview
in common.js; all three pages load the Panel singleton. */
.diff-panel {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.diff-base-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.diff-base-tab {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font: inherit;
font-size: 0.85em;
padding: 0.2em 0.7em;
cursor: pointer;
}
.diff-base-tab:hover {
color: var(--fg);
}
.diff-base-tab.active {
color: var(--purple);
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
/* Image / tabbed file preview */
.preview-host {
margin-top: 0.5em;
}
.img-preview {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border: 1px solid var(--border);
border-radius: 4px;
/* checkerboard so transparent regions of the image read clearly */
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
50% / 18px 18px;
}
/* Path linkification — agents drop pointer strings into messages;
clicking the anchor opens the file in the side panel. */
.path-link {
color: var(--blue);
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover {
color: var(--amber);
}
/* File-preview body — rendered inside the side panel. */
.path-preview-body {
background: var(--bg);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
color: var(--fg);
}
/* page chrome (.page-header / .page-back / .page-title — the standalone
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
at the top of this file. */
/* ─── side panel ─────────────────────────────────────────────────
Long content (file previews, diffs, applied config) opens in
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
here is the drag cursor-override escape hatch (has to be a global,
light-DOM rule — it reaches every element on the page during a drag,
not just the shared element's own shadow tree) and the styling for
the panel's own content types, which the shared element's shadow tree
can never reach (see @hive/shared/side-panel/hive-side-panel.css's
header comment). The `hive-side-panel .md …` rules below use the
element's own tag name as the selector root — no compatibility class
needed, the tag name already uniquely identifies the light-DOM
instance `common.js`'s `sidePanel` creates. */
body.side-panel-resizing {
user-select: none;
}
body.side-panel-resizing * {
cursor: ew-resize !important;
}
hive-side-panel .md {
color: var(--fg);
line-height: 1.5;
}
hive-side-panel .md > :first-child {
margin-top: 0;
}
hive-side-panel .md > :last-child {
margin-bottom: 0;
}
hive-side-panel .md p {
margin: 0.5em 0;
}
hive-side-panel .md h1,
hive-side-panel .md h2,
hive-side-panel .md h3,
hive-side-panel .md h4 {
color: var(--purple);
margin: 0.9em 0 0.4em;
}
hive-side-panel .md code {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.1em 0.3em;
font-size: 0.9em;
}
hive-side-panel .md pre {
background: var(--crust);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
overflow-x: auto;
margin: 0.5em 0;
}
hive-side-panel .md pre code {
background: none;
border: none;
padding: 0;
}
hive-side-panel .md a {
color: var(--cyan);
}
hive-side-panel .md ul,
hive-side-panel .md ol {
margin: 0.4em 0;
padding-left: 1.5em;
}
hive-side-panel .md blockquote {
border-left: 3px solid var(--purple-dim);
padding-left: 0.8em;
margin: 0.4em 0;
color: var(--muted);
}
hive-side-panel .md table {
border-collapse: collapse;
margin: 0.5em 0;
}
hive-side-panel .md th,
hive-side-panel .md td {
border: 1px solid var(--border);
padding: 0.2em 0.5em;
}
/* ─── page-content gutter ───────────────────────────────────────────── /* ─── page-content gutter ─────────────────────────────────────────────
Inverted-gutter layout: the dashboard keeps `<body>` full-bleed Inverted-gutter layout: the dashboard keeps `<body>` full-bleed
and wraps their padded content in a single `.page-content` div that and wraps their padded content in a single `.page-content` div that

View file

@ -1,12 +1,8 @@
// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers, // Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
// the side-panel singleton, and the path-link / file-preview infrastructure // the shared-worker SSE pipe, and the sticky top region.
// for the side panel.
import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js"; import { el } from "@hive/shared/dom.js";
import "@hive/shared/side-panel.js"; // registers <hive-side-panel> — side-effect import
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
import DOMPurify from "dompurify";
// ─── helpers ──────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id); export const $ = (id) => document.getElementById(id);
@ -343,225 +339,6 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
return es; return es;
} }
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, applied config)
// opens here via `sidePanel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
//
// The instance is created once, eagerly, at module evaluation time — ES
// modules run after the document is parsed (same timing `defer` scripts
// get), so `document.body` already exists here. Exported directly (not
// wrapped in a forwarding object) — callers use the element's own
// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
// @hive/shared/side-panel.js for what they do. `common.css` reaches the
// slotted content via a plain `hive-side-panel .md …` tag-name selector
// (no compatibility class needed — the element's own tag name already
// uniquely identifies it in the light DOM).
export const sidePanel = document.createElement("hive-side-panel");
document.body.append(sidePanel);
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages (it's the 1
// KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
async function fetchStateFile(path) {
const resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || "http " + resp.status);
return text;
}
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
// `renderRendered()` produces the rendered-tab node fresh on each
// switch; `plainText` backs the raw tab; `plainLabel` names it.
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
const tabs = el("div", { class: "diff-base-tabs" });
const host = el("div", { class: "preview-host" });
function show(mode) {
for (const b of tabs.children) {
b.classList.toggle("active", b.dataset.mode === mode);
}
host.replaceChildren(
mode === "plain"
? el("pre", { class: "path-preview-body" }, plainText)
: renderRendered(),
);
}
for (const [mode, label] of [
["rendered", "rendered"],
["plain", plainLabel],
]) {
const b = el(
"button",
{ type: "button", class: "diff-base-tab", "data-mode": mode },
label,
);
b.addEventListener("click", () => show(mode));
tabs.append(b);
}
show("rendered");
return el("div", {}, tabs, host);
}
// Rendered <img> for an SVG, loaded via an <img> data: URI —
// <img>-loaded SVG runs in the browser's secure static mode (no
// scripts, no external fetches), so an untrusted SVG from an
// agent's state dir can't execute code in the dashboard.
function svgImage(text) {
const img = el("img", { class: "img-preview", alt: "SVG preview" });
img.addEventListener("error", () => {
img.replaceWith(
el("div", { class: "meta" }, "(could not render — see the source tab)"),
);
});
img.src = "data:image/svg+xml," + encodeURIComponent(text);
return img;
}
// Marked-rendered markdown node (raw text fallback if `marked`
// failed to load). `text` is untrusted (agent-authored state files served
// verbatim by /api/state-file) — the parsed HTML is run through DOMPurify
// before it touches innerHTML, since markdown can carry raw HTML/script
// tags that `marked` itself no longer strips (v5+ dropped the built-in
// sanitizer).
function mdNode(text) {
const div = el("div", { class: "md" });
if (window.marked && typeof window.marked.parse === "function") {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard.
div.querySelectorAll("a[href]").forEach((a) => {
a.target = "_blank";
a.rel = "noopener noreferrer";
});
} else {
div.textContent = text;
}
return div;
}
// Raster image extensions the preview renders as an <img> pointed
// straight at /api/state-file (served binary with a real
// content-type). SVG is handled on the text path instead.
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// Lazy-load `path` from /api/state-file into the side panel.
// Markdown + SVG get a rendered/plain tabbed view; raster images
// render as an <img>; every other file stays raw text in a <pre>.
async function openFilePanel(path) {
if (RASTER_RE.test(path)) {
const img = el("img", { class: "img-preview", alt: path });
img.addEventListener("error", () => {
img.replaceWith(
el(
"pre",
{ class: "path-preview-body" },
"(could not load image — it may be missing or over the preview size cap)",
),
);
});
img.src = "/api/state-file?path=" + encodeURIComponent(path);
sidePanel.open("↳ " + path, img);
return;
}
const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path);
const view = el("div");
view.textContent = "(fetching…)";
sidePanel.open("↳ " + path, view);
try {
const text = await fetchStateFile(path);
if (isSvg) {
view.replaceChildren(
buildTabbedPreview(() => svgImage(text), text, "source"),
);
} else if (isMd) {
view.replaceChildren(
buildTabbedPreview(() => mdNode(text), text, "plain"),
);
} else {
view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
}
} catch (e) {
view.textContent = "error: " + (e.message || e);
}
}
export function makePathLink(path) {
const anchor = el(
"a",
{
href: "#",
class: "path-link",
title: "open " + path + " in panel",
},
path,
);
anchor.addEventListener("click", (e) => {
e.preventDefault();
openFilePanel(path);
});
return anchor;
}
// Append a plain-text run, with bare http(s) URLs turned into clickable
// links via the shared terminal linkifier.
export function appendText(parent, s) {
if (!s) return;
parent.appendChild(termLinkify(s));
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
export function appendLinkified(parent, text, refs) {
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
appendText(parent, str);
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
// shorter token that prefixes it. O(text * refs) worst case;
// refs is bounded server-side to whatever fits in a body, so
// this stays cheap.
tokens.sort((a, b) => b.length - a.length);
let i = 0;
while (i < str.length) {
let bestStart = -1;
let bestToken = null;
for (const t of tokens) {
const idx = str.indexOf(t, i);
if (idx === -1) continue;
if (
bestStart === -1 ||
idx < bestStart ||
(idx === bestStart && t.length > bestToken.length)
) {
bestStart = idx;
bestToken = t;
}
}
if (bestStart === -1) {
appendText(parent, str.slice(i));
break;
}
if (bestStart > i) {
appendText(parent, str.slice(i, bestStart));
}
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
}
// ─── sticky top region ─────────────────────────────────────────────── // ─── sticky top region ───────────────────────────────────────────────
// One sticky container wrapping the page's chrome (tab bar / page // One sticky container wrapping the page's chrome (tab bar / page
// header), built once by wrapping the existing chrome element. A page // header), built once by wrapping the existing chrome element. A page

View file

@ -240,12 +240,6 @@
<p class="meta" id="hive-rev" hidden></p> <p class="meta" id="hive-rev" hidden></p>
</footer> </footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into <!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
@ -272,8 +266,7 @@
<!-- Single bundled entry — tabs.js is the dashboard tabs surface; <!-- Single bundled entry — tabs.js is the dashboard tabs surface;
esbuild folds esbuild folds
@hive/shared/terminal.js and the marked npm package into @hive/shared/terminal.js into tabs.js. -->
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script> <script type="module" src="/static/tabs.js" defer></script>
</body> </body>
</html> </html>

View file

@ -8,7 +8,6 @@
// live in `./common.js`. // live in `./common.js`.
// 4G3NTS (containers) domain lives in `./agents.js`. // 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked";
import { $, openStream, initStickyTop } from "./common.js"; import { $, openStream, initStickyTop } from "./common.js";
import { el } from "@hive/shared/dom.js"; import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js"; import { bindAsyncForms } from "@hive/shared/forms.js";
@ -42,11 +41,6 @@ import {
} from "./kept.js"; } from "./kept.js";
import { renderLinksMenu } from "./links-menu.js"; 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.
window.marked = marked;
(() => { (() => {
initStickyTop(); initStickyTop();