dashboard: selection-bar M0V3 affordances for re-parenting agents (#486)

Backend POST /api/topology/set-parent already shipped; the dashboard
was missing the operator surface to drive it. Adds two affordances
to the SW4RM tab's selection bar (alongside the existing R3ST4RT /
ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3 actions):

- '⇡ M0V3 → ROOT' (bulk): promote selected agents to top-level
  (parent=null). Disabled when all selected are already at root or
  the selection includes the manager (backend refuses anyway).

- '⇢ M0V3 → [pick]' (single-agent only): inline <select> dropdown
  + button pair. Dropdown lists every container that isn't the
  target nor a descendant of it (client-side BFS via the existing
  c.parent map). On submit POSTs form-encoded
  'child=<name>&new_parent=<target>' to /api/topology/set-parent;
  the backend re-checks the cycle invariant and re-emits a
  container snapshot so the tree repaints without a reload.

Both POSTs hit a single URL, so addBulkButton grew an optional
'perAgentBodyFor(name)' hook to handle the body-driven endpoint
shape (vs the URL-suffix /start/<name> shape every other action
uses). Lifecycle endpoints unchanged.

Mauve chrome (var(--mauve)) reads as 'structural change' rather
than the destructive red / amber of destroy / rebuild.

closes #486
This commit is contained in:
iris 2026-05-31 09:35:52 +02:00 committed by Mara
commit 2950a7f9ee
3 changed files with 189 additions and 2 deletions

View file

@ -440,6 +440,16 @@ frosted-mauve bar slides up from the bottom of the viewport
- `▶ ST4RT` — stopped agents only
- `↻ R3BU1LD` — always available
- `DESTR0Y` / `PURG3` — sub-agents only (disabled if manager selected)
- `⇡ M0V3 → ROOT` (#486) — promote selected agents to top-level
(parent = null); disabled when all selected are already at root
or when the selection includes the manager.
- `⇢ M0V3 → [select]` (#486) — single-agent-only inline picker;
the dropdown lists every container that isn't the target itself
nor a descendant of it (client-side cycle prevention; the
backend's `topology::set_parent` re-checks). On submit POSTs to
`/api/topology/set-parent` (form-encoded `child=<name>&new_parent=<target>`),
which writes `topology.json` and re-emits a container snapshot so
the tree repaints without a page reload.
- **`✕ clear`** button + `Esc` key clear the entire selection.
Stale selections (agents destroyed while selected) are pruned on

View file

@ -915,6 +915,11 @@ ul form.inline { display: inline-block; }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
/* #486 M0V3 affordance (selection bar). Mauve picks up the same
accent the question-override / mid-status surfaces use; reads as
"structural change" rather than the destructive red / amber chrome
of destroy / rebuild. */
.btn-move { color: var(--mauve); border-color: var(--mauve); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.btn-fire-now { color: var(--mauve, #cba6f7); border-color: var(--mauve, #cba6f7); }
@ -2085,3 +2090,32 @@ body.flow-shell .tabbar .tab.active.tab-link {
visible (`body.has-selection`, toggled by tabs.js when the
selection set is non-empty). */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
/* #486 M0V3 <pick> picker. Inline `<select>` + button pair
sitting alongside the bulk action buttons in the selection bar.
The select inherits the terminal-y monospace look so it doesn't
read as system-chrome popping out of the swarm aesthetic. Only
surfaces when exactly one agent is selected. */
.move-picker {
display: inline-flex;
align-items: center;
gap: 0.3em;
margin-left: 0.6em;
}
.move-picker-select {
font-family: inherit;
font-size: 0.75em;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--mauve);
border-radius: 2px;
padding: 0.1em 0.3em;
max-width: 16em;
}
.move-picker-select:disabled {
opacity: 0.5;
cursor: default;
}
.move-picker .btn-move {
margin-left: 0;
}

View file

@ -864,6 +864,134 @@ window.marked = marked;
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`)),
});
// #486 — move agent(s) in the topology tree. Two affordances:
//
// ⇡ M0V3 → ROOT promote selected agent(s) to top-level (parent=null)
// ⇢ M0V3 → [sel] reparent the single selected agent under a picked
// parent (cycle-safe — the dropdown filters out self
// and own descendants on the client side; the
// backend rechecks via `topology::set_parent`).
//
// Backend lives at POST /api/topology/set-parent (dashboard.rs#2170),
// form-encoded `child=<name>&new_parent=<target-or-empty>`. The
// backend re-emits container snapshots on success, so the tree
// repaints without a separate refresh.
addMoveActions(actions, selected, containers);
}
// #486 — render the M0V3 affordances inside the selection bar. Split
// into its own helper because the picker variant needs a select + button
// pair, not the single-button shape addBulkButton ships.
function addMoveActions(parent, selected, containers) {
// Manager can never be a child of anything — backend refuses with
// "refusing to move the manager". Disable both affordances when the
// selection includes hm1nd (or any agent flagged is_manager).
const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name);
const movable = !managerNames.length;
// M0V3 → ROOT: parent=null for every selected agent. Only meaningful
// when at least one selected agent currently has a non-null parent;
// otherwise it's a no-op for everything.
const someNotAtRoot = selected.some((c) => c.parent);
addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', movable && someNotAtRoot, selected, {
action: '/api/topology/set-parent',
perAgentBodyFor: (name) => ({ child: name, new_parent: '' }),
confirm: (names) => `promote ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')}) to top-level (parent → root)?`,
disabledTitle: !movable
? why('⇡ M0V3 → ROOT', managerNames.map((n) => `\`${n}\` is the manager`))
: (!someNotAtRoot ? '⇡ M0V3 → ROOT not available — all selected agents are already at root' : null),
});
// M0V3 → <pick>: only when exactly one agent is selected. Picker
// omits self + own descendants (cycle-safe) + the manager (the
// backend allows manager-as-parent, that's fine — manager IS the
// default parent already, so we include it as an option). Empty
// candidate list ⇒ disable the picker.
if (selected.length !== 1 || !movable) {
// Multi-select or manager-included → skip the picker. The
// ROOT button above still applies; the picker is single-agent
// ergonomics only.
return;
}
const target = selected[0];
const candidates = validReparentCandidates(target, containers);
const wrap = el('span', { class: 'move-picker' });
const select = el('select', {
class: 'move-picker-select',
title: `change ${target.name}'s parent`,
});
select.append(el('option', { value: '' }, '— pick parent —'));
for (const name of candidates) {
select.append(el('option', { value: name }, name));
}
if (!candidates.length) {
select.disabled = true;
select.title = `no valid parents for ${target.name} (all other agents are its descendants)`;
}
const btn = el('button', { type: 'button', class: 'btn btn-move' }, '⇢ M0V3');
btn.disabled = true;
select.addEventListener('change', () => { btn.disabled = !select.value; });
btn.addEventListener('click', async () => {
const newParent = select.value;
if (!newParent) return;
if (!confirm(`move ${target.name} under ${newParent}?`)) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ⇢ M0V3';
try {
const body = new URLSearchParams({ child: target.name, new_parent: newParent });
const resp = await fetch('/api/topology/set-parent', {
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(() => '');
alert(`move failed: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
alert(`move failed: ${err}`);
}
btn.innerHTML = original;
btn.disabled = !select.value;
});
wrap.append(select, btn);
parent.append(wrap);
}
// Filter the dashboard's container list to those that are valid
// re-parent targets for `target`: anyone who isn't `target` itself,
// isn't a descendant of `target` (cycle prevention), and isn't a
// tombstoned/non-running entry that the topology layer would refuse.
// The backend re-checks the same constraints; this client-side filter
// is purely UX so the operator can't pick an obviously-invalid option.
function validReparentCandidates(target, containers) {
// Build descendant set for target via BFS through the parent map.
const childrenOf = new Map();
for (const c of containers) {
const p = c.parent || null;
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(c.name);
}
const descendants = new Set([target.name]);
const queue = [target.name];
while (queue.length) {
const n = queue.shift();
for (const child of (childrenOf.get(n) || [])) {
if (descendants.has(child)) continue;
descendants.add(child);
queue.push(child);
}
}
return containers
.filter((c) => !descendants.has(c.name))
.map((c) => c.name)
.sort();
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
@ -887,10 +1015,25 @@ window.marked = marked;
// 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.
//
// Two URL shapes:
// - `opts.action` is a path prefix and the agent name gets
// appended (lifecycle endpoints: /start/<name>, /rebuild/<name>).
// `opts.body` is a static object applied to every POST.
// - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
// full URL (no name appended) and the per-agent body comes
// from the callback. Used by /api/topology/set-parent (#486),
// where the agent name is a body field rather than a URL
// component.
for (const name of names) {
const body = new URLSearchParams(opts.body || {});
const body = opts.perAgentBodyFor
? new URLSearchParams(opts.perAgentBodyFor(name))
: new URLSearchParams(opts.body || {});
const url = opts.perAgentBodyFor
? opts.action
: opts.action + encodeURIComponent(name);
try {
const resp = await fetch(opts.action + encodeURIComponent(name), {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,