Compare commits
3 changed files with 42 additions and 331 deletions
|
|
@ -287,43 +287,6 @@ window.marked = marked;
|
||||||
if (s) renderContainers(s);
|
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
|
// Re-derive port conflicts from the live containers map. Mirrors the
|
||||||
// server-side `build_port_conflicts` so the banner reacts to event
|
// server-side `build_port_conflicts` so the banner reacts to event
|
||||||
// updates instead of waiting for a /api/state refetch.
|
// updates instead of waiting for a /api/state refetch.
|
||||||
|
|
@ -482,13 +445,6 @@ window.marked = marked;
|
||||||
return;
|
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 hostname = (s && s.hostname) || window.location.hostname;
|
||||||
const ul = el('ul', { class: 'containers' });
|
const ul = el('ul', { class: 'containers' });
|
||||||
const tree = buildAgentTree(containers);
|
const tree = buildAgentTree(containers);
|
||||||
|
|
@ -518,12 +474,7 @@ window.marked = marked;
|
||||||
: (op.kind === 'meta_update' ? 'meta-update queued'
|
: (op.kind === 'meta_update' ? 'meta-update queued'
|
||||||
: op.kind === 'destroy' ? 'destroy queued'
|
: op.kind === 'destroy' ? 'destroy queued'
|
||||||
: 'rebuild queued')));
|
: 'rebuild queued')));
|
||||||
const selected = selectionState.has(c.name);
|
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
|
||||||
const li = el('li', {
|
|
||||||
class: 'container-row'
|
|
||||||
+ (pending ? ' pending' : '')
|
|
||||||
+ (selected ? ' selected' : ''),
|
|
||||||
});
|
|
||||||
// Topology: depth contributes left-padding; the glyph string in
|
// Topology: depth contributes left-padding; the glyph string in
|
||||||
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
||||||
// lines (`│ `) for ancestors whose subtree extends below this
|
// lines (`│ `) for ancestors whose subtree extends below this
|
||||||
|
|
@ -549,28 +500,7 @@ window.marked = marked;
|
||||||
// hyperhive mark (`/favicon.svg`, served by the dashboard
|
// hyperhive mark (`/favicon.svg`, served by the dashboard
|
||||||
// itself, always reachable). (issues #195, #202)
|
// itself, always reachable). (issues #195, #202)
|
||||||
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
||||||
// #443: icon is the selection toggle. Click → add/remove from
|
const icon = el('div', { class: 'container-icon' }, iconImg);
|
||||||
// `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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (c.running) {
|
if (c.running) {
|
||||||
iconImg.src = `${url}icon`;
|
iconImg.src = `${url}icon`;
|
||||||
iconImg.addEventListener('error', () => {
|
iconImg.addEventListener('error', () => {
|
||||||
|
|
@ -713,15 +643,46 @@ window.marked = marked;
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-card action buttons used to live here (R3ST4RT / ST0P /
|
// ── action buttons ───────────────────────────────────────────
|
||||||
// ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont
|
const actions = el('div', { class: 'actions' });
|
||||||
// show all the restart buttons etc., just show state and links.
|
if (c.running) {
|
||||||
// instead, clicking an agent icon selects that agent." Actions
|
actions.append(
|
||||||
// moved into the sticky #selection-bar (see renderSelectionBar)
|
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
|
||||||
// which appears when the operator has at least one agent
|
'restart ' + c.name + '?', {}, { noRefresh: true }),
|
||||||
// selected via the icon click. The contextual `needs update ↻`
|
);
|
||||||
// chip in the head row stays — it's a state-hint, not an
|
if (!c.is_manager) {
|
||||||
// action button per se.
|
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 ────────────────────────────────────────────────
|
// ── drill-ins ────────────────────────────────────────────────
|
||||||
const drill = el('div', { class: 'drill-ins' });
|
const drill = el('div', { class: 'drill-ins' });
|
||||||
|
|
@ -742,147 +703,6 @@ window.marked = marked;
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
root.append(ul);
|
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
|
// Per-container journald viewer. Returns an inline trigger; the
|
||||||
|
|
|
||||||
|
|
@ -298,28 +298,6 @@ a:hover {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-color: rgba(17, 17, 27, 0.6);
|
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
|
/* The icon image fills the square wrapper and is taken out of flow
|
||||||
(absolute) so its load state — pending, loaded, broken — can never
|
(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;
|
text-shadow: 0 0 10px currentColor;
|
||||||
box-shadow: 0 0 10px -2px 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-approve { color: var(--green); border-color: var(--green); }
|
||||||
.btn-deny { color: var(--red); border-color: var(--red); }
|
.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; }
|
.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
|
wants `#inbox-section` in the DOM (legacy contract), but we
|
||||||
surface the messages via the pill/flyout instead. */
|
surface the messages via the pill/flyout instead. */
|
||||||
.flow-inbox-headless { display: none !important; }
|
.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>
|
</aside>
|
||||||
</div>
|
</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
|
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||||
the marked npm package into app.js; load order is preserved by
|
the marked npm package into app.js; load order is preserved by
|
||||||
the module bundler. -->
|
the module bundler. -->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue