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 ▲△▲

- - +