feat(987): add per-agent three-dot context menu on SW4RM tab

Each agent card in the SW4RM tab now has a ⋮ button that reveals a
dropdown with single-agent lifecycle actions: R3ST4RT, ST0P, ST4RT,
R3BU1LD, DESTR0Y, PURG3. Items are state-aware (disabled when action
doesn't apply). The button is CSS-invisible until the row is hovered
or the menu is open — quiet rows stay clean. DESTR0Y and PURG3 are
hidden for the manager container.

Click-outside and Escape close the menu. A tree-rebuild (on any SSE
state update) also closes it to avoid stale DOM references.
This commit is contained in:
iris 2026-06-01 18:55:08 +02:00 committed by mara
commit 28e0d1b7fa
2 changed files with 234 additions and 1 deletions

View file

@ -316,6 +316,86 @@ a:hover {
flex: 1;
min-width: 0;
}
/* per-agent three-dot context menu
Positioned after .card-body in the flex row. The button is invisible
until the row is hovered (or the menu is open) so quiet rows stay clean.
The dropdown is absolute-positioned relative to .agent-menu and opens
below + right-aligned to the button. */
.agent-menu {
flex: none;
position: relative;
align-self: flex-start;
margin-top: 0.3em;
}
.agent-menu-btn {
display: block;
background: none;
border: none;
color: var(--subtext0);
font-size: 1.1em;
line-height: 1;
cursor: pointer;
padding: 0.1em 0.4em;
border-radius: 4px;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
/* Show on row hover or when the menu is open. */
.container-row:hover .agent-menu-btn,
.agent-menu.open .agent-menu-btn {
opacity: 1;
}
.agent-menu-btn:hover,
.agent-menu-btn:focus-visible {
background: var(--surface1);
color: var(--text);
opacity: 1;
}
.agent-menu-btn:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 1px;
}
.agent-menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 200;
background: var(--surface0);
border: 1px solid var(--surface2);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
list-style: none;
margin: 0;
padding: 0.3em 0;
min-width: 10em;
white-space: nowrap;
}
.agent-menu-item {
display: block;
width: 100%;
background: none;
border: none;
color: var(--text);
font-family: inherit;
font-size: 0.82em;
letter-spacing: 0.01em;
text-align: left;
padding: 0.4em 0.9em;
cursor: pointer;
}
.agent-menu-item:not(:disabled):hover {
background: var(--surface1);
}
.agent-menu-item:disabled {
opacity: 0.35;
cursor: default;
}
.agent-menu-sep {
height: 1px;
background: var(--surface2);
margin: 0.3em 0;
}
/* Pending state splits queued vs running queued ops show only
the pending-state badge (no row tint), running ops keep the
amber row tint AND get a rotating amber ring on the icon. See

View file

@ -325,6 +325,156 @@ window.marked = marked;
}
});
// ─── per-agent context menu ──────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
// restart/stop disabled when agent is stopped, start disabled when
// running, destroy/purge hidden for the manager.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
let openAgentMenu = null; // currently open dropdown element, or null
function closeAllAgentMenus() {
if (!openAgentMenu) return;
openAgentMenu.hidden = true;
const wrap = openAgentMenu.closest('.agent-menu');
if (wrap) {
wrap.classList.remove('open');
const btn = wrap.querySelector('.agent-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
openAgentMenu = null;
}
// Close on any click outside an agent-menu element.
document.addEventListener('click', (e) => {
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
}, true);
// Close on Escape (shares the keydown listener below with selection-clear;
// close happens first since no selection change is needed here).
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openAgentMenu) {
closeAllAgentMenus();
e.stopPropagation();
}
}, true);
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body) {
const url = actionPath + encodeURIComponent(name);
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
}
} catch (err) {
alert('action failed: ' + err);
}
}
function buildAgentMenu(c) {
const wrap = el('div', { class: 'agent-menu' });
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
function menuItem(label, opts) {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
if (opts.disabled) {
item.disabled = true;
if (opts.title) item.title = opts.title;
} else {
item.addEventListener('click', async () => {
closeAllAgentMenus();
if (opts.confirm && !confirm(opts.confirm)) return;
await agentMenuPost(opts.action, c.name, opts.body || null);
});
}
li.append(item);
return li;
}
function menuSep() {
return el('li', { class: 'agent-menu-sep', role: 'separator' });
}
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/restart/',
disabled: !c.running,
title: 'agent is stopped',
confirm: `restart ${c.name}?`,
}),
menuItem('■ ST0P', {
action: '/kill/',
disabled: !c.running,
title: 'agent is already stopped',
confirm: `stop ${c.name}?`,
}),
menuItem('▶ ST4RT', {
action: '/start/',
disabled: c.running,
title: 'agent is already running',
confirm: `start ${c.name}?`,
}),
menuItem('↻ R3BU1LD', {
action: '/rebuild/',
confirm: `rebuild ${c.name}? hot-reloads the container.`,
}),
);
if (!c.is_manager) {
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = dropdown.hidden;
closeAllAgentMenus();
if (wasHidden) {
dropdown.hidden = false;
btn.setAttribute('aria-expanded', 'true');
wrap.classList.add('open');
openAgentMenu = dropdown;
}
});
wrap.append(btn, dropdown);
return wrap;
}
// 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.
@ -350,6 +500,9 @@ window.marked = marked;
// See docs/web-ui.md::Topology tree for the rendering contract
// (forest walk, alphabetical sort, orphan + cycle handling).
function buildAgentTree(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllAgentMenus();
const byName = new Map();
for (const c of containers) byName.set(c.name, c);
const children = new Map(); // parent_name → [child_name, …]
@ -721,7 +874,7 @@ window.marked = marked;
// here since it opens the side panel rather than a link.
body.append(drill);
li.append(icon, body);
li.append(icon, body, buildAgentMenu(c));
ul.append(li);
}
root.append(ul);