Compare commits

..
11 changed files with 390 additions and 395 deletions

View file

@ -891,34 +891,83 @@ pre.diff {
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ /* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
/* side panel (singleton drawer) /* side panel (singleton drawer)
Inbox + loose-ends details open here instead of expanding inline. The Inbox + loose-ends details open here instead of expanding inline.
drawer chrome itself (backdrop, drawer, resize handle, header, title, Copy of the dashboard's side-panel pattern candidate for
close button) is now the shared `<hive-side-panel>` element extraction into @hive/shared once both surfaces stabilize. */
(@hive/shared/side-panel.js this also picks up drag-to-resize, .side-panel {
which this page didn't have before); what's left here is the drag position: fixed;
cursor-override escape hatch (has to be a global, light-DOM rule it inset: 0;
reaches every element on the page during a drag, not just the shared z-index: 50;
element's own shadow tree) and the styling for the panel's own /* Closed: ignore pointer events so the agent page underneath stays
content types, which the shared element's shadow tree can never interactive; `.open` flips it back on. */
reach (see @hive/shared/side-panel/hive-side-panel.css's header pointer-events: none;
comment). The `hive-side-panel .agent-inbox ` rules below use the }
element's own tag name as the selector root no compatibility class .side-panel-backdrop {
needed, the tag name already uniquely identifies the light-DOM position: absolute;
instance app.js's `sidePanel` creates. */ inset: 0;
body.side-panel-resizing { background: rgba(0, 0, 0, 0.55);
user-select: none; 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 /* Inbox / loose-ends lists rendered into the side-panel body. The
legacy <details>-collapsible variant of .agent-inbox is gone, so legacy <details>-collapsible variant of .agent-inbox is gone, so
here we strip the inbox-only chrome (background, border-left) and here we strip the inbox-only chrome (background, border-left) and
let the panel body's own padding own the framing. */ let the panel body's own padding own the framing. */
hive-side-panel .agent-inbox { .side-panel-body .agent-inbox {
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
color: var(--fg); color: var(--fg);
} }
hive-side-panel .agent-inbox ul { .side-panel-body .agent-inbox ul {
background: transparent; background: transparent;
border-left: 0; border-left: 0;
padding: 0; padding: 0;

View file

@ -6,7 +6,6 @@ import { create as termCreate, linkify as termLinkify } from '@hive/shared/termi
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js'; import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js'; import { themedConfirm } from '@hive/shared/modal.js';
import { el } from '@hive/shared/dom.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 { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@ -38,17 +37,43 @@ window.marked = marked;
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ────── // ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js // Shared shape with the dashboard's panel. Candidate for extraction
// for the chrome/behavior it owns), created once, eagerly, when this // into @hive/shared once both surfaces stabilise.
// module's IIFE runs (ES modules execute after the document is const Panel = (() => {
// parsed, so `document.body` already exists). Used directly — this const root = $('side-panel');
// UI's opens always take an owner name, so call sites use the const titleEl = $('side-panel-title');
// element's own `openNamed(name, title, content)` rather than the const bodyEl = $('side-panel-body');
// untyped `open`. `agent.css` reaches the slotted content via a plain /** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
// `hive-side-panel .agent-inbox …` tag-name selector (no compat class * against this so a live event only re-renders the panel when
// needed — the element's own tag name already identifies it). * the matching view is actually visible. null when closed. */
const sidePanel = document.createElement('hive-side-panel'); let owner = null;
document.body.append(sidePanel); 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 // Wire the header pills to open the side panel. Pre-built (vs
// re-building per-click) so the freshest snapshot already lives // re-building per-click) so the freshest snapshot already lives
@ -58,14 +83,14 @@ window.marked = marked;
const inboxPill = $('inbox-pill'); const inboxPill = $('inbox-pill');
if (inboxPill) { if (inboxPill) {
inboxPill.addEventListener('click', () => { inboxPill.addEventListener('click', () => {
sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length, Panel.open('inbox', 'inbox · ' + lastInbox.length,
buildInboxList(lastInbox)); buildInboxList(lastInbox));
}); });
} }
const todosPill = $('todos-pill'); const todosPill = $('todos-pill');
if (todosPill) { if (todosPill) {
todosPill.addEventListener('click', () => { todosPill.addEventListener('click', () => {
sidePanel.openNamed('todos', 'todos · ' + lastTodos.length, Panel.open('todos', 'todos · ' + lastTodos.length,
buildTodosList(lastTodos)); buildTodosList(lastTodos));
}); });
} }
@ -327,7 +352,7 @@ window.marked = marked;
else closeOverflowMenu(); else closeOverflowMenu();
} }
// Wire once on boot. The trigger itself + click-outside + Escape // 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() { (function bindOverflowMenu() {
const btn = $('overflow-btn'); const btn = $('overflow-btn');
if (!btn) return; if (!btn) return;
@ -798,7 +823,7 @@ window.marked = marked;
function buildLooseEndsList(threads) { function buildLooseEndsList(threads) {
// Returns the <div> the side panel renders. The structural shape // Returns the <div> the side panel renders. The structural shape
// mirrors the legacy <details>-collapsible block — same CSS rules // mirrors the legacy <details>-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' }); const wrap = el('div', { class: 'agent-inbox' });
if (!threads.length) { if (!threads.length) {
wrap.append(el('p', { class: 'side-panel-empty' }, wrap.append(el('p', { class: 'side-panel-empty' },
@ -898,7 +923,7 @@ window.marked = marked;
const count = $('todos-count'); const count = $('todos-count');
if (count) count.textContent = todos.length; if (count) count.textContent = todos.length;
if (pill) pill.hidden = todos.length === 0; if (pill) pill.hidden = todos.length === 0;
sidePanel.refresh('todos', 'todos · ' + todos.length, Panel.refresh('todos', 'todos · ' + todos.length,
buildTodosList(todos)); buildTodosList(todos));
} }
@ -1064,7 +1089,7 @@ window.marked = marked;
const count = $('inbox-count'); const count = $('inbox-count');
if (count) count.textContent = rows.length; if (count) count.textContent = rows.length;
if (pill) pill.hidden = rows.length === 0; 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 // Harness reachability badge: derived from the same `s.status` the
// status block reads. Each status maps to a glyph + label + colour // status block reads. Each status maps to a glyph + label + colour

View file

@ -93,10 +93,21 @@
<div id="term-input" class="term-input"></div> <div id="term-input" class="term-input"></div>
</footer> </footer>
<!-- Slide-in side panel (inbox / todos flyouts) is a <hive-side-panel> <!-- Slide-in side panel. Singleton — JS swaps the title + body
element (@hive/shared/side-panel.js) — the Panel singleton in and toggles `.open`. Shared shape with the dashboard's panel
app.js creates + appends it to <body> lazily on first use, so (candidate for extraction into @hive/shared in a follow-up). -->
nothing needs to be pre-declared here. --> <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>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and <!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js. --> the marked npm package into app.js. -->

View file

@ -14,7 +14,7 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry // live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`. // 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 { el } from '@hive/shared/dom.js';
import { themedToast } from '@hive/shared/modal.js'; import { themedToast } from '@hive/shared/modal.js';
import { epochSec, fmtAgo, fmtDuration } from './util.js'; import { epochSec, fmtAgo, fmtDuration } from './util.js';

View file

@ -366,59 +366,134 @@ code {
} }
/* side panel /* side panel
Long content (file previews, diffs, journald, applied config) opens in Long content (file previews, diffs, journald, applied config)
a drawer that swipes in from the right used by both /flow.html and opens in a drawer that swipes in from the right used by both
the dashboard. The drawer chrome itself (backdrop, drawer, resize /flow.html and the dashboard. */
handle, header, title, close button) is now the shared .side-panel {
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left position: fixed;
here is the drag cursor-override escape hatch (has to be a global, inset: 0;
light-DOM rule it reaches every element on the page during a drag, z-index: 200;
not just the shared element's own shadow tree) and the styling for pointer-events: none;
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 .side-panel-backdrop {
header comment). The `hive-side-panel .md ` rules below use the position: absolute;
element's own tag name as the selector root no compatibility class inset: 0;
needed, the tag name already uniquely identifies the light-DOM background: color-mix(in srgb, var(--crust) 55%, transparent);
instance `common.js`'s `sidePanel` creates. */ 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 { body.side-panel-resizing {
user-select: none; user-select: none;
} }
body.side-panel-resizing * { cursor: ew-resize !important; } body.side-panel-resizing * { cursor: ew-resize !important; }
hive-side-panel .md { color: var(--fg); line-height: 1.5; } .side-panel-head {
hive-side-panel .md > :first-child { margin-top: 0; } display: flex;
hive-side-panel .md > :last-child { margin-bottom: 0; } align-items: center;
hive-side-panel .md p { margin: 0.5em 0; } gap: 0.6em;
hive-side-panel .md h1, padding: 0.6em 1em;
hive-side-panel .md h2, border-bottom: 1px solid var(--border);
hive-side-panel .md h3, flex: none;
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; } }
hive-side-panel .md code { .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); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 2px; border-radius: 2px;
padding: 0.1em 0.3em; padding: 0.1em 0.3em;
font-size: 0.9em; font-size: 0.9em;
} }
hive-side-panel .md pre { .side-panel-body .md pre {
background: var(--crust); background: var(--crust);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.5em 0.7em; padding: 0.5em 0.7em;
overflow-x: auto; overflow-x: auto;
margin: 0.5em 0; margin: 0.5em 0;
} }
hive-side-panel .md pre code { background: none; border: none; padding: 0; } .side-panel-body .md pre code { background: none; border: none; padding: 0; }
hive-side-panel .md a { color: var(--cyan); } .side-panel-body .md a { color: var(--cyan); }
hive-side-panel .md ul, .side-panel-body .md ul,
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; } .side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; }
hive-side-panel .md blockquote { .side-panel-body .md blockquote {
border-left: 3px solid var(--purple-dim); border-left: 3px solid var(--purple-dim);
padding-left: 0.8em; padding-left: 0.8em;
margin: 0.4em 0; margin: 0.4em 0;
color: var(--muted); color: var(--muted);
} }
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; } .side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; }
hive-side-panel .md th, .side-panel-body .md th,
hive-side-panel .md td { .side-panel-body .md td {
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
} }

View file

@ -5,7 +5,6 @@
import { linkify as termLinkify } from '@hive/shared/terminal.js'; import { linkify as termLinkify } from '@hive/shared/terminal.js';
import { el } from '@hive/shared/dom.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'; import DOMPurify from 'dompurify';
// ─── helpers ──────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────
@ -281,21 +280,141 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
// ─── side panel ───────────────────────────────────────────────────────── // ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content // Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config) // (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 // inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition. // the content stays visible through the transition.
// export const Panel = (() => {
// The instance is created once, eagerly, at module evaluation time — ES let root = null;
// modules run after the document is parsed (same timing `defer` scripts let titleEl = null;
// get), so `document.body` already exists here. Exported directly (not let bodyEl = null;
// wrapped in a forwarding object) — callers use the element's own let drawer = null;
// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see /** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, )`
// @hive/shared/side-panel.js for what they do. `common.css` reaches the * is a no-op when the current owner doesn't match, so live
// slotted content via a plain `hive-side-panel .md …` tag-name selector * updates can re-render an open view without grabbing focus
// (no compatibility class needed — the element's own tag name already * from a closed one (or from an unrelated open view like a
// uniquely identifies it in the light DOM). * diff drill-in). Untyped calls via `open(title, content)`
export const sidePanel = document.createElement('hive-side-panel'); * clear the owner the legacy file-preview/diff/log paths
document.body.append(sidePanel); * 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 ───────────────────────────────────────────────── // ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question // 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)')); '(could not load image — it may be missing or over the preview size cap)'));
}); });
img.src = '/api/state-file?path=' + encodeURIComponent(path); img.src = '/api/state-file?path=' + encodeURIComponent(path);
sidePanel.open('↳ ' + path, img); Panel.open('↳ ' + path, img);
return; return;
} }
const isMd = /\.(md|markdown)$/i.test(path); const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path); const isSvg = /\.svg$/i.test(path);
const view = el('div'); const view = el('div');
view.textContent = '(fetching…)'; view.textContent = '(fetching…)';
sidePanel.open('↳ ' + path, view); Panel.open('↳ ' + path, view);
try { try {
const text = await fetchStateFile(path); const text = await fetchStateFile(path);
if (isSvg) { if (isSvg) {

View file

@ -234,11 +234,23 @@
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p> <p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer> </footer>
<!-- Slide-in detail panel (clicked file previews, approval diffs, <!-- Slide-in detail panel. Long content (clicked file previews,
journald logs, applied config) is a <hive-side-panel> element approval diffs, journald logs, applied config) opens here
(@hive/shared/side-panel.js) — the Panel singleton in common.js instead of expanding inline. Singleton — JS swaps the title +
creates + appends it to <body> lazily on first use, so nothing body and toggles `.open`. Lives outside the tab panes so it
needs to be pre-declared here. --> 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>
<!-- Selection action bar. Sticky-bottom strip that slides into <!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon view when one or more agent cards is selected (click the icon

View file

@ -14,7 +14,7 @@
import { marked } from 'marked'; import { marked } from 'marked';
import { import {
$, $,
NOTIF, Panel, NOTIF,
openStream, renderServerWarnings, openStream, renderServerWarnings,
} from './common.js'; } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
@ -276,9 +276,8 @@ window.marked = marked;
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html // NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/ // with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
// question notifications — it reads the browser permission + localStorage // question notifications — it reads the browser permission + localStorage
// mute flag the settings page sets, so no bind() is needed here. The // mute flag the settings page sets, so no bind() is needed here.
// side panel (<hive-side-panel>) wires its own internals in Panel.bind();
// connectedCallback — no bind() step needed either.
// ─── live updates: dashboard event stream ────────────────────────────── // ─── live updates: dashboard event stream ──────────────────────────────
// The dashboard subscribes to /dashboard/stream for live mutation // The dashboard subscribes to /dashboard/stream for live mutation

View file

@ -19,8 +19,7 @@
"./dom.js": "./src/dom.js", "./dom.js": "./src/dom.js",
"./modal.js": "./src/modal.js", "./modal.js": "./src/modal.js",
"./shadow-css.js": "./src/shadow-css.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": [ "files": [
"src/" "src/"

View file

@ -1,111 +0,0 @@
/* 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;
}

View file

@ -1,183 +0,0 @@
// 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);