Compare commits
3 changed files with 42 additions and 331 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue