dashboard #486: drop manager special-case + enable M0V3 picker for multi-select (mara on #695)

Two changes from mara's review:

1. drop the manager special-case. Both M0V3 affordances now apply
   regardless of whether the manager is in the selection; backend
   topology::set_parent refuses the manager move and the failure
   surfaces in the bulk-action error roll-up. Matches the #443 ST0P
   policy of 'don't pre-gate manager actions, let the backend speak'.

2. enable the M0V3 → <pick> picker for multi-select. Was single-agent
   only in v1. Picker now omits every selected agent itself plus the
   union of every selected agent's descendants (cycle-safe across the
   whole batch); on submit POSTs once per selected agent sequentially,
   same shape as the existing bulk-button loop. Confirm message +
   error roll-up adapt to selection size.

docs/web-ui.md updated to match.
This commit is contained in:
iris 2026-05-31 10:13:30 +02:00 committed by Mara
commit 1656b265ed
2 changed files with 92 additions and 77 deletions

View file

@ -441,15 +441,18 @@ frosted-mauve bar slides up from the bottom of the viewport
- `↻ 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.
(parent = null); disabled when all selected are already at root.
Manager included with no special-case (matches the `ST0P` policy);
the backend's `topology::set_parent` refuses to move the manager
and the refusal surfaces in the failure roll-up.
- `⇢ M0V3 → [select]` (#486) — inline picker available for any
selection size. The dropdown lists every container that isn't IN
the selection itself nor a descendant of any selected agent
(client-side BFS cycle prevention across the whole batch; the
backend re-checks per-agent). On submit POSTs to
`/api/topology/set-parent` (form-encoded `child=<name>&new_parent=<target>`)
once per selected agent, 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

@ -883,113 +883,125 @@ window.marked = marked;
// #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.
//
// No client-side manager special-case (mara on #695): backend
// `topology::set_parent` refuses to move the manager and surfaces the
// refusal as a per-agent failure in the bulk-action error roll-up. Same
// pattern as #443 ST0P (which also doesn't special-case manager).
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, {
addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', 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),
disabledTitle: !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);
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
// button. Available for any selection size (mara on #695 — was
// single-agent only in v1). Picker omits each selected agent itself
// plus the union of every selected agent's descendants (cycle-safe;
// backend `topology::set_parent` re-checks). Empty candidate list ⇒
// disable the picker.
const candidates = validReparentCandidates(selected, 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 —'));
const selectTitle = selected.length === 1
? `change ${selected[0].name}'s parent`
: `change ${selected.length} agents' parent`;
const sel = el('select', { class: 'move-picker-select', title: selectTitle });
sel.append(el('option', { value: '' }, '— pick parent —'));
for (const name of candidates) {
select.append(el('option', { value: name }, name));
sel.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)`;
sel.disabled = true;
sel.title = selected.length === 1
? `no valid parents for ${selected[0].name} (every other agent is its descendant)`
: `no valid parents — every other agent is a descendant of one of the selected`;
}
const btn = el('button', { type: 'button', class: 'btn btn-move' }, '⇢ M0V3');
btn.disabled = true;
select.addEventListener('change', () => { btn.disabled = !select.value; });
sel.addEventListener('change', () => { btn.disabled = !sel.value; });
btn.addEventListener('click', async () => {
const newParent = select.value;
const newParent = sel.value;
if (!newParent) return;
if (!confirm(`move ${target.name} under ${newParent}?`)) return;
const names = selected.map((c) => c.name);
const promptMsg = names.length === 1
? `move ${names[0]} under ${newParent}?`
: `move ${names.length} agents (${names.join(', ')}) under ${newParent}?`;
if (!confirm(promptMsg)) 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) : ''}`);
// Sequential POSTs — same shape as the bulk-button loop. Each
// call is small + backend serialises topology writes via the
// file-lock anyway.
const failures = [];
for (const name of names) {
try {
const body = new URLSearchParams({ child: 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(() => '');
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
} catch (err) {
alert(`move failed: ${err}`);
}
btn.innerHTML = original;
btn.disabled = !select.value;
btn.disabled = !sel.value;
if (failures.length) {
alert(`⇢ M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
}
});
wrap.append(select, btn);
wrap.append(sel, 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.
// re-parent targets for the `selected` agents: anyone who isn't IN
// the selection itself, isn't a descendant of any selected agent
// (cycle prevention across the whole batch). The backend re-checks
// per-agent via `topology::set_parent`; this client-side filter is
// purely UX so the operator can't pick an obviously-invalid option.
function validReparentCandidates(selected, containers) {
// Build child map once.
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);
// Union descendant set across every selected agent (each agent's
// descendants AND itself).
const blocked = new Set();
for (const t of selected) {
const queue = [t.name];
blocked.add(t.name);
while (queue.length) {
const n = queue.shift();
for (const child of (childrenOf.get(n) || [])) {
if (blocked.has(child)) continue;
blocked.add(child);
queue.push(child);
}
}
}
return containers
.filter((c) => !descendants.has(c.name))
.filter((c) => !blocked.has(c.name))
.map((c) => c.name)
.sort();
}