Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/side-panel/hive-side-panel.js
iris 77296aff35 docs: retire the stray top-level web-ui.md, fold it into web-ui/README.md
docs/web-ui.md duplicated the web-ui/ directory name at the top level --
the only such collision in docs/ (every other subsystem has just a
directory, no sibling <dir>.md file). That's exactly why it rendered
outside the directory structure in the docs site nav (mara's report,
hyperhive#4054): the site build walks docs/ generically with no
special-casing, so a loose top-level file next to a same-named
directory shows up as its own flat top-level entry instead of nesting
under that directory's section.

web-ui.md's own first paragraph already said as much -- 'This doc has
been split for readability... start at web-ui/README.md instead.' It
was a leftover pointer from before the split, not a page carrying
unique content on its own merit.

Folded its two sections web-ui/README.md didn't already have (the
swarm-ui design-guide link, and the task-oriented 'reading paths'
quick-lookup list) into web-ui/README.md's existing 'More depth'
section, then deleted the stray file and repointed every real
reference at it: 3 in-tree doc cross-links, 3 doc prose mentions
(retargeted to the more specific dashboard.md/shape.md sub-page each
one was actually about), and ~28 frontend source comments
(dashboard/agent/shared packages) that cited it as
'docs/web-ui.md::<heading>' for implementation context -- retargeted
each to whichever of dashboard.md/shape.md/agent.md actually carries
that heading now, verified against each file's real heading list
rather than guessed.

Verified via scripts/check-doc-refs.sh (the same lint CI runs): 0 dead
pointers, both before write (confirming the tree was clean beforehand)
and after (confirming nothing broke).
2026-09-07 15:49:52 +02:00

218 lines
7.5 KiB
JavaScript

// hive-side-panel.js — <hive-side-panel>, the generic slide-in drawer
// shadow-DOM custom element behind the dashboard's and the per-agent UI's
// side panel (file previews, diffs, logs, inbox/todo lists — anything too
// long to expand inline). One singleton instance per page, created once,
// eagerly, by each package's own module (dashboard/src/common.js's
// `sidePanel`, agent/src/app.js's `sidePanel`) and used directly — no
// wrapper object, no per-call lazy-init guard.
//
// Builds all structural chrome itself in `connectedCallback` (backdrop,
// drawer, resize handle, header, title, close button) — shadow-owned,
// zero `::slotted()` needed. Only the panel *content* is opaque
// caller-built DOM, appended as a light-DOM child and picked up by the
// shadow tree's single default `<slot>`, exactly like `<hive-menu>`'s
// `content` (see that file's header for why). Each package's own global
// stylesheet reaches that content via the element's own tag name as the
// selector root (`hive-side-panel .md`, `hive-side-panel .agent-inbox`)
// — no compatibility class needed.
//
// Public API is instance methods: `open(title, content)`,
// `openNamed(name, title, content)`, `refresh(name, title, content)`,
// `close()`, `currentOwner()`. `open` is exactly `openNamed(null, title,
// content)` — untyped opens clear the owner, matching the dashboard's
// original semantics.
//
// Drag-to-resize + localStorage width persistence is ported from the
// dashboard's original `Panel` (the only one of the two originals that
// had it) — making it available to every consumer (the agent UI didn't
// have it before) is a deliberate behavior widening, not incidental.
import { el } from "../dom.js";
import { attachShadowCss } from "../shadow-css.js";
import sidePanelCss from "./hive-side-panel.css";
// See docs/web-ui/agent.md::Side panel for the hit-strip + pointer-capture +
// localStorage persistence model; CSS clamps the stored value to min
// 320px / max 96vw and out-of-range stored values are dropped silently.
// Shared by both packages deliberately — see module header.
const WIDTH_KEY = "hyperhive:side-panel-width";
const WIDTH_MIN = 320;
class HiveSidePanel extends HTMLElement {
connectedCallback() {
const root = attachShadowCss(this, sidePanelCss);
this.setAttribute("aria-hidden", "true");
this._owner = null;
this._titleEl = el("span", {
class: "side-panel-title",
id: "side-panel-title",
});
this._closeBtn = el(
"button",
{
type: "button",
class: "side-panel-close",
title: "close (esc)",
},
"✕",
);
const head = el(
"header",
{ class: "side-panel-head" },
this._titleEl,
this._closeBtn,
);
this._resizeHandle = el("div", {
class: "side-panel-resize",
role: "separator",
"aria-orientation": "vertical",
"aria-label": "drag to resize side panel",
title: "drag to resize",
});
this._bodyEl = el("div", { class: "side-panel-body" }, el("slot"));
this._drawer = el(
"aside",
{
class: "side-panel-drawer",
role: "dialog",
"aria-modal": "true",
"aria-labelledby": "side-panel-title",
},
this._resizeHandle,
head,
this._bodyEl,
);
this._backdrop = el("div", { class: "side-panel-backdrop" });
root.append(this._backdrop, this._drawer);
this._closeBtn.addEventListener("click", () => this.close());
this._backdrop.addEventListener("click", () => this.close());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.classList.contains("open")) this.close();
});
this._applyStoredWidth();
this._bindResize();
}
open(title, content) {
this.openNamed(null, title, content);
}
openNamed(name, title, content) {
this._owner = name;
this._titleEl.textContent = title;
this.replaceChildren(...(content ? [content] : []));
this.classList.add("open");
this.setAttribute("aria-hidden", "false");
}
refresh(name, title, content) {
if (this._owner !== name) return;
this._titleEl.textContent = title;
this.replaceChildren(...(content ? [content] : []));
}
close() {
this._owner = null;
this.classList.remove("open");
this.setAttribute("aria-hidden", "true");
}
currentOwner() {
return this._owner;
}
_clampWidth(w) {
// Use document.documentElement.clientWidth rather than window.innerWidth:
// clientWidth gives the actual CSS layout viewport width and is not
// altered by Firefox's Fingerprinting Protection (which rounds
// window.innerWidth/outerWidth to the nearest 200px).
const max = Math.floor(document.documentElement.clientWidth * 0.96);
return Math.max(WIDTH_MIN, Math.min(max, w));
}
_applyStoredWidth() {
const raw = (() => {
try {
return localStorage.getItem(WIDTH_KEY);
} catch {
return null;
}
})();
if (!raw) return;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return;
this._drawer.style.setProperty(
"--side-panel-w",
this._clampWidth(parsed) + "px",
);
}
_bindResize() {
const handle = this._resizeHandle;
const drawer = this._drawer;
let dragging = false;
handle.addEventListener("pointerdown", (e) => {
e.preventDefault();
dragging = true;
document.body.classList.add("side-panel-resizing");
// Shadow-scoped counterpart of the global `body.side-panel-resizing`
// class below — the resize handle's own hover/drag appearance lives
// inside this element's shadow tree, which the global class (a
// light-DOM-only escape hatch for the page-wide cursor override)
// can't reach.
this.classList.add("resizing");
// Capture so we keep getting pointermove even when the cursor
// outpaces the handle band (drag-fast-then-pause loses the
// handle's :hover state otherwise).
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* legacy */
}
});
document.addEventListener("pointermove", (e) => {
if (!dragging) return;
// Drawer is anchored to the right edge — width = viewport - pointer X.
const w = this._clampWidth(
document.documentElement.clientWidth - e.clientX,
);
drawer.style.setProperty("--side-panel-w", w + "px");
});
const stopDrag = () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove("side-panel-resizing");
this.classList.remove("resizing");
// Persist the final width. Read the actual rendered width
// rather than re-deriving so the stored value matches what
// the operator saw at mouseup.
const w = drawer.getBoundingClientRect().width;
try {
localStorage.setItem(WIDTH_KEY, String(Math.round(w)));
} catch {
/* localStorage unavailable — width is session-only */
}
};
document.addEventListener("pointerup", stopDrag);
document.addEventListener("pointercancel", stopDrag);
// Re-clamp on viewport resize so a persisted width that exceeds
// 96vw doesn't push the drawer off-screen after a window shrink.
window.addEventListener("resize", () => {
if (dragging) return;
const cur = drawer.getBoundingClientRect().width;
const clamped = this._clampWidth(cur);
if (clamped !== Math.round(cur)) {
drawer.style.setProperty("--side-panel-w", clamped + "px");
}
});
}
}
customElements.define("hive-side-panel", HiveSidePanel);