diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 28519eb4..2b9d7007 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -891,83 +891,34 @@ 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). The `hive-side-panel .agent-inbox …` rules below use the + element's own tag name as the selector root — no compatibility class + needed, the tag name already uniquely identifies the light-DOM + instance app.js's `sidePanel` creates. */ +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 let the panel body's own padding own the framing. */ -.side-panel-body .agent-inbox { +hive-side-panel .agent-inbox { margin: 0; font-size: inherit; color: var(--fg); } -.side-panel-body .agent-inbox ul { +hive-side-panel .agent-inbox ul { background: transparent; border-left: 0; padding: 0; diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index c650910e..729e4b06 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,17 @@ 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'); - } - 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(); + // The shared `` element (see @hive/shared/side-panel.js + // for the chrome/behavior it owns), created once, eagerly, when this + // module's IIFE runs (ES modules execute after the document is + // parsed, so `document.body` already exists). Used directly — this + // UI's opens always take an owner name, so call sites use the + // element's own `openNamed(name, title, content)` rather than the + // untyped `open`. `agent.css` reaches the slotted content via a plain + // `hive-side-panel .agent-inbox …` tag-name selector (no compat class + // needed — the element's own tag name already identifies it). + const sidePanel = document.createElement('hive-side-panel'); + document.body.append(sidePanel); // Wire the header pills to open the side panel. Pre-built (vs // re-building per-click) so the freshest snapshot already lives @@ -83,14 +58,14 @@ window.marked = marked; const inboxPill = $('inbox-pill'); if (inboxPill) { inboxPill.addEventListener('click', () => { - Panel.open('inbox', 'inbox · ' + lastInbox.length, + sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length, buildInboxList(lastInbox)); }); } const todosPill = $('todos-pill'); if (todosPill) { todosPill.addEventListener('click', () => { - Panel.open('todos', 'todos · ' + lastTodos.length, + sidePanel.openNamed('todos', 'todos · ' + lastTodos.length, buildTodosList(lastTodos)); }); } @@ -352,7 +327,7 @@ window.marked = marked; else closeOverflowMenu(); } // Wire once on boot. The trigger itself + click-outside + Escape - // dismissal pattern matches the side-panel flyout (Panel). + // dismissal pattern matches the side-panel flyout (sidePanel). (function bindOverflowMenu() { const btn = $('overflow-btn'); if (!btn) return; @@ -823,7 +798,7 @@ window.marked = marked; function buildLooseEndsList(threads) { // Returns the
the side panel renders. The structural shape // mirrors the legacy
-collapsible block — same CSS rules - // apply via `.side-panel-body .agent-inbox`. + // apply via `hive-side-panel .agent-inbox`. const wrap = el('div', { class: 'agent-inbox' }); if (!threads.length) { wrap.append(el('p', { class: 'side-panel-empty' }, @@ -923,7 +898,7 @@ window.marked = marked; const count = $('todos-count'); if (count) count.textContent = todos.length; if (pill) pill.hidden = todos.length === 0; - Panel.refresh('todos', 'todos · ' + todos.length, + sidePanel.refresh('todos', 'todos · ' + todos.length, buildTodosList(todos)); } @@ -1089,7 +1064,7 @@ window.marked = marked; const count = $('inbox-count'); if (count) count.textContent = rows.length; if (pill) pill.hidden = rows.length === 0; - Panel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows)); + sidePanel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows)); } // Harness reachability badge: derived from the same `s.status` the // status block reads. Each status maps to a glyph + label + colour 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/call.js b/frontend/packages/dashboard/src/call.js index 9e8df3a6..34902847 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -14,7 +14,7 @@ // live-mutation paths call an injected `onCountsChanged` callback the entry // registers once via `initCall`. -import { $, form, Panel, appendLinkified } from './common.js'; +import { $, form, appendLinkified } from './common.js'; import { el } from '@hive/shared/dom.js'; import { themedToast } from '@hive/shared/modal.js'; import { epochSec, fmtAgo, fmtDuration } from './util.js'; diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 8922df45..d8746d73 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -366,134 +366,59 @@ 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). The `hive-side-panel .md …` rules below use the + element's own tag name as the selector root — no compatibility class + needed, the tag name already uniquely identifies the light-DOM + instance `common.js`'s `sidePanel` creates. */ 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; } -.side-panel-body .md p { margin: 0.5em 0; } -.side-panel-body .md h1, -.side-panel-body .md h2, -.side-panel-body .md h3, -.side-panel-body .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; } -.side-panel-body .md code { +hive-side-panel .md { color: var(--fg); line-height: 1.5; } +hive-side-panel .md > :first-child { margin-top: 0; } +hive-side-panel .md > :last-child { margin-bottom: 0; } +hive-side-panel .md p { margin: 0.5em 0; } +hive-side-panel .md h1, +hive-side-panel .md h2, +hive-side-panel .md h3, +hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; } +hive-side-panel .md code { background: var(--bg); border: 1px solid var(--border); border-radius: 2px; padding: 0.1em 0.3em; font-size: 0.9em; } -.side-panel-body .md pre { +hive-side-panel .md pre { background: var(--crust); border: 1px solid var(--border); padding: 0.5em 0.7em; overflow-x: auto; margin: 0.5em 0; } -.side-panel-body .md pre code { background: none; border: none; padding: 0; } -.side-panel-body .md a { color: var(--cyan); } -.side-panel-body .md ul, -.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; } -.side-panel-body .md blockquote { +hive-side-panel .md pre code { background: none; border: none; padding: 0; } +hive-side-panel .md a { color: var(--cyan); } +hive-side-panel .md ul, +hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; } +hive-side-panel .md blockquote { border-left: 3px solid var(--purple-dim); padding-left: 0.8em; margin: 0.4em 0; color: var(--muted); } -.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; } -.side-panel-body .md th, -.side-panel-body .md td { +hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; } +hive-side-panel .md th, +hive-side-panel .md td { border: 1px solid var(--border); padding: 0.2em 0.5em; } diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 44d6a56d..6d34c7a7 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 ──────────────────────────────────────────────────────────── @@ -280,141 +281,21 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) { // ─── side panel ───────────────────────────────────────────────────────── // Singleton drawer that swipes in from the right. Long content // (file previews, approval diffs, journald logs, applied config) -// opens here via `Panel.open(title, node)` instead of expanding +// opens here via `sidePanel.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; - } - 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 }; -})(); +// +// The instance is created once, eagerly, at module evaluation time — ES +// modules run after the document is parsed (same timing `defer` scripts +// get), so `document.body` already exists here. Exported directly (not +// wrapped in a forwarding object) — callers use the element's own +// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see +// @hive/shared/side-panel.js for what they do. `common.css` reaches the +// slotted content via a plain `hive-side-panel .md …` tag-name selector +// (no compatibility class needed — the element's own tag name already +// uniquely identifies it in the light DOM). +export const sidePanel = document.createElement('hive-side-panel'); +document.body.append(sidePanel); // ─── path linkification ───────────────────────────────────────────────── // Agents constantly drop pointer strings into messages + question @@ -505,14 +386,14 @@ async function openFilePanel(path) { '(could not load image — it may be missing or over the preview size cap)')); }); img.src = '/api/state-file?path=' + encodeURIComponent(path); - Panel.open('↳ ' + path, img); + sidePanel.open('↳ ' + path, img); return; } const isMd = /\.(md|markdown)$/i.test(path); const isSvg = /\.svg$/i.test(path); const view = el('div'); view.textContent = '(fetching…)'; - Panel.open('↳ ' + path, view); + sidePanel.open('↳ ' + path, view); try { const text = await fetchStateFile(path); if (isSvg) { 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 ▲△▲

- - +