From 1f5197a0a54916a7cd5835258489166bfbc3e980 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 20:21:37 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(#1007):=20unify=20M0V3=20=E2=86=92=20R?= =?UTF-8?q?OOT=20and=20reparent=20into=20single=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/dashboard/src/tabs.js | 60 +++++++++---------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index f99afc04..1f55ca28 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -956,57 +956,39 @@ window.marked = marked; // included) and the refusal surfaces in the bulk-action error // 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 → : inline `` with "(no parent)" as the + // first real choice (backend: empty new_parent → promotes to root) plus + // all valid candidate agents. Removes the need for a separate ROOT + // button — picking "(no parent)" achieves the same outcome. 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)" promotes selected agent(s) to top-level. + 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'); + const btn = el('button', { type: 'button', class: 'btn btn-move' }, 'M0V3'); btn.disabled = true; - sel.addEventListener('change', () => { btn.disabled = !sel.value; }); + // Enable once any real choice is made (selectedIndex > 0). + sel.addEventListener('change', () => { btn.disabled = sel.selectedIndex === 0; }); btn.addEventListener('click', async () => { - const newParent = sel.value; - if (!newParent) return; + if (sel.selectedIndex === 0) return; + // '__root__' → empty new_parent (promotes to root / no parent). + 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}?`; + ? `move ${names[0]} → ${newParentLabel}?` + : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`; if (!confirm(promptMsg)) return; btn.disabled = true; - const original = btn.innerHTML; - btn.innerHTML = ' ⇢ M0V3'; - // Sequential POSTs — same shape as the bulk-button loop. Each - // call is small + backend serialises topology writes via the - // file-lock anyway. + const original = btn.textContent; + btn.innerHTML = ''; const failures = []; for (const name of names) { try { @@ -1027,10 +1009,10 @@ window.marked = marked; failures.push(`${name}: ${err}`); } } - btn.innerHTML = original; - btn.disabled = !sel.value; + btn.textContent = original; + btn.disabled = 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); From 912f9c5ed25be9f2caebbe7e4db7d252e6fa51a6 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 20:29:43 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(#1007):=20select-as-action=20=E2=80=94?= =?UTF-8?q?=20picking=20a=20parent=20directly=20triggers=20M0V3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/dashboard/src/dashboard.css | 4 +- frontend/packages/dashboard/src/tabs.js | 59 ++++++++----------- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index e10073be..2578cbdd 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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; -} + diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 1f55ca28..494ecb4d 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -933,33 +933,24 @@ 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=&new_parent=`. 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=&new_parent=`. 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) { - // Unified M0V3 picker: a single `