frontend: drop the Panel forwarding object and the side-panel-body compat class

Per mara's review: '2 and maybe 1, but 3 also sounds reasonable on first
glance' (against 3 options I posted). Doing 2 and 1, leaving open()/
openNamed() as-is (option 3, tentative only).

Both dashboard/common.js and agent/app.js now export/use the
<hive-side-panel> element instance directly (sidePanel) instead of a
thin Panel = { open, openNamed, refresh, close } object that existed
purely to keep the old call-site shape unchanged. All 6 real call sites
updated to call the element's own methods directly.

The .side-panel-body class each wrapper stamped onto its own instance,
purely so common.css/agent.css's pre-existing content-styling selectors
kept matching, is gone too -- those selectors now use the element's own
tag name as the root (hive-side-panel .md, hive-side-panel .agent-inbox),
which already uniquely identifies the light-DOM instance without a
compatibility class. Verified via headless Chromium/CDP that the
tag-name selectors resolve correctly with no class needed.

Drive-by: removed an unrelated dead Panel import in call.js.
This commit is contained in:
iris 2026-08-01 00:46:02 +02:00 committed by mara
commit 399a837e17
8 changed files with 73 additions and 90 deletions

View file

@ -901,10 +901,10 @@ pre.diff {
element's own shadow tree) and the styling for the panel's own element's own shadow tree) and the styling for the panel's own
content types, which the shared element's shadow tree can never content types, which the shared element's shadow tree can never
reach (see @hive/shared/side-panel/hive-side-panel.css's header reach (see @hive/shared/side-panel/hive-side-panel.css's header
comment). `.side-panel-body` below is the compatibility class comment). The `hive-side-panel .agent-inbox ` rules below use the
app.js's `Panel` wrapper puts directly on its `<hive-side-panel>` element's own tag name as the selector root no compatibility class
instance so these rules keep reaching the slotted content by needed, the tag name already uniquely identifies the light-DOM
ordinary light-DOM descendant matching. */ instance app.js's `sidePanel` creates. */
body.side-panel-resizing { body.side-panel-resizing {
user-select: none; user-select: none;
} }
@ -913,12 +913,12 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
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. */
.side-panel-body .agent-inbox { hive-side-panel .agent-inbox {
margin: 0; margin: 0;
font-size: inherit; font-size: inherit;
color: var(--fg); color: var(--fg);
} }
.side-panel-body .agent-inbox ul { hive-side-panel .agent-inbox ul {
background: transparent; background: transparent;
border-left: 0; border-left: 0;
padding: 0; padding: 0;

View file

@ -38,26 +38,17 @@ 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) ──────
// Thin wrapper around the shared `<hive-side-panel>` element (see // The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
// @hive/shared/side-panel.js) — created once, eagerly, when this // for the chrome/behavior it owns), created once, eagerly, when this
// module's IIFE runs (ES modules execute after the document is // module's IIFE runs (ES modules execute after the document is
// parsed, so `document.body` already exists; no per-call lazy-init // parsed, so `document.body` already exists). Used directly — this
// guard to remember for any method, present or future). This UI's // UI's opens always take an owner name, so call sites use the
// `open(name, title, content)` always takes an owner name, so it maps // element's own `openNamed(name, title, content)` rather than the
// straight onto the shared element's `openNamed`; `side-panel-body` // untyped `open`. `agent.css` reaches the slotted content via a plain
// is a plain compatibility class this wrapper puts on its own // `hive-side-panel .agent-inbox …` tag-name selector (no compat class
// instance so `agent.css`'s existing `.side-panel-body .agent-inbox // needed — the element's own tag name already identifies it).
// …` rules keep reaching the slotted content by ordinary light-DOM const sidePanel = document.createElement('hive-side-panel');
// descendant matching. document.body.append(sidePanel);
const panelEl = document.createElement('hive-side-panel');
panelEl.classList.add('side-panel-body');
document.body.append(panelEl);
const Panel = {
open: (name, title, content) => panelEl.openNamed(name, title, content),
close: () => panelEl.close(),
refresh: (name, title, content) => panelEl.refresh(name, title, content),
currentOwner: () => panelEl.currentOwner(),
};
// 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
@ -67,14 +58,14 @@ window.marked = marked;
const inboxPill = $('inbox-pill'); const inboxPill = $('inbox-pill');
if (inboxPill) { if (inboxPill) {
inboxPill.addEventListener('click', () => { inboxPill.addEventListener('click', () => {
Panel.open('inbox', 'inbox · ' + lastInbox.length, sidePanel.openNamed('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', () => {
Panel.open('todos', 'todos · ' + lastTodos.length, sidePanel.openNamed('todos', 'todos · ' + lastTodos.length,
buildTodosList(lastTodos)); buildTodosList(lastTodos));
}); });
} }
@ -336,7 +327,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 (Panel). // dismissal pattern matches the side-panel flyout (sidePanel).
(function bindOverflowMenu() { (function bindOverflowMenu() {
const btn = $('overflow-btn'); const btn = $('overflow-btn');
if (!btn) return; if (!btn) return;
@ -807,7 +798,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 `.side-panel-body .agent-inbox`. // apply via `hive-side-panel .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' },
@ -907,7 +898,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;
Panel.refresh('todos', 'todos · ' + todos.length, sidePanel.refresh('todos', 'todos · ' + todos.length,
buildTodosList(todos)); buildTodosList(todos));
} }
@ -1073,7 +1064,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;
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 // 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

@ -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, Panel, appendLinkified } from './common.js'; import { $, form, 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

@ -376,49 +376,49 @@ code {
not just the shared element's own shadow tree) and the styling for 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 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 can never reach (see @hive/shared/side-panel/hive-side-panel.css's
header comment). `.side-panel-body` below is the compatibility class header comment). The `hive-side-panel .md ` rules below use the
common.js's `Panel` wrapper puts directly on its `<hive-side-panel>` element's own tag name as the selector root no compatibility class
instance so these rules keep reaching the slotted content by ordinary needed, the tag name already uniquely identifies the light-DOM
light-DOM descendant matching. */ instance `common.js`'s `sidePanel` creates. */
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; }
.side-panel-body .md { color: var(--fg); line-height: 1.5; } hive-side-panel .md { color: var(--fg); line-height: 1.5; }
.side-panel-body .md > :first-child { margin-top: 0; } hive-side-panel .md > :first-child { margin-top: 0; }
.side-panel-body .md > :last-child { margin-bottom: 0; } hive-side-panel .md > :last-child { margin-bottom: 0; }
.side-panel-body .md p { margin: 0.5em 0; } hive-side-panel .md p { margin: 0.5em 0; }
.side-panel-body .md h1, hive-side-panel .md h1,
.side-panel-body .md h2, hive-side-panel .md h2,
.side-panel-body .md h3, hive-side-panel .md h3,
.side-panel-body .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; } hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
.side-panel-body .md code { hive-side-panel .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;
} }
.side-panel-body .md pre { hive-side-panel .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;
} }
.side-panel-body .md pre code { background: none; border: none; padding: 0; } hive-side-panel .md pre code { background: none; border: none; padding: 0; }
.side-panel-body .md a { color: var(--cyan); } hive-side-panel .md a { color: var(--cyan); }
.side-panel-body .md ul, hive-side-panel .md ul,
.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; } hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
.side-panel-body .md blockquote { hive-side-panel .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);
} }
.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; } hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
.side-panel-body .md th, hive-side-panel .md th,
.side-panel-body .md td { hive-side-panel .md td {
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
} }

View file

@ -281,30 +281,21 @@ 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 `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 // 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.
// //
// Thin wrapper around the shared `<hive-side-panel>` element (see // The instance is created once, eagerly, at module evaluation time — ES
// @hive/shared/side-panel.js for the chrome/behavior it owns, including // modules run after the document is parsed (same timing `defer` scripts
// drag-to-resize). The instance is created once, eagerly, at module // get), so `document.body` already exists here. Exported directly (not
// evaluation time — ES modules run after the document is parsed (same // wrapped in a forwarding object) — callers use the element's own
// timing `defer` scripts get), so `document.body` already exists here; // `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
// no per-call lazy-init guard needed, and no easy-to-forget step for // @hive/shared/side-panel.js for what they do. `common.css` reaches the
// any new method added later. `side-panel-body` is a plain compatibility // slotted content via a plain `hive-side-panel .md …` tag-name selector
// class this wrapper puts on its own instance so `common.css`'s // (no compatibility class needed — the element's own tag name already
// existing `.side-panel-body .md …` rules keep reaching the slotted // uniquely identifies it in the light DOM).
// content by ordinary light-DOM descendant matching — the shared export const sidePanel = document.createElement('hive-side-panel');
// element itself has no idea that class name means anything. document.body.append(sidePanel);
const panelEl = document.createElement('hive-side-panel');
panelEl.classList.add('side-panel-body');
document.body.append(panelEl);
export const Panel = {
open: (title, content) => panelEl.open(title, content),
openNamed: (name, title, content) => panelEl.openNamed(name, title, content),
refresh: (name, title, content) => panelEl.refresh(name, title, content),
close: () => panelEl.close(),
};
// ─── path linkification ───────────────────────────────────────────────── // ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question // Agents constantly drop pointer strings into messages + question
@ -395,14 +386,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);
Panel.open('↳ ' + path, img); sidePanel.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…)';
Panel.open('↳ ' + path, view); sidePanel.open('↳ ' + path, view);
try { try {
const text = await fetchStateFile(path); const text = await fetchStateFile(path);
if (isSvg) { if (isSvg) {

View file

@ -276,8 +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. Panel // mute flag the settings page sets, so no bind() is needed here. The
// (backed by <hive-side-panel>) wires its own internals in // side panel (<hive-side-panel>) wires its own internals in
// connectedCallback — no bind() step needed either. // connectedCallback — no bind() step needed either.
// ─── live updates: dashboard event stream ────────────────────────────── // ─── live updates: dashboard event stream ──────────────────────────────

View file

@ -9,11 +9,11 @@
it is slotted, so this file needs zero `::slotted()`. The one slotted it is slotted, so this file needs zero `::slotted()`. The one slotted
node (the caller's opaque content, passed to open()/openNamed()/ node (the caller's opaque content, passed to open()/openNamed()/
refresh()) is intentionally un-styled from in here: each package's own refresh()) is intentionally un-styled from in here: each package's own
content-type-specific rules (`.side-panel-body .md` in the dashboard, content-type-specific rules (`hive-side-panel .md` in the dashboard,
`.side-panel-body .agent-inbox` in the agent UI) live in that package's `hive-side-panel .agent-inbox` in the agent UI) live in that package's
own global stylesheet and reach the slotted content via a light-DOM own global stylesheet and reach the slotted content via the element's
class its own wrapper puts directly on its `<hive-side-panel>` instance own tag name as the selector root no compatibility class needed,
same architecture floor as `<hive-menu>`'s item-row styling, not a same architecture floor as `<hive-menu>`'s item-row styling, not a
scope choice. scope choice.
`:host(.open)`/`:host(.resizing)` respond to the two state classes the `:host(.open)`/`:host(.resizing)` respond to the two state classes the

View file

@ -1,10 +1,10 @@
// hive-side-panel.js — <hive-side-panel>, the generic slide-in drawer // 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 // 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 // side panel (file previews, diffs, logs, inbox/todo lists — anything too
// long to expand inline). One singleton instance per page, created + // long to expand inline). One singleton instance per page, created once,
// appended lazily by each package's own thin wrapper // eagerly, by each package's own module (dashboard/src/common.js's
// (dashboard/src/common.js's `Panel`, agent/src/app.js's `Panel`) — same // `sidePanel`, agent/src/app.js's `sidePanel`) and used directly — no
// lazy-creation pattern `themedToast` uses for its toast-stack container. // wrapper object, no per-call lazy-init guard.
// //
// Builds all structural chrome itself in `connectedCallback` (backdrop, // Builds all structural chrome itself in `connectedCallback` (backdrop,
// drawer, resize handle, header, title, close button) — shadow-owned, // drawer, resize handle, header, title, close button) — shadow-owned,
@ -12,8 +12,9 @@
// caller-built DOM, appended as a light-DOM child and picked up by the // 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 // shadow tree's single default `<slot>`, exactly like `<hive-menu>`'s
// `content` (see that file's header for why). Each package's own global // `content` (see that file's header for why). Each package's own global
// stylesheet reaches that content the same way it always did, via a // stylesheet reaches that content via the element's own tag name as the
// light-DOM class its own wrapper puts on the instance it owns. // selector root (`hive-side-panel .md`, `hive-side-panel .agent-inbox`)
// — no compatibility class needed.
// //
// Public API is instance methods: `open(title, content)`, // Public API is instance methods: `open(title, content)`,
// `openNamed(name, title, content)`, `refresh(name, title, content)`, // `openNamed(name, title, content)`, `refresh(name, title, content)`,