From df78236ce449d0615354e09481011c54b7c3321d Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 20:54:11 +0200 Subject: [PATCH] agent: meta-nav, favicon fallback, document.title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the 3 remaining gaps argus's lost-functionality audit found beyond login: the header meta-nav strip (stats/screen/forge/config + hyperhive.dashboardLinks extras — the only on-page path to those, not just polish), the header icon's /favicon.svg fallback on a broken image, and the browser tab title update. All three are straight ports of app.js's existing logic (refreshState's meta-links loop, bindHeaderIconFallback, setHeader's document.title block), same kind -> URL resolution rules, same dataset.fallback loop guard, same qualified_label/hive_name fallback chain — no new backend fields needed, hive_agent::web_ui::state::StateSnapshot already serves links/forge_public_url and useAgentState's whole-payload cast already threads them to Root. New component (reuses agent.css's existing .agent-nav/ .agent-nav-link rules verbatim, no new CSS) renders in Header's title row via a new nav prop. Favicon fallback lives in Header itself (onError handler on the .agent-icon ). document.title is a useEffect in Root keyed on label/qualified_label/hive_name. tsc --noEmit clean, build clean, both pre-push lints clean. Screenshot verifies meta-nav renders 4 links (stats/forge/config/extra, forge kind resolving against a mocked forge_public_url) and the favicon fallback firing against a deliberately-404'd /icon. --- frontend/packages/agent/src/Root.tsx | 26 ++++++++++- .../packages/agent/src/components/Header.tsx | 22 +++++++++- .../packages/agent/src/components/MetaNav.tsx | 44 +++++++++++++++++++ frontend/packages/agent/src/types.ts | 14 ++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 frontend/packages/agent/src/components/MetaNav.tsx diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index d6a7249f..148fd9fc 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -4,9 +4,10 @@ // `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with // no credentials has no other web-UI path back online, so this isn't // optional polish. -import { useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import type { BadgeTone } from '@hive/shared/badge.js'; import { Header } from './components/Header.js'; +import { MetaNav } from './components/MetaNav.js'; import { StatusChips } from './components/StatusChips.js'; import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js'; import { HeaderPill } from './components/HeaderPill.js'; @@ -57,6 +58,22 @@ export function Root() { const { todos, refresh: refreshTodos } = useTodos(); const [openPanel, setOpenPanel] = useState(null); const liveStreamRef = useRef(null); + + // Browser tab title — ports app.js's setHeader logic verbatim: prefer + // the human display name + hive_name ("iris // pr1ma") so tabs read + // naturally; fall back to the qualified domain label for multi-hive + // disambiguation when hive_name is unset, then to plain label for + // single-hive deploys. Without this every agent's tab reads the + // static index.html forever — can't tell tabs apart with + // several open. + useEffect(() => { + if (!state) return; + const tab = + state.qualified_label && state.qualified_label !== state.label + ? state.qualified_label + : state.label; + document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`; + }, [state?.label, state?.qualified_label, state?.hive_name]); const termInput = ( <footer class="agent-composer"> <TermInput @@ -140,7 +157,12 @@ export function Root() { return ( <> - <Header label={state.label} hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null} pills={pills}> + <Header + label={state.label} + hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null} + nav={<MetaNav links={state.links} forgePublicUrl={state.forge_public_url} />} + pills={pills} + > <StatusChips aliveLabel={`${alive.glyph} ${alive.text}`} aliveTone={alive.tone} diff --git a/frontend/packages/agent/src/components/Header.tsx b/frontend/packages/agent/src/components/Header.tsx index ad85b2d9..518aceff 100644 --- a/frontend/packages/agent/src/components/Header.tsx +++ b/frontend/packages/agent/src/components/Header.tsx @@ -10,23 +10,41 @@ // stylesheet — no new visual language needed for the chrome itself. import type { ComponentChildren } from 'preact'; +// Icon 404s when the agent has no `hyperhive.icon` override (no +// bundled server-side default any more — see +// `hive_agent::web_ui::screen::serve_icon`). Ports app.js's +// `bindHeaderIconFallback` exactly: fire-and-forget load, swap to +// `/favicon.svg` on failure, `dataset.fallback` guards against a +// 404-on-the-fallback-itself loop. +function handleIconError(e: Event) { + const img = e.currentTarget as HTMLImageElement; + if (img.dataset.fallback) return; + img.dataset.fallback = '1'; + img.src = '/favicon.svg'; +} + export interface HeaderProps { label: string; hiveLabel?: string | null; children?: ComponentChildren; + /** Meta-nav links (stats/screen/forge/config/extras) — `<MetaNav>` + * lands here, in the title row alongside the `<h2>`, same as the old + * markup's `<nav id="meta-links">` (docs/web-ui/agent.md::Header). */ + nav?: ComponentChildren; /** Right-cluster flyout triggers (inbox/todos pills today, the * overflow menu button lands here in a later commit) — mirrors the * old markup's `.agent-header-pills` third column. */ pills?: ComponentChildren; } -export function Header({ label, hiveLabel, children, pills }: HeaderProps) { +export function Header({ label, hiveLabel, children, nav, pills }: HeaderProps) { return ( <header class="agent-header"> - <img class="agent-icon" src="icon" alt="" /> + <img class="agent-icon" src="icon" alt="" onError={handleIconError} /> <div class="agent-header-main"> <div class="agent-header-row agent-header-title-row"> <h2 class="agent-header-title">◆ {label} ◆</h2> + {nav} </div> {hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null} <div class="agent-header-row">{children}</div> diff --git a/frontend/packages/agent/src/components/MetaNav.tsx b/frontend/packages/agent/src/components/MetaNav.tsx new file mode 100644 index 00000000..6b92fd60 --- /dev/null +++ b/frontend/packages/agent/src/components/MetaNav.tsx @@ -0,0 +1,44 @@ +// <MetaNav> — the header's meta-nav strip (`<nav id="meta-links">` in +// the old markup): stats/screen/forge/config + any +// `hyperhive.dashboardLinks` extras, sourced from the backend's +// `agent_links()` (the single source of truth — same list also feeds +// the dashboard card's icon strip). See +// docs/web-ui/agent.md::Header for the kind → URL resolution table +// this ports verbatim from app.js's `refreshState` (lines ~1216-1244): +// `container` → same-origin path (this page is itself container-local); +// `forge` → `forgePublicUrl + url`, and the link is dropped entirely +// when `forgePublicUrl` is unset (never guessed from `<host>:3000`); +// `external` → already absolute. Plain JSX text (not `innerHTML`), so +// this is XSS-safe by construction the same way app.js's `el()`-built +// anchors were — no new escaping to get right. +import type { AgentLink } from '../types.js'; + +export interface MetaNavProps { + links: AgentLink[]; + forgePublicUrl: string | null; +} + +export function MetaNav({ links, forgePublicUrl }: MetaNavProps) { + const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl); + if (visible.length === 0) return null; + + return ( + <nav class="agent-nav" id="meta-links"> + {visible.map((lnk) => { + const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url; + return ( + <a + key={lnk.url} + class="agent-nav-link" + href={href} + target="_blank" + rel="noopener" + title={lnk.label} + > + {`${lnk.icon} ${lnk.label}`.trim()} → + </a> + ); + })} + </nav> + ); +} diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts index 28f631ea..3eb6388b 100644 --- a/frontend/packages/agent/src/types.ts +++ b/frontend/packages/agent/src/types.ts @@ -24,6 +24,20 @@ export interface AgentState { available_efforts: string[]; paused: boolean; inbox: InboxRow[]; + links: AgentLink[]; + forge_public_url: string | null; +} + +// Mirrors `hive_agent::web_ui::state::AgentLink` — one meta-nav entry +// in the agent page header (stats/screen/forge/config + any +// `hyperhive.dashboardLinks` extras). `agent_links()` on the backend +// is the single source of truth for what links an agent exposes; see +// docs/web-ui/agent.md::Header for the kind → URL resolution table. +export interface AgentLink { + url: string; + icon: string; + label: string; + kind: 'container' | 'forge' | 'external'; } // Mirrors `hive_agent::web_ui::state::SessionView` — populated only