From c5610b075aa14921696901a35f9351bb673232a5 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 1 Aug 2026 00:21:28 +0200 Subject: [PATCH] frontend: unify dashboard + agent side panel into shared hive-side-panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 shadow-DOM custom element in @hive/shared, following the same house pattern as : 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 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 's item-row styling. Each wrapper applies a plain 'side-panel-body' compatibility class to its own 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). --- frontend/packages/agent/src/agent.css | 83 ++------ frontend/packages/agent/src/app.js | 60 +++--- frontend/packages/agent/src/index.html | 19 +- frontend/packages/dashboard/src/common.css | 103 ++-------- frontend/packages/dashboard/src/common.js | 156 +++------------ .../packages/dashboard/src/dashboard.html | 22 +-- frontend/packages/dashboard/src/tabs.js | 7 +- frontend/packages/shared/package.json | 3 +- .../shared/src/side-panel/hive-side-panel.css | 111 +++++++++++ .../shared/src/side-panel/hive-side-panel.js | 182 ++++++++++++++++++ 10 files changed, 388 insertions(+), 358 deletions(-) create mode 100644 frontend/packages/shared/src/side-panel/hive-side-panel.css create mode 100644 frontend/packages/shared/src/side-panel/hive-side-panel.js diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 28519eb4..06d37a0a 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -891,73 +891,24 @@ pre.diff { /* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ /* ─── side panel (singleton drawer) ──────────────────────────────── - Inbox + loose-ends details open here instead of expanding inline. - Copy of the dashboard's side-panel pattern — candidate for - extraction into @hive/shared once both surfaces stabilize. */ -.side-panel { - position: fixed; - inset: 0; - z-index: 50; - /* Closed: ignore pointer events so the agent page underneath stays - interactive; `.open` flips it back on. */ - pointer-events: none; -} -.side-panel-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.55); - opacity: 0; - transition: opacity 0.2s ease; -} -.side-panel-drawer { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: min(640px, 92vw); - display: flex; - flex-direction: column; - background: var(--bg-elev); - border-left: 2px solid var(--purple); - box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45); - transform: translateX(100%); - transition: transform 0.25s ease; -} -.side-panel.open { pointer-events: auto; } -.side-panel.open .side-panel-backdrop { opacity: 1; } -.side-panel.open .side-panel-drawer { transform: translateX(0); } -.side-panel-head { - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1em; - padding: 0.7em 1em; - border-bottom: 1px solid var(--border); -} -.side-panel-title { - color: var(--purple); - font-weight: bold; - letter-spacing: 0.05em; - word-break: break-all; -} -.side-panel-close { - flex: 0 0 auto; - background: var(--bg); - color: var(--fg); - border: 1px solid var(--border); - font-family: inherit; - font-size: 1em; - line-height: 1; - padding: 0.25em 0.55em; - cursor: pointer; -} -.side-panel-close:hover { border-color: var(--red); color: var(--red); } -.side-panel-body { - flex: 1 1 auto; - overflow: auto; - padding: 0.8em 1em; + Inbox + loose-ends details open here instead of expanding inline. The + drawer chrome itself (backdrop, drawer, resize handle, header, title, + close button) is now the shared `` element + (@hive/shared/side-panel.js — this also picks up drag-to-resize, + which this page didn't have before); what's left here is the drag + cursor-override escape hatch (has to be a global, light-DOM rule — it + reaches every element on the page during a drag, not just the shared + element's own shadow tree) and the styling for the panel's own + content types, which the shared element's shadow tree can never + reach (see @hive/shared/side-panel/hive-side-panel.css's header + comment). `.side-panel-body` below is the compatibility class + app.js's `Panel` wrapper puts directly on its `` + instance so these rules keep reaching the slotted content by + ordinary light-DOM descendant matching. */ +body.side-panel-resizing { + user-select: none; } +body.side-panel-resizing * { cursor: ew-resize !important; } /* Inbox / loose-ends lists rendered into the side-panel body. The legacy
-collapsible variant of .agent-inbox is gone, so here we strip the inbox-only chrome (background, border-left) and diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index c650910e..3ae0125d 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -6,6 +6,7 @@ import { create as termCreate, linkify as termLinkify } from '@hive/shared/termi import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js'; import { themedConfirm } from '@hive/shared/modal.js'; import { el } from '@hive/shared/dom.js'; +import '@hive/shared/side-panel.js'; // registers — side-effect import import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -37,43 +38,30 @@ window.marked = marked; bindAsyncForms(() => refreshState()); // ─── side panel (singleton drawer for inbox + loose-ends flyouts) ────── - // Shared shape with the dashboard's panel. Candidate for extraction - // into @hive/shared once both surfaces stabilise. - const Panel = (() => { - const root = $('side-panel'); - const titleEl = $('side-panel-title'); - const bodyEl = $('side-panel-body'); - /** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check - * against this so a live event only re-renders the panel when - * the matching view is actually visible. null when closed. */ - let owner = null; - function open(name, title, content) { - owner = name; - titleEl.textContent = title; - bodyEl.replaceChildren(...(content ? [content] : [])); - root.classList.add('open'); - root.setAttribute('aria-hidden', 'false'); + // Thin wrapper around the shared `` element (see + // @hive/shared/side-panel.js) — created and appended to lazily + // on first use, same pattern `themedToast` uses for its toast-stack + // container. This UI's `open(name, title, content)` always takes an + // owner name, so it maps straight onto the shared element's + // `openNamed`; `side-panel-body` is a plain compatibility class this + // wrapper puts on its own instance so `agent.css`'s existing + // `.side-panel-body .agent-inbox …` rules keep reaching the slotted + // content by ordinary light-DOM descendant matching. + let panelEl = null; + function ensurePanel() { + if (!panelEl) { + panelEl = document.createElement('hive-side-panel'); + panelEl.classList.add('side-panel-body'); + document.body.append(panelEl); } - function close() { - owner = null; - root.classList.remove('open'); - root.setAttribute('aria-hidden', 'true'); - } - function refresh(name, title, content) { - if (owner !== name) return; - titleEl.textContent = title; - bodyEl.replaceChildren(...(content ? [content] : [])); - } - function bind() { - $('side-panel-close').addEventListener('click', close); - $('side-panel-backdrop').addEventListener('click', close); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && root.classList.contains('open')) close(); - }); - } - return { open, close, refresh, bind, currentOwner: () => owner }; - })(); - Panel.bind(); + return panelEl; + } + const Panel = { + open: (name, title, content) => ensurePanel().openNamed(name, title, content), + close: () => ensurePanel().close(), + refresh: (name, title, content) => ensurePanel().refresh(name, title, content), + currentOwner: () => ensurePanel().currentOwner(), + }; // Wire the header pills to open the side panel. Pre-built (vs // re-building per-click) so the freshest snapshot already lives diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index 0e1c1d68..86686717 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -93,21 +93,10 @@
- - + diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 8922df45..7d82770d 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -366,99 +366,24 @@ code { } /* ─── side panel ───────────────────────────────────────────────── - Long content (file previews, diffs, journald, applied config) - opens in a drawer that swipes in from the right — used by both - /flow.html and the dashboard. */ -.side-panel { - 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: 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; -} -.side-panel.open { pointer-events: auto; } -.side-panel.open .side-panel-backdrop { opacity: 1; } -.side-panel.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, -body.side-panel-resizing .side-panel-resize { - opacity: 1; -} -body.side-panel-resizing .side-panel-resize { - background: var(--purple); -} + Long content (file previews, diffs, journald, applied config) opens in + a drawer that swipes in from the right — used by both /flow.html and + the dashboard. The drawer chrome itself (backdrop, drawer, resize + handle, header, title, close button) is now the shared + `` element (@hive/shared/side-panel.js); what's left + here is the drag cursor-override escape hatch (has to be a global, + light-DOM rule — it reaches every element on the page during a drag, + not just the shared element's own shadow tree) and the styling for + the panel's own content types, which the shared element's shadow tree + can never reach (see @hive/shared/side-panel/hive-side-panel.css's + header comment). `.side-panel-body` below is the compatibility class + common.js's `Panel` wrapper puts directly on its `` + instance so these rules keep reaching the slotted content by ordinary + light-DOM descendant matching. */ body.side-panel-resizing { user-select: none; } body.side-panel-resizing * { cursor: ew-resize !important; } -.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; -} .side-panel-body .md { color: var(--fg); line-height: 1.5; } .side-panel-body .md > :first-child { margin-top: 0; } .side-panel-body .md > :last-child { margin-bottom: 0; } diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 44d6a56d..56b5e22d 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -5,6 +5,7 @@ import { linkify as termLinkify } from '@hive/shared/terminal.js'; import { el } from '@hive/shared/dom.js'; +import '@hive/shared/side-panel.js'; // registers — side-effect import import DOMPurify from 'dompurify'; // ─── helpers ──────────────────────────────────────────────────────────── @@ -283,138 +284,31 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) { // opens here via `Panel.open(title, node)` instead of expanding // inline. Body is swapped on each open; closing just slides out so // the content stays visible through the transition. -export const Panel = (() => { - let root = null; - let titleEl = null; - let bodyEl = null; - let drawer = null; - /** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)` - * is a no-op when the current owner doesn't match, so live - * updates can re-render an open view without grabbing focus - * from a closed one (or from an unrelated open view like a - * diff drill-in). Untyped calls via `open(title, content)` - * clear the owner — the legacy file-preview/diff/log paths - * don't participate in named-refresh semantics. */ - let owner = null; - function ensure() { - if (!root) { - root = $('side-panel'); - titleEl = $('side-panel-title'); - bodyEl = $('side-panel-body'); - drawer = root && root.querySelector('.side-panel-drawer'); - } - return root != null; +// +// Thin wrapper around the shared `` element (see +// @hive/shared/side-panel.js for the chrome/behavior it owns, including +// drag-to-resize) — the instance is created and appended to +// lazily on first use, same pattern `themedToast` uses for its +// toast-stack container. `side-panel-body` is a plain compatibility +// class this wrapper puts on its own instance so `common.css`'s +// existing `.side-panel-body .md …` rules keep reaching the slotted +// content by ordinary light-DOM descendant matching — the shared +// element itself has no idea that class name means anything. +let panelEl = null; +function ensurePanel() { + if (!panelEl) { + panelEl = document.createElement('hive-side-panel'); + panelEl.classList.add('side-panel-body'); + document.body.append(panelEl); } - function open(title, content) { - if (!ensure()) return; - owner = null; - titleEl.textContent = title; - bodyEl.replaceChildren(...(content ? [content] : [])); - root.classList.add('open'); - root.setAttribute('aria-hidden', 'false'); - } - function openNamed(name, title, content) { - open(title, content); - owner = name; - } - function refresh(name, title, content) { - if (!ensure()) return; - if (owner !== name) return; - titleEl.textContent = title; - bodyEl.replaceChildren(...(content ? [content] : [])); - } - function close() { - if (!ensure()) return; - owner = null; - root.classList.remove('open'); - root.setAttribute('aria-hidden', 'true'); - } - // Drag-to-resize the drawer's width. 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. - const WIDTH_KEY = 'hyperhive:side-panel-width'; - const WIDTH_MIN = 320; - function 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)); - } - function applyStoredWidth() { - if (!drawer) return; - 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; - drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px'); - } - function bindResize() { - if (!drawer) return; - const handle = document.createElement('div'); - handle.className = 'side-panel-resize'; - handle.setAttribute('role', 'separator'); - handle.setAttribute('aria-orientation', 'vertical'); - handle.setAttribute('aria-label', 'drag to resize side panel'); - handle.title = 'drag to resize'; - drawer.prepend(handle); - let dragging = false; - handle.addEventListener('pointerdown', (e) => { - e.preventDefault(); - dragging = true; - document.body.classList.add('side-panel-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 = clampWidth(document.documentElement.clientWidth - e.clientX); - drawer.style.setProperty('--side-panel-w', w + 'px'); - }); - function stopDrag() { - if (!dragging) return; - dragging = false; - document.body.classList.remove('side-panel-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 = clampWidth(cur); - if (clamped !== Math.round(cur)) { - drawer.style.setProperty('--side-panel-w', clamped + 'px'); - } - }); - } - function bind() { - if (!ensure()) return; - $('side-panel-close').addEventListener('click', close); - $('side-panel-backdrop').addEventListener('click', close); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && root.classList.contains('open')) close(); - }); - applyStoredWidth(); - bindResize(); - } - return { open, openNamed, refresh, close, bind }; -})(); + return panelEl; +} +export const Panel = { + open: (title, content) => ensurePanel().open(title, content), + openNamed: (name, title, content) => ensurePanel().openNamed(name, title, content), + refresh: (name, title, content) => ensurePanel().refresh(name, title, content), + close: () => ensurePanel().close(), +}; // ─── path linkification ───────────────────────────────────────────────── // Agents constantly drop pointer strings into messages + question diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index a15a94e3..e3f3007d 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -234,23 +234,11 @@

▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲

- - +