diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 04468966..d2c673bb 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -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 = '◐ ' + 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 diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index c0f7fc14..3caffb1c 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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; } diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index d6b924bc..94582573 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -159,6 +159,20 @@ + +
+