diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 2b9d7007..28519eb4 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -891,34 +891,83 @@ 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. 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; + 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; } -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. */ -hive-side-panel .agent-inbox { +.side-panel-body .agent-inbox { margin: 0; font-size: inherit; color: var(--fg); } -hive-side-panel .agent-inbox ul { +.side-panel-body .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 729e4b06..c650910e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -6,7 +6,6 @@ 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'; @@ -38,17 +37,43 @@ window.marked = marked; bindAsyncForms(() => refreshState()); // ─── side panel (singleton drawer for inbox + loose-ends flyouts) ────── - // 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); + // 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(); // Wire the header pills to open the side panel. Pre-built (vs // re-building per-click) so the freshest snapshot already lives @@ -58,14 +83,14 @@ window.marked = marked; const inboxPill = $('inbox-pill'); if (inboxPill) { inboxPill.addEventListener('click', () => { - sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length, + Panel.open('inbox', 'inbox · ' + lastInbox.length, buildInboxList(lastInbox)); }); } const todosPill = $('todos-pill'); if (todosPill) { todosPill.addEventListener('click', () => { - sidePanel.openNamed('todos', 'todos · ' + lastTodos.length, + Panel.open('todos', 'todos · ' + lastTodos.length, buildTodosList(lastTodos)); }); } @@ -327,7 +352,7 @@ window.marked = marked; else closeOverflowMenu(); } // Wire once on boot. The trigger itself + click-outside + Escape - // dismissal pattern matches the side-panel flyout (sidePanel). + // dismissal pattern matches the side-panel flyout (Panel). (function bindOverflowMenu() { const btn = $('overflow-btn'); if (!btn) return; @@ -798,7 +823,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 `hive-side-panel .agent-inbox`. + // apply via `.side-panel-body .agent-inbox`. const wrap = el('div', { class: 'agent-inbox' }); if (!threads.length) { wrap.append(el('p', { class: 'side-panel-empty' }, @@ -898,7 +923,7 @@ window.marked = marked; const count = $('todos-count'); if (count) count.textContent = todos.length; if (pill) pill.hidden = todos.length === 0; - sidePanel.refresh('todos', 'todos · ' + todos.length, + Panel.refresh('todos', 'todos · ' + todos.length, buildTodosList(todos)); } @@ -1064,7 +1089,7 @@ window.marked = marked; const count = $('inbox-count'); if (count) count.textContent = rows.length; if (pill) pill.hidden = rows.length === 0; - sidePanel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows)); + Panel.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 86686717..0e1c1d68 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -93,10 +93,21 @@
- + + diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 34902847..9e8df3a6 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, appendLinkified } from './common.js'; +import { $, form, Panel, 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 d8746d73..8922df45 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -366,59 +366,134 @@ 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. 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. */ + 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); +} body.side-panel-resizing { user-select: none; } body.side-panel-resizing * { cursor: ew-resize !important; } -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 { +.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 { background: var(--bg); border: 1px solid var(--border); border-radius: 2px; padding: 0.1em 0.3em; font-size: 0.9em; } -hive-side-panel .md pre { +.side-panel-body .md pre { background: var(--crust); border: 1px solid var(--border); padding: 0.5em 0.7em; overflow-x: auto; margin: 0.5em 0; } -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 { +.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 { border-left: 3px solid var(--purple-dim); padding-left: 0.8em; margin: 0.4em 0; color: var(--muted); } -hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; } -hive-side-panel .md th, -hive-side-panel .md td { +.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; } +.side-panel-body .md th, +.side-panel-body .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 6d34c7a7..44d6a56d 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -5,7 +5,6 @@ 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 ──────────────────────────────────────────────────────────── @@ -281,21 +280,141 @@ 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 `sidePanel.open(title, node)` instead of expanding +// 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. -// -// 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); +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 }; +})(); // ─── path linkification ───────────────────────────────────────────────── // Agents constantly drop pointer strings into messages + question @@ -386,14 +505,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); - sidePanel.open('↳ ' + path, img); + Panel.open('↳ ' + path, img); return; } const isMd = /\.(md|markdown)$/i.test(path); const isSvg = /\.svg$/i.test(path); const view = el('div'); view.textContent = '(fetching…)'; - sidePanel.open('↳ ' + path, view); + Panel.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 e3f3007d..a15a94e3 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -234,11 +234,23 @@

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

- + +