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:
parent
8b415f91e3
commit
e7f977b99e
7 changed files with 9 additions and 437 deletions
|
|
@ -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
|
||||
`<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 ─────────────────────────────────────────────
|
||||
Inverted-gutter layout: the dashboard keeps `<body>` full-bleed
|
||||
and wraps their padded content in a single `.page-content` div that
|
||||
|
|
|
|||
|
|
@ -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 <hive-side-panel> — side-effect import
|
||||
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — 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 <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 ───────────────────────────────────────────────
|
||||
// One sticky container wrapping the page's chrome (tab bar / page
|
||||
// header), built once by wrapping the existing chrome element. A page
|
||||
|
|
|
|||
|
|
@ -240,12 +240,6 @@
|
|||
<p class="meta" id="hive-rev" hidden></p>
|
||||
</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
|
||||
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
|
||||
|
|
@ -272,8 +266,7 @@
|
|||
|
||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||
esbuild folds
|
||||
@hive/shared/terminal.js and the marked npm package into
|
||||
tabs.js. -->
|
||||
@hive/shared/terminal.js into tabs.js. -->
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue