dashboard: agent selection + bulk action bar (#443)
per mara on #443: "dont show all the restart buttons etc., just show state and links. instead, clicking an agent icon selects that agent. you can select as many as you like. then you can run an action on all of them." selection model - module-level selectionState = Set<string> of agent logical names. clicking a container-row icon toggles membership; Esc clears the whole set (ignored when an editable element has focus so typing in compose / answer / journal-search isn't intercepted). - icon now has role="button" + tabindex="0" so it's keyboard-accessible; aria-pressed reflects the toggle state. hover + focus-visible get a mauve ring. - selected rows get a .selected class — mauve outline + faint mauve wash on the row, brighter ring on the icon. - on every renderContainers pass, stale selections (agents destroyed while selected) are pruned defensively. sticky action bar - new #selection-bar in index.html — fixed bottom strip with count chip, name list, action buttons, clear button. hidden when selection empty. styled to match the flow composer's frosted-mauve chrome (vibecore family). - per mara's option B answer: every action button is visible; buttons that don't apply to the whole selection are disabled with a tooltip explaining WHY ("iris is already stopped" etc). .btn:disabled styling added. - actions: R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3. per-action confirm prompt lists the names being acted on. when the selection includes the manager the ST0P prompt calls out the consequence (approvals + meta operations pause until manager is restarted). DESTR0Y/PURG3 stay sub-agent-only; including the manager disables them with a clear tooltip. - actions POST per agent in a loop to the existing endpoints (/restart/{name}, /kill/{name}, etc.); no new backend wire surface. event-driven derived stores (containersState, rebuildQueueState) already update live via SSE — no manual refetch. - failures from any individual POST surface in a single alert at the end rather than spamming dialogs mid-loop. per-card actions removed - the in-card R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3 block is gone. cards now show identity / state / nav-strip / status text / drill-ins only — "state and links" per mara. the contextual needs-update chip in the head row stays. manager stop - the "also make manager stoppable" half of #443 ships as PR #445 (separate small backend change). this PR depends on #445 for the bulk-stop button to actually work on selections containing the manager; until #445 merges, ST0P on a manager-included selection will fail individually with a 500 for the manager (other selected agents are still stopped; failures bucket into the end-of-loop alert). build clean: npm run build --workspace=@hive/dashboard. no backend changes here.
This commit is contained in:
parent
b42b219f9b
commit
a366c00e0e
3 changed files with 336 additions and 42 deletions
|
|
@ -287,6 +287,43 @@ 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.
|
||||
|
|
@ -445,6 +482,13 @@ 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);
|
||||
|
|
@ -474,7 +518,12 @@ window.marked = marked;
|
|||
: (op.kind === 'meta_update' ? 'meta-update queued'
|
||||
: op.kind === 'destroy' ? 'destroy queued'
|
||||
: 'rebuild queued')));
|
||||
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
|
||||
const selected = selectionState.has(c.name);
|
||||
const li = el('li', {
|
||||
class: 'container-row'
|
||||
+ (pending ? ' pending' : '')
|
||||
+ (selected ? ' selected' : ''),
|
||||
});
|
||||
// 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
|
||||
|
|
@ -500,7 +549,28 @@ 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: '' });
|
||||
const icon = el('div', { class: 'container-icon' }, iconImg);
|
||||
// #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);
|
||||
}
|
||||
});
|
||||
if (c.running) {
|
||||
iconImg.src = `${url}icon`;
|
||||
iconImg.addEventListener('error', () => {
|
||||
|
|
@ -643,46 +713,15 @@ window.marked = marked;
|
|||
));
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
// 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.
|
||||
|
||||
// ── drill-ins ────────────────────────────────────────────────
|
||||
const drill = el('div', { class: 'drill-ins' });
|
||||
|
|
@ -703,6 +742,152 @@ 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 confirm message
|
||||
// calls out the manager case ("approvals pause until restart") when
|
||||
// the manager is in the set.
|
||||
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
|
||||
action: '/kill/',
|
||||
confirm: (names) => {
|
||||
const hasManager = selected.some((c) => c.is_manager);
|
||||
const base = `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`;
|
||||
return hasManager
|
||||
? base + ' approvals + meta operations pause until the manager is restarted.'
|
||||
: base;
|
||||
},
|
||||
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,6 +298,28 @@ 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
|
||||
|
|
@ -806,6 +828,19 @@ 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; }
|
||||
|
|
@ -1519,3 +1554,63 @@ 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,6 +159,20 @@
|
|||
</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