// 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.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);