hyperhive/frontend/packages/shared/src/side-panel/hive-side-panel.js
iris 399a837e17 frontend: drop the Panel forwarding object and the side-panel-body compat class
Per mara's review: '2 and maybe 1, but 3 also sounds reasonable on first
glance' (against 3 options I posted). Doing 2 and 1, leaving open()/
openNamed() as-is (option 3, tentative only).

Both dashboard/common.js and agent/app.js now export/use the
<hive-side-panel> element instance directly (sidePanel) instead of a
thin Panel = { open, openNamed, refresh, close } object that existed
purely to keep the old call-site shape unchanged. All 6 real call sites
updated to call the element's own methods directly.

The .side-panel-body class each wrapper stamped onto its own instance,
purely so common.css/agent.css's pre-existing content-styling selectors
kept matching, is gone too -- those selectors now use the element's own
tag name as the root (hive-side-panel .md, hive-side-panel .agent-inbox),
which already uniquely identifies the light-DOM instance without a
compatibility class. Verified via headless Chromium/CDP that the
tag-name selectors resolve correctly with no class needed.

Drive-by: removed an unrelated dead Panel import in call.js.
2026-08-01 00:58:53 +02:00

183 lines
7.3 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.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);