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