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