Compare commits

..
3 changed files with 42 additions and 331 deletions

View file

@ -287,43 +287,6 @@ window.marked = marked;
if (s) renderContainers(s);
}
// ─── selection (#443) ───────────────────────────────────────────────
// Set of selected agent logical names. Toggled by clicking the
// container-row icon. When non-empty, the sticky #selection-bar
// becomes visible with the bulk actions. Per-card action buttons
// are gone — actions live in the bar.
const selectionState = new Set();
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer / journal-search isn't
// intercepted.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!selectionState.size) return;
const a = document.activeElement;
if (a && (a.isContentEditable
|| a.tagName === 'INPUT'
|| a.tagName === 'TEXTAREA'
|| a.tagName === 'SELECT')) return;
e.preventDefault();
clearSelection();
});
document.addEventListener('click', (e) => {
if (e.target && e.target.closest('#selection-clear')) {
clearSelection();
}
});
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
@ -482,13 +445,6 @@ window.marked = marked;
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
@ -518,12 +474,7 @@ window.marked = marked;
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: 'rebuild queued')));
const selected = selectionState.has(c.name);
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (selected ? ' selected' : ''),
});
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
// Topology: depth contributes left-padding; the glyph string in
// the .tree-prefix span draws the ├─ / └─ joint + continuation
// lines (`│ `) for ancestors whose subtree extends below this
@ -549,28 +500,7 @@ window.marked = marked;
// hyperhive mark (`/favicon.svg`, served by the dashboard
// itself, always reachable). (issues #195, #202)
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
// #443: icon is the selection toggle. Click → add/remove from
// `selectionState` → re-render. role=button + tabindex makes it
// keyboard-accessible; aria-pressed reflects the toggle state.
const icon = el('div', {
class: 'container-icon',
role: 'button',
tabindex: '0',
'aria-pressed': selected ? 'true' : 'false',
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
}, iconImg);
icon.addEventListener('click', (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelection(c.name);
}
});
const icon = el('div', { class: 'container-icon' }, iconImg);
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener('error', () => {
@ -713,15 +643,46 @@ window.marked = marked;
));
}
// Per-card action buttons used to live here (R3ST4RT / ST0P /
// ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont
// show all the restart buttons etc., just show state and links.
// instead, clicking an agent icon selects that agent." Actions
// moved into the sticky #selection-bar (see renderSelectionBar)
// which appears when the operator has at least one agent
// selected via the icon click. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button per se.
// ── action buttons ───────────────────────────────────────────
const actions = el('div', { class: 'actions' });
if (c.running) {
actions.append(
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?', {}, { noRefresh: true }),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
'stop ' + c.name + '?', {}, { noRefresh: true }),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
'start ' + c.name + '?', {}, { noRefresh: true }),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true }),
);
if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it
// Both event-covered now (ContainerRemoved +
// TombstonesChanged); no /api/state refetch needed.
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.',
{}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{ purge: 'on' }, { noRefresh: true }),
);
}
body.append(actions);
// ── drill-ins ────────────────────────────────────────────────
const drill = el('div', { class: 'drill-ins' });
@ -742,147 +703,6 @@ window.marked = marked;
ul.append(li);
}
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar (#443) ───────────────────────────────────────────
// Sticky-bottom strip; visible when ≥1 agent selected. mara picked
// option B: show every action button, disable the ones that don't
// apply to the full selection, hover tooltip explains why. Actions
// POST per agent in a loop (no new backend wire — endpoints already
// exist and are individually idempotent / event-covered).
function renderSelectionBar(containers) {
const bar = $('selection-bar');
if (!bar) return;
const countSpan = $('selection-count');
const namesSpan = $('selection-names');
const actions = $('selection-actions');
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
if (!selected.length) {
bar.hidden = true;
document.body.classList.remove('has-selection');
return;
}
bar.hidden = false;
document.body.classList.add('has-selection');
countSpan.textContent = selected.length === 1
? '1 agent selected'
: selected.length + ' agents selected';
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.innerHTML = '';
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
const noManagers = selected.every((c) => !c.is_manager);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
// #443 also lifts the manager-stop guard: when the whole selection
// is running, ST0P applies — manager included. host-side hive-c0re
// keeps serving the dashboard either way + per-agent approvals +
// meta-input updates still work without the manager up, so we
// don't special-case the confirm prompt when the manager is in
// the selection (mara: "dont special case manager for stopping").
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/kill/',
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/start/',
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
});
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
action: '/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
// DESTR0Y / PURG3: sub-agents only (manager has its own
// `refusing to destroy` guard at the host layer). When the
// selection includes the manager, both buttons go disabled with a
// clear reason rather than letting the operator submit and eat a
// 500.
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, {
action: '/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)),
});
addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, selected, {
action: '/destroy/',
body: { purge: 'on' },
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)),
});
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
const names = selected.map((c) => c.name);
const btn = el('button', {
type: 'button',
class: 'btn ' + btnClass,
}, label);
if (!enabled) {
btn.disabled = true;
if (opts.disabledTitle) btn.title = opts.disabledTitle;
}
btn.addEventListener('click', async () => {
if (btn.disabled) return;
const msg = opts.confirm(names);
if (msg && !confirm(msg)) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
const failures = [];
// Sequential POSTs to keep server-side serialisation predictable
// (rebuild_queue dedups but other endpoints don't); the loop is
// short — bulk selections are typically a handful of agents.
for (const name of names) {
const body = new URLSearchParams(opts.body || {});
try {
const resp = await fetch(opts.action + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
}
btn.disabled = false;
btn.innerHTML = original;
if (failures.length) {
alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
}
// Container-lifecycle events (ContainerStateChanged /
// ContainerRemoved / RebuildQueueChanged) flow over the existing
// SSE channel and update the derived stores live — no manual
// refresh needed.
});
parent.append(btn);
}
// Per-container journald viewer. Returns an inline trigger; the

View file

@ -298,28 +298,6 @@ a:hover {
aspect-ratio: 1;
border-radius: 6px;
background-color: rgba(17, 17, 27, 0.6);
/* #443 icon is the selection toggle. Cursor + hover ring make
that affordable without a chrome change. The :focus-visible ring
covers keyboard activation (Enter / Space). */
cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease;
}
.container-row:not(.tombstone) > .container-icon:hover {
box-shadow: 0 0 0 2px var(--purple);
}
.container-row:not(.tombstone) > .container-icon:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
/* Selected state mauve ring on the icon + a matching outline on the
whole row so a glance at the list shows what's in the active set. */
.container-row.selected {
border-color: var(--purple);
box-shadow: inset 0 0 0 1px var(--purple);
background: rgba(203, 166, 247, 0.06);
}
.container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
}
/* The icon image fills the square wrapper and is taken out of flow
(absolute) so its load state pending, loaded, broken can never
@ -828,19 +806,6 @@ ul form.inline { display: inline-block; }
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn:disabled,
.btn[disabled] {
opacity: 0.32;
cursor: not-allowed;
text-shadow: none;
box-shadow: none;
}
.btn:disabled:hover,
.btn[disabled]:hover {
background: transparent;
text-shadow: none;
box-shadow: none;
}
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
@ -1554,63 +1519,3 @@ body.flow-shell .tabbar .tab.active.tab-link {
wants `#inbox-section` in the DOM (legacy contract), but we
surface the messages via the pill/flyout instead. */
.flow-inbox-headless { display: none !important; }
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
actions when 1 agent is selected (click the icon). Visually
echoes the flow composer's frosted-mauve treatment so the chrome
reads as part of the same vibecore family. Hidden when empty
leaves the page footer's normal-flow position untouched. */
.selection-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
background: var(--flow-frost-bg);
-webkit-backdrop-filter: var(--flow-frost-blur);
backdrop-filter: var(--flow-frost-blur);
border-top: 1px solid var(--purple);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
}
.selection-bar[hidden] { display: none; }
.selection-count {
color: var(--purple);
font-weight: bold;
letter-spacing: 0.05em;
}
.selection-names {
color: var(--muted);
font-size: 0.85em;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selection-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4em;
align-items: center;
}
.selection-actions .btn {
font-size: 0.75em;
padding: 0.2em 0.7em;
}
.selection-clear {
color: var(--muted);
border-color: var(--purple-dim);
font-size: 0.75em;
padding: 0.2em 0.7em;
}
/* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. ~3.4em covers the bar's vertical
footprint with breathing room; only applies when the bar is
visible (`body.has-selection`, toggled by app.js when the
selection set is non-empty). */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }

View file

@ -159,20 +159,6 @@
</aside>
</div>
<!-- Selection action bar (#443). Sticky-bottom strip that slides
into view when one or more agent cards is selected (click the
icon to toggle). Shows the selection count + actions that
apply to ALL selected; disabled-with-tooltip for actions that
don't (mara picked option B). Hidden when selection is empty. -->
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
aria-label="bulk agent actions">
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button type="button" class="btn selection-clear" id="selection-clear"
title="clear selection (esc)">✕ clear</button>
</div>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js; load order is preserved by
the module bundler. -->