diff --git a/docs/web-ui.md b/docs/web-ui.md index b1ecb86..30139fe 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -184,9 +184,19 @@ falls it back to the dimmed hyperhive mark (`/favicon.svg`) instead of an empty box — a real load-failure fallback, not a guess from container state. -- Line 1: agent name (link → new tab), m1nd/ag3nt chip, status - badges — `⊘ rate limited` (red, while the harness is parked - after a 429), `needs login`, `needs update` — in-flight `◐ +- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an + **icon-only nav strip** populated async from the agent backend + (`📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge profile`, + `↳ agent-configs mirror`, plus any agent-declared + `dashboardLinks` extras — issue #262). The dashboard JS fetches + `GET /api/agent/{name}/links`, a same-origin passthrough proxy + that forwards the agent's own link list; the agent backend is + the single source of truth. The frontend resolves each + `AgentLink.kind` (`container` → `http://host:`, + `forge` → `http://host:3000`, `external` → already absolute). + Status badges follow — `⊘ rate limited` (red, while the harness + is parked after a 429), `needs login`, `needs update` — in-flight + `◐ pending-state…` pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port, and a `ctx · Nk` chip showing the agent's last-turn context size @@ -209,12 +219,12 @@ guess from container state. (`journalctl -M -b --no-pager --output=short-iso`). A unit dropdown (harness service / full machine journal) and a refresh button live in the panel. - - `↳ config repo ↗` — link to the agent's applied config repo - on the bundled forge (`agent-configs/`), opened in a - new tab. Shown only when `forge_present`. Replaces the old - one-file `agent.nix` viewer — the forge shows the full repo - with history. Mutating config still requires - `request_apply_commit` + approval. + - Plain navigation links (config repo, forge profile, + `dashboardLinks` extras) now live in the icon-only nav strip + on Line 1 — see above (issue #262). The agent's `config` link + goes to the repo root; the deployed sha shows separately on + Line 1 as the `deployed:` chip, since the agent harness + can't know its own deployed commit. `↻ UPD4TE 4LL` button appears above the containers list when any agent is stale. Banner pulses on each broker SSE event @@ -399,10 +409,21 @@ Layout, top to bottom: - Banner (gradient shimmer while state=thinking). - Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. -- Meta links row: `📊 stats →`, `🖥 screen →` (shown when - `gui_enabled`), and `⬡ forge ↗` (shown when `/api/state` - supplies a non-null `forge_url` — the agent's Forgejo profile, - assembled server-side from the request `Host` header, new tab). +- Meta links row: backend-supplied `StateSnapshot.links` (issue + #262) rendered as ` label →`. Always includes `📊 stats` + (`kind = Container`); `🖥 screen` when the VNC compositor is + enabled; `⬡ forge` (profile) + `↳ config` (agent-configs + mirror, repo root since the agent doesn't know its own deployed + sha) when the agent has a forge account, both `kind = Forge`; + followed by any `hyperhive.dashboardLinks` extras + (`kind = External`) read from + `{state_dir}/hyperhive-dashboard-links.json`. The same list + feeds the dashboard card's icon strip via the host's + `GET /api/agent/{name}/links` passthrough proxy — agent backend + is the single source of truth for what links it exposes. + Frontend resolves each `kind` (`container` → same-origin path, + `forge` → `http://host:3000`, `external` → absolute) via DOM + building, so agent-declared strings never reach `innerHTML`. - Status section: empty when online (alive-badge in the state row carries the signal), populated with the login form / OAuth URL when `status` is `needs_login_*`. diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index ec712a8..eb47893 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -674,14 +674,33 @@ const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } currentLabel = s.label; - // Render server-supplied navigation links (stats, screen, forge, extras). + // Render server-supplied navigation links — stats, screen, the + // forge profile, the agent-configs mirror, plus any + // agent-declared `dashboardLinks` extras (issue #262). Each + // NavLink's `kind` says how to resolve `url`: Container → + // same-origin path (the agent page is itself container-local); + // Forge → `http://:3000`; External → already + // absolute. DOM-built via el() — agent-declared icon / label / + // url strings must NEVER reach innerHTML. const metaLinks = $('meta-links'); - if (metaLinks && s.links) { - metaLinks.innerHTML = (s.links || []).map((lnk, i) => { - const margin = i === 0 ? '' : ' margin-left: 1em;'; - const target = lnk.url.startsWith('http') ? ' target="_blank" rel="noopener"' : ''; - return `${lnk.icon} ${lnk.label} →`; - }).join(''); + if (metaLinks && Array.isArray(s.links)) { + metaLinks.replaceChildren(); + const forgeBase = `http://${window.location.hostname}:3000`; + s.links.forEach((lnk, i) => { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ (lnk.url || ''); + const a = el('a', { + class: 'agent-nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + if (i > 0) a.style.marginLeft = '1em'; + a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →'); + metaLinks.append(a); + }); } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 9c4594c..fde54d3 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{ IntoResponse, Response, sse::{Event, KeepAlive, Sse}, @@ -383,21 +383,56 @@ struct StateSnapshot { /// Cumulative token usage across the most recent turn's inferences /// (cost signal). `null` until the first turn finishes. cost_usage: Option, - /// Navigation links for this agent page. Always includes at least - /// the stats link; conditionally includes screen (gui enabled) and - /// forge (agent has account). Extra agent-specific links land here - /// once the declarative `hyperhive.dashboardLinks` option (#191) - /// ships. Frontend renders only the entries present — no client-side - /// URL construction or icon assignment. + /// Navigation links for this agent page (issue #262). Stats is + /// always present; screen when the VNC compositor is enabled; the + /// forge profile + the agent-configs mirror repo when the agent + /// has a forge account; followed by any agent-declared + /// `hyperhive.dashboardLinks` extras (read from + /// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is + /// already absolute — built server-side from the request `Host` + /// header — so the frontend just renders. + /// + /// This same list is the **source of truth** for the per-agent + /// page *and* the dashboard card's icon-only nav strip: hive-c0re + /// proxies it via `GET /api/agent/{name}/links` (same-origin from + /// the dashboard JS), avoiding CORS and centralising the link + /// definitions in the agent backend. links: Vec, } -/// One navigation link in the agent page header row. +/// One navigation link in the agent page header row. Same JSON +/// shape feeds the dashboard's icon-only nav strip via the host's +/// `GET /api/agent/{name}/links` passthrough proxy, so the agent +/// backend is the single source of truth for what links an agent +/// exposes (issue #262). #[derive(Serialize)] struct AgentLink { + /// `kind = Container | Forge` → path; `kind = External` → full URL. + /// The frontend prepends the right base before rendering. url: String, icon: String, label: String, + kind: AgentLinkKind, +} + +/// Resolution hint for `AgentLink.url`. The agent backend can't know +/// which hostname the browser sees (especially when the dashboard +/// proxies the call from a different origin), so it labels each link +/// and lets the frontend prepend the right base. +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum AgentLinkKind { + /// `url` is a path on the agent's container web UI (`/stats`, + /// `/screen`). Agent page: same-origin path. Dashboard: + /// `http://:`. + Container, + /// `url` is a path on the local Forgejo (`/