// hive-side-panel.js — , 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 ``, exactly like ``'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);