From 28e0d1b7fadb81973159da5c21dba7ba630cf4b9 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 18:55:08 +0200 Subject: [PATCH] feat(987): add per-agent three-dot context menu on SW4RM tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/dashboard/src/dashboard.css | 80 +++++++++ frontend/packages/dashboard/src/tabs.js | 155 +++++++++++++++++- 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 04545a12..fd4aac40 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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 diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 38a20e39..62946eb9 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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);