Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399a837e17 | ||
|
|
996899fcad | ||
|
|
c5610b075a |
11 changed files with 395 additions and 390 deletions
|
|
@ -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 `<hive-side-panel>` 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 <details>-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;
|
||||
|
|
|
|||
|
|
@ -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 <hive-side-panel> — 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 `<hive-side-panel>` 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 <div> the side panel renders. The structural shape
|
||||
// mirrors the legacy <details>-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
|
||||
|
|
|
|||
|
|
@ -93,21 +93,10 @@
|
|||
<div id="term-input" class="term-input"></div>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
||||
and toggles `.open`. Shared shape with the dashboard's panel
|
||||
(candidate for extraction into @hive/shared in a follow-up). -->
|
||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="side-panel-title">
|
||||
<header class="side-panel-head">
|
||||
<span class="side-panel-title" id="side-panel-title"></span>
|
||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||
title="close (esc)">✕</button>
|
||||
</header>
|
||||
<div class="side-panel-body" id="side-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- Slide-in side panel (inbox / todos flyouts) is a <hive-side-panel>
|
||||
element (@hive/shared/side-panel.js) — the Panel singleton in
|
||||
app.js creates + appends it to <body> lazily on first use, so
|
||||
nothing needs to be pre-declared here. -->
|
||||
|
||||
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||
the marked npm package into app.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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<hive-side-panel>` 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <hive-side-panel> — 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) {
|
||||
|
|
|
|||
|
|
@ -234,23 +234,11 @@
|
|||
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in detail panel. Long content (clicked file previews,
|
||||
approval diffs, journald logs, applied config) opens here
|
||||
instead of expanding inline. Singleton — JS swaps the title +
|
||||
body and toggles `.open`. Lives outside the tab panes so it
|
||||
overlays any active tab. -->
|
||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="side-panel-title">
|
||||
<header class="side-panel-head">
|
||||
<span class="side-panel-title" id="side-panel-title"></span>
|
||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||
title="close (esc)">✕</button>
|
||||
</header>
|
||||
<div class="side-panel-body" id="side-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- Slide-in detail panel (clicked file previews, approval diffs,
|
||||
journald logs, applied config) is a <hive-side-panel> element
|
||||
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
||||
creates + appends it to <body> lazily on first use, so nothing
|
||||
needs to be pre-declared here. -->
|
||||
|
||||
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||
view when one or more agent cards is selected (click the icon
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import { marked } from 'marked';
|
||||
import {
|
||||
$,
|
||||
Panel, NOTIF,
|
||||
NOTIF,
|
||||
openStream, renderServerWarnings,
|
||||
} from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
|
|
@ -276,8 +276,9 @@ window.marked = marked;
|
|||
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
|
||||
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
|
||||
// question notifications — it reads the browser permission + localStorage
|
||||
// mute flag the settings page sets, so no bind() is needed here.
|
||||
Panel.bind();
|
||||
// mute flag the settings page sets, so no bind() is needed here. The
|
||||
// side panel (<hive-side-panel>) wires its own internals in
|
||||
// connectedCallback — no bind() step needed either.
|
||||
|
||||
// ─── live updates: dashboard event stream ──────────────────────────────
|
||||
// The dashboard subscribes to /dashboard/stream for live mutation
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@
|
|||
"./dom.js": "./src/dom.js",
|
||||
"./modal.js": "./src/modal.js",
|
||||
"./shadow-css.js": "./src/shadow-css.js",
|
||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
|
||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
||||
"./side-panel.js": "./src/side-panel/hive-side-panel.js"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
|
|
|
|||
111
frontend/packages/shared/src/side-panel/hive-side-panel.css
Normal file
111
frontend/packages/shared/src/side-panel/hive-side-panel.css
Normal 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 (`hive-side-panel .md` in the dashboard,
|
||||
`hive-side-panel .agent-inbox` in the agent UI) live in that package's
|
||||
own global stylesheet and reach the slotted content via the element's
|
||||
own tag name as the selector root — no compatibility class needed,
|
||||
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;
|
||||
}
|
||||
183
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
183
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// 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 once,
|
||||
// eagerly, by each package's own module (dashboard/src/common.js's
|
||||
// `sidePanel`, agent/src/app.js's `sidePanel`) and used directly — no
|
||||
// wrapper object, no per-call lazy-init guard.
|
||||
//
|
||||
// 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 via the element's own tag name as the
|
||||
// selector root (`hive-side-panel .md`, `hive-side-panel .agent-inbox`)
|
||||
// — no compatibility class needed.
|
||||
//
|
||||
// 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);
|
||||
Loading…
Reference in a new issue