frontend: unify dashboard + agent side panel into shared hive-side-panel

The dashboard's Panel singleton and the per-agent UI's own inline Panel
IIFE each had their own near-identical implementation of the right-side
slide-in drawer used for file previews, diffs, logs, and inbox/todo
lists. Both are now thin wrappers around a new <hive-side-panel>
shadow-DOM custom element in @hive/shared, following the same house
pattern as <hive-menu>: the element owns and builds all its structural
chrome itself (backdrop, drawer, resize handle, header, title, close
button) in connectedCallback, and only the caller's opaque content node
is projected in via a default <slot> so each package's own
content-type-specific CSS keeps reaching it.

Public API is the union of both originals: open(title, content),
openNamed(name, title, content), refresh(name, title, content),
close(), and currentOwner(). Drag-to-resize + localStorage width
persistence (ported verbatim from the dashboard's original
implementation, the only one of the two that had it) is now available
to both consumers by default — a deliberate behavior widening for the
agent UI, which didn't have resize before. Along the way, fixed a
latent bug in the ported CSS: the resize handle was setting a
--side-panel-w custom property that no width rule ever consumed, so
dragging never actually resized the drawer even though it looked wired
up; the new shared stylesheet's width rule reads it properly.

Each package's own global stylesheet keeps its content-specific rules
(common.css's .side-panel-body .md, agent.css's .side-panel-body
.agent-inbox) exactly where they were — those can never be reached from
the shared element's shadow tree, same architectural floor as
<hive-menu>'s item-row styling. Each wrapper applies a plain
'side-panel-body' compatibility class to its own <hive-side-panel>
instance so those existing selectors keep matching by ordinary
light-DOM descendant matching, with the shared element itself having no
knowledge of what that class name means.

Panel.bind() is gone from both packages' public API — the shared
element wires its own listeners in connectedCallback, so there's no
bind step left to call. tabs.js's one call site (the only bind() caller
in either package) was updated to drop it.

The two original chrome CSS blocks disagreed on several purely visual
details beyond the resize-handle rules (z-index, backdrop color, drawer
border/box-shadow, title typography) — the dashboard's values (the more
feature-complete of the two) were kept as canonical, which is a small
visible style change for the agent UI's panel chrome (thinner border,
no box-shadow, no bold purple title). Flagged for visibility since nothing
in the original two implementations called this out explicitly.

Verified with a real headless-Chromium/CDP harness (bundled the actual
component + built page CSS, served statically, drove via raw CDP) for
both usage shapes: open/close, backdrop-click dismiss, Escape dismiss,
refresh() owner-matching (no-op on wrong owner, applies on matching
owner), and drag-to-resize (drawer width updates live during drag and
persists to localStorage on release).
This commit is contained in:
iris 2026-08-01 00:21:28 +02:00 committed by mara
commit c5610b075a
10 changed files with 388 additions and 358 deletions

View file

@ -0,0 +1,111 @@
/* hive-side-panel.css scoped stylesheet for the generic <hive-side-panel>
shadow-DOM custom element (hive-side-panel.js). Loaded as raw text at
build time (esbuild's `text` loader) and appended as a <style> element
inside the shadow root see @hive/shared/shadow-css.js's header comment
for why a plain <style> tag and not adoptedStyleSheets.
Every rule here styles shadow-owned chrome this element builds itself
(backdrop, drawer, resize handle, header, title, close button) none of
it is slotted, so this file needs zero `::slotted()`. The one slotted
node (the caller's opaque content, passed to open()/openNamed()/
refresh()) is intentionally un-styled from in here: each package's own
content-type-specific rules (`.side-panel-body .md` in the dashboard,
`.side-panel-body .agent-inbox` in the agent UI) live in that package's
own global stylesheet and reach the slotted content via a light-DOM
class its own wrapper puts directly on its `<hive-side-panel>` instance
same architecture floor as `<hive-menu>`'s item-row styling, not a
scope choice.
`:host(.open)`/`:host(.resizing)` respond to the two state classes the
JS toggles on the host itself (`open`, and `resizing` during a drag)
the shadow-scoped counterparts of the page-global `body.side-panel-
resizing` cursor-override class, which stays in each package's own
stylesheet since it has to reach light-DOM elements outside this
element entirely (see hive-side-panel.js's module header). */
:host {
position: fixed;
inset: 0;
z-index: 200;
pointer-events: none;
}
.side-panel-backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--crust) 55%, transparent);
opacity: 0;
transition: opacity 200ms ease;
}
.side-panel-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--side-panel-w, min(42em, 95vw));
background: var(--bg-elev);
border-left: 1px solid var(--purple-dim);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 220ms ease;
overflow: hidden;
}
:host(.open) { pointer-events: auto; }
:host(.open) .side-panel-backdrop { opacity: 1; }
:host(.open) .side-panel-drawer { transform: translateX(0); }
.side-panel-resize {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
opacity: 0;
transition: opacity 120ms;
background: var(--purple-dim);
}
.side-panel-resize:hover,
:host(.resizing) .side-panel-resize {
opacity: 1;
}
:host(.resizing) .side-panel-resize {
background: var(--purple);
}
.side-panel-head {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.6em 1em;
border-bottom: 1px solid var(--border);
flex: none;
}
.side-panel-title {
flex: 1;
font-size: 0.88em;
color: var(--muted);
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-panel-close {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--muted);
font-size: 1em;
line-height: 1;
padding: 0.15em 0.4em;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
.side-panel-body {
flex: 1;
overflow-y: auto;
padding: 0.8em 1em;
font-size: 0.88em;
}

View file

@ -0,0 +1,182 @@
// 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 +
// appended lazily by each package's own thin wrapper
// (dashboard/src/common.js's `Panel`, agent/src/app.js's `Panel`) — same
// lazy-creation pattern `themedToast` uses for its toast-stack container.
//
// 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 the same way it always did, via a
// light-DOM class its own wrapper puts on the instance it owns.
//
// 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);