From e7f977b99e58807d3f26945acc8ba7c4606c863d Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 23:06:53 +0200 Subject: [PATCH] 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. --- docs/web-ui/README.md | 2 +- docs/web-ui/dashboard.md | 15 +- docs/web-ui/shape.md | 33 +-- frontend/packages/dashboard/src/common.css | 156 -------------- frontend/packages/dashboard/src/common.js | 225 +-------------------- frontend/packages/dashboard/src/index.html | 9 +- frontend/packages/dashboard/src/tabs.js | 6 - 7 files changed, 9 insertions(+), 437 deletions(-) diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index d3c2a99d..5cf78189 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -84,7 +84,7 @@ JS renders — no full-page reloads. side panel, slash commands, and per-agent endpoints. - **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE 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 properties declared once in `base.css` and the rule that per-page stylesheets reference (never redeclare) them. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index f525f82b..4e712c54 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -564,19 +564,14 @@ below — some endpoints aren't in it yet. and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses `/agents//claude` / `config` subtrees, truncates - bodies at 1 MiB. Selection-time backing for the inline path-link - preview. + bodies at 1 MiB. No dashboard page calls it since the flow page + stopped existing. - - The broker forwarder detects which tokens *are* path links - **server-side at broker-message ingest**, not client-side: - it calls `scan_validated_paths(body)` — + The broker forwarder still detects path tokens **server-side at + broker-message ingest**: it calls `scan_validated_paths(body)` — same allow-list helper the read endpoint uses — and attaches the verified file tokens to the event as `file_refs: Vec`. - The client trusts that list and linkifies only those tokens, - so directories, missing files, and forbidden subtrees never - become anchors. No probe endpoint, no client-side regex - heuristics. + No dashboard page reads that list either. - `GET /api/reminders` — list pending reminders for the dashboard's queued-reminders panel. - `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 1e3facd3..a79560c5 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -243,9 +243,7 @@ values rather than per-row caches. **`
` open-state preservation:** any collapsible element tagged with `data-restore-key=""` survives the refresh. `snapshotOpenDetails()` walks managed sections before -render, `restoreOpenDetails()` re-applies after. Long-content -drill-ins (file previews, diffs) now open in the -**side panel** (see below) rather than expanding inline. The +render, `restoreOpenDetails()` re-applies after. The dashboard's own answered-questions history list was the only `
` consumer and is gone — `snapshotOpenDetails()` / `restoreOpenDetails()` currently have @@ -254,35 +252,6 @@ that needs it rather than ripped out along with their one caller. -**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 `` `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 `` 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 `
`.
-
 ## Listener bind
 
 Both bind their TCP listener with `SO_REUSEADDR` via
diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css
index 34e31ce3..bc1d7c22 100644
--- a/frontend/packages/dashboard/src/common.css
+++ b/frontend/packages/dashboard/src/common.css
@@ -265,162 +265,6 @@ code {
   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
-   `` 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 ─────────────────────────────────────────────
    Inverted-gutter layout: the dashboard keeps `` full-bleed
    and wraps their padded content in a single `.page-content` div that
diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
index fc536038..b4b82031 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -1,12 +1,8 @@
 // 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.
+// the shared-worker SSE pipe, and the sticky top region.
 
-import { linkify as termLinkify } from "@hive/shared/terminal.js";
 import { el } from "@hive/shared/dom.js";
-import "@hive/shared/side-panel.js"; // registers  — side-effect import
 import "@hive/shared/hive-warn.js"; // registers  — side-effect import
-import DOMPurify from "dompurify";
 
 // ─── helpers ────────────────────────────────────────────────────────────
 export const $ = (id) => document.getElementById(id);
@@ -343,225 +339,6 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
   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 
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//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 for an SVG, loaded via an data: URI — -// -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 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 ; every other file stays raw text in a
.
-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 ───────────────────────────────────────────────
 // One sticky container wrapping the page's chrome (tab bar / page
 // header), built once by wrapping the existing chrome element. A page
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html
index 81a65f07..9587234d 100644
--- a/frontend/packages/dashboard/src/index.html
+++ b/frontend/packages/dashboard/src/index.html
@@ -240,12 +240,6 @@
       
     
 
-    
-
     
+       @hive/shared/terminal.js into tabs.js. -->
     
   
 
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 4c625619..07fd1310 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -8,7 +8,6 @@
 // live in `./common.js`.
 // 4G3NTS (containers) domain lives in `./agents.js`.
 
-import { marked } from "marked";
 import { $, openStream, initStickyTop } from "./common.js";
 import { el } from "@hive/shared/dom.js";
 import { bindAsyncForms } from "@hive/shared/forms.js";
@@ -42,11 +41,6 @@ import {
 } from "./kept.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();