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
1 changed files with 9 additions and 437 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue