diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js
index 4bc8d3e..2ff419a 100644
--- a/hive-ag3nt/assets/app.js
+++ b/hive-ag3nt/assets/app.js
@@ -674,19 +674,14 @@
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
currentLabel = s.label;
- // Show the screen link when the weston VNC compositor is enabled.
- const screenLink = $('screen-link');
- if (screenLink) screenLink.style.display = s.gui_enabled ? '' : 'none';
- // Forge profile link — the backend hands us the full URL (or
- // null when this agent has no forge account).
- const forgeLink = $('forge-link');
- if (forgeLink) {
- if (s.forge_url) {
- forgeLink.href = s.forge_url;
- forgeLink.style.display = '';
- } else {
- forgeLink.style.display = 'none';
- }
+ // Render server-supplied navigation links (stats, screen, forge, extras).
+ 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('');
}
renderTermInput(s.label, s.status === 'online');
renderInbox(s.inbox || []);
diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html
index c38ef60..8721ef9 100644
--- a/hive-ag3nt/assets/index.html
+++ b/hive-ag3nt/assets/index.html
@@ -12,14 +12,7 @@
◆ … ◆
-
- 📊 stats →
- 🖥 screen →
- ⬡ forge ↗
-
+
loading…
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index e49ab10..53e3148 100644
--- a/hive-ag3nt/src/web_ui.rs
+++ b/hive-ag3nt/src/web_ui.rs
@@ -379,15 +379,21 @@ struct StateSnapshot {
/// Cumulative token usage across the most recent turn's inferences
/// (cost signal). `null` until the first turn finishes.
cost_usage: Option
,
- /// Whether the weston VNC compositor is configured for this agent
- /// (i.e. `/etc/hyperhive/gui.json` was present at harness startup).
- /// When true, the UI may render a `🖥 screen` link to `/screen`.
- gui_enabled: bool,
- /// Full URL of this agent's Forgejo profile, or `null` when the
- /// agent has no forge account (`forge-token` absent). Assembled
- /// server-side from the request `Host` header so the UI just
- /// links it — no client-side URL construction.
- forge_url: 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.
+ links: Vec,
+}
+
+/// One navigation link in the agent page header row.
+#[derive(Serialize)]
+struct AgentLink {
+ url: String,
+ icon: String,
+ label: String,
}
#[derive(Serialize)]
@@ -489,27 +495,44 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J
context_window_tokens,
ctx_usage,
cost_usage,
- gui_enabled: state.gui_vnc_port.is_some(),
- forge_url: forge_profile_url(&headers, &state.label),
+ links: agent_links(&headers, &state.label, state.gui_vnc_port.is_some()),
})
}
-/// This agent's Forgejo profile URL — `Some` only when the agent has a
-/// forge account (its `forge-token` file exists). The host is taken
-/// from the request `Host` header so the link resolves against
-/// whatever host the operator reached the page on; the forge always
-/// listens on `:3000`, and the per-agent forge user is named after
-/// the agent (`label`).
-fn forge_profile_url(headers: &HeaderMap, label: &str) -> Option {
- if !crate::paths::state_dir().join("forge-token").is_file() {
- return None;
+/// Build the ordered list of navigation links for the agent page header.
+/// Stats is always present. Screen is included when the VNC compositor
+/// is enabled. Forge is included when the agent has a forge account.
+fn agent_links(headers: &HeaderMap, label: &str, gui_enabled: bool) -> Vec {
+ let mut links = Vec::new();
+
+ links.push(AgentLink {
+ url: "/stats".to_owned(),
+ icon: "📊".to_owned(),
+ label: "stats".to_owned(),
+ });
+
+ if gui_enabled {
+ links.push(AgentLink {
+ url: "/screen".to_owned(),
+ icon: "🖥".to_owned(),
+ label: "screen".to_owned(),
+ });
}
- let host = headers
- .get("host")
- .and_then(|h| h.to_str().ok())
- .unwrap_or("localhost");
- let hostname = host.split(':').next().unwrap_or(host);
- Some(format!("http://{hostname}:3000/{label}"))
+
+ if crate::paths::state_dir().join("forge-token").is_file() {
+ let host = headers
+ .get("host")
+ .and_then(|h| h.to_str().ok())
+ .unwrap_or("localhost");
+ let hostname = host.split(':').next().unwrap_or(host);
+ links.push(AgentLink {
+ url: format!("http://{hostname}:3000/{label}"),
+ icon: "⬡".to_owned(),
+ label: "forge".to_owned(),
+ });
+ }
+
+ links
}
/// Best-effort: pull the last 30 messages addressed to us via the