From 5bd085fbac20865557f3e27e8977e69b549b1828 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 11 Aug 2026 17:25:07 +0200 Subject: [PATCH] web-ui: expose per-agent paused status, add pause/resume to the agent page's own overflow menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hive-agent's own web_ui module never exposed the agent's own paused status to its own /api/state — the dashboard's cross-container view knew it, but a per-agent page had no way to know it's paused. Added StateSnapshot.paused (a direct stat of the same harness-local pause marker hive-c0re's Coordinator::is_paused checks). The per-agent page's ⋯ overflow menu now has a pause/resume item that POSTs to hive-c0re's existing /api/pause/ / /api/resume/ — the same endpoints the dashboard's already uses, same cross-origin form-submit pattern the existing rebuild-container item uses. The item's label tracks state.paused on every /api/state refresh so a pause/resume triggered from the dashboard while this page is open doesn't leave a stale action showing. --- docs/web-ui/agent.md | 13 ++++++- frontend/packages/agent/src/app.js | 61 ++++++++++++++++++++++++++++++ hive-agent/src/web_ui/state.rs | 13 +++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 084575c1..7bde2cb1 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -67,14 +67,23 @@ through. Three flex columns: (`GET /api/todos`, refreshed on cold load + every `turn_end`). - **Overflow button** (`⋯`): always visible. Opens a frosted popover (`#overflow-menu`, positioned outside the header to escape any - stacking context) with four management rows followed by a model + stacking context) with five management rows followed by a model quick-picker section: `↑ dashboard` (link), `↻ rebuild container` (POST confirm, same action as the dashboard R3BU1LD button), `↻ new claude session` (POST confirm → `POST /api/new-session`; next turn drops `--continue`), `🔓 logout` (POST confirm → `POST /api/logout`; SIGINTs any in-flight turn, wipes OAuth credential files, flips the agent to `needs_login` — session history - preserved). All destructive actions require one extra click to + preserved), and `⏸ pause agent` / `▶ resume agent` (POST confirm → + hive-c0re's `/api/pause/` / `/api/resume/` — the same + endpoints the dashboard's own `` uses, since + pausing is a hive-c0re-owned write this unprivileged process can't + make directly). The label + target endpoint track `state.paused` + (this agent's own `/api/state`, a direct stat of the harness's + local pause marker — see `docs/persistence.md`), refreshed on every + snapshot so a pause/resume triggered from the *dashboard* while + this page is open doesn't leave the menu item showing the wrong + action. All destructive actions require one extra click to acknowledge — rare ops shouldn't live in the primary state strip. Below a separator, a **model quick-picker** section labelled `model` renders one button per model in the operator-configured diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 26192801..c80b7999 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -231,6 +231,41 @@ window.marked = marked; }); menu.append(logoutBtn); + // ⏸ pause / ▶ resume — this page has no pause state of its own to + // hold or mutate; it POSTs to the same hive-c0re endpoints + // (`/api/pause/` / `/api/resume/`) the dashboard's own + // `` already uses, and only needs `state.paused` + // (this agent's own `/api/state`, added alongside this menu item) + // to know which of the two to show. See `renderPausedChip`, called + // from `refreshState` on every snapshot so the label tracks reality + // even when the pause/resume actually happened from the dashboard. + const pauseBtn = el('button', { + type: 'button', + class: 'overflow-item overflow-item-pause', + role: 'menuitem', + id: 'pause-btn', + }, + el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⏸'), + el('span', { id: 'pause-btn-label' }, 'pause agent'), + ); + pauseBtn.addEventListener('click', async () => { + const paused = pauseBtn.dataset.paused === 'true'; + const verb = paused ? 'resume' : 'pause'; + const message = paused + ? `resume ${label}? the turn loop restarts and drains queued messages.` + : `pause ${label}? parks the turn loop — inbox messages queue unacked.`; + if (!(await themedConfirm({ + message, danger: true, confirmLabel: paused ? '▶ resume' : '⏸ pause', + }))) return; + closeOverflowMenu(); + const f = document.createElement('form'); + f.method = 'POST'; + f.action = `${dashUrl}api/${verb}/${label}`; + document.body.appendChild(f); + f.submit(); + }); + menu.append(pauseBtn); + // ─── model quick-picker ──────────────────────────────────────── // One-click shortcuts for each model in `availableModels` (seeded // from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option). @@ -493,6 +528,12 @@ window.marked = marked; let effortPickerBtns = []; let availableEfforts = []; + // Pause/resume toggle in the overflow menu — see `renderPausedChip`. + // Tracked so subsequent /api/state refreshes can flip the existing + // button's label without rebuilding the whole overflow menu (same + // reason `currentModel`/`currentEffort` are tracked above). + let currentPaused = false; + const SLASH_COMMANDS = [ { name: '/help', desc: 'list slash commands' }, { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, @@ -1056,6 +1097,25 @@ window.marked = marked; btn.setAttribute('aria-pressed', String(isActive)); } } + + // Flips the overflow menu's pause/resume item to match the backend's + // reported `state.paused` (harness-local marker stat, cheap to refresh + // on every /api/state poll) — without this, an operator who pauses from + // the *dashboard* while this page is open would still see a stale + // "pause agent" item here, offering the wrong action. + function renderPausedChip(paused) { + currentPaused = !!paused; + const btn = $('pause-btn'); + if (!btn) return; + btn.dataset.paused = String(currentPaused); + const icon = btn.querySelector('.overflow-item-icon'); + const label_ = $('pause-btn-label'); + if (icon) icon.textContent = currentPaused ? '▶' : '⏸'; + if (label_) label_.textContent = currentPaused ? 'resume agent' : 'pause agent'; + btn.title = currentPaused + ? 'resume this agent — the turn loop restarts and drains queued messages' + : 'pause this agent — parks the turn loop, inbox messages queue unacked'; + } // Token badges — two separate chips: // ctx · N last inference's prompt size = current context window // utilisation (what to watch for compaction decisions) @@ -1196,6 +1256,7 @@ window.marked = marked; renderAliveBadge(s.status); renderModelChip(s.model); renderEffortChip(s.effort); + renderPausedChip(s.paused); renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage }); // Todos pill: cold-load populate; turn_end refreshes via renderTodos. refreshTodos(); diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index 9c84e8d0..73736aa0 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -66,6 +66,7 @@ pub(super) async fn api_state(State(state): State) -> axum::Json, + /// Whether this agent's turn loop is currently parked (the harness + /// keeps serving this page + its MCP daemons but drives no turns). + /// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks + /// (`crate::paths::paused_marker`) — read directly here rather than + /// asking hive-c0re over the socket, since the harness already has + /// the file locally. hive-c0re owns the actual pause/resume *writes* + /// (via hive-priv, this process runs unprivileged) — the per-agent + /// page's own `⋯` menu POSTs to hive-c0re's existing + /// `/api/pause/` / `/api/resume/` (same endpoints + /// `` on the dashboard already uses), this field + /// only tells the frontend which of the two to show. + paused: bool, } #[derive(Serialize)]