Compare commits

...
Author SHA1 Message Date
iris
912f9c5ed2 feat(#1007): select-as-action — picking a parent directly triggers M0V3
Selecting any option in the M0V3 dropdown now fires confirm+POST
immediately; no separate button needed. On cancel or after the
request completes, the select resets to the placeholder. Removes
the now-redundant btn-move button and its CSS rule.
2026-06-01 20:31:35 +02:00
iris
1f5197a0a5 feat(#1007): unify M0V3 → ROOT and reparent into single picker
Remove the separate '⇡ M0V3 → ROOT' button from the selection bar.
Add '(no parent)' as the first real option in the existing M0V3
dropdown — selecting it submits an empty new_parent, which the backend
already treats as 'promote to root'.

The select placeholder label changes to '⇢ M0V3 →' so the combined
control reads naturally without the old standalone button. The submit
button enables as soon as any option past the placeholder is chosen
(selectedIndex > 0), which correctly covers both '(no parent)' and
named-parent selections.
2026-06-01 20:31:35 +02:00
2 changed files with 32 additions and 65 deletions

View file

@ -2520,6 +2520,4 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
opacity: 0.5;
cursor: default;
}
.move-picker .btn-move {
margin-left: 0;
}

View file

@ -933,80 +933,49 @@ window.marked = marked;
disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)),
});
// Move agent(s) in the topology tree — see
// docs/web-ui.md::Selection bar for the two affordances:
// Move agent(s) in the topology tree — selecting an option in the
// M0V3 dropdown immediately confirms + executes the move. "(no parent)"
// promotes to root (empty new_parent on the backend). Cycle-safe:
// dropdown filters out self and descendants on the client side; the
// backend rechecks via `topology::set_parent`.
//
// ⇡ 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),
// 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.
// Backend: POST /api/topology/set-parent (dashboard.rs),
// form-encoded `child=<name>&new_parent=<target-or-empty>`. Re-emits
// container snapshots on success so the tree repaints without a
// separate refresh.
addMoveActions(actions, selected, containers);
}
// Render the M0V3 affordances inside the selection bar — separate
// helper because the picker variant needs a select + button pair,
// not the single-button shape addBulkButton ships. Backend
// `topology::set_parent` refuses moves it can't satisfy (manager
// included) and the refusal surfaces in the bulk-action error
// roll-up.
// Render the M0V3 picker in the selection bar. Selecting any real option
// (including "(no parent)") immediately fires a confirm + POST — no
// separate button. Backend `topology::set_parent` refuses invalid moves
// and the refusal surfaces in the alert roll-up.
function addMoveActions(parent, selected, containers) {
// 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', 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: !someNotAtRoot
? '⇡ M0V3 → ROOT not available — all selected agents are already at root'
: null,
});
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
// button. Available for any selection size. 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 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 —'));
sel.append(el('option', { value: '' }, '⇢ M0V3 →'));
// "(no parent)" → empty new_parent on the backend (promotes to root).
sel.append(el('option', { value: '__root__' }, '(no parent)'));
for (const name of candidates) {
sel.append(el('option', { value: name }, name));
}
if (!candidates.length) {
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;
sel.addEventListener('change', () => { btn.disabled = !sel.value; });
btn.addEventListener('click', async () => {
const newParent = sel.value;
if (!newParent) return;
sel.addEventListener('change', async () => {
if (sel.selectedIndex === 0) return;
const newParent = sel.value === '__root__' ? '' : sel.value;
const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value;
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';
// Sequential POSTs — same shape as the bulk-button loop. Each
// call is small + backend serialises topology writes via the
// file-lock anyway.
? `move ${names[0]}${newParentLabel}?`
: `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
if (!confirm(promptMsg)) {
sel.selectedIndex = 0;
return;
}
sel.disabled = true;
const failures = [];
for (const name of names) {
try {
@ -1027,13 +996,13 @@ window.marked = marked;
failures.push(`${name}: ${err}`);
}
}
btn.innerHTML = original;
btn.disabled = !sel.value;
sel.disabled = false;
sel.selectedIndex = 0;
if (failures.length) {
alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
}
});
wrap.append(sel, btn);
wrap.append(sel);
parent.append(wrap);
}