agent: meta-nav, favicon fallback, document.title
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 <MetaNav> 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 <img>). 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.
This commit is contained in:
parent
b56696d898
commit
df78236ce4
4 changed files with 102 additions and 4 deletions
|
|
@ -4,9 +4,10 @@
|
||||||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
||||||
// no credentials has no other web-UI path back online, so this isn't
|
// no credentials has no other web-UI path back online, so this isn't
|
||||||
// optional polish.
|
// optional polish.
|
||||||
import { useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import type { BadgeTone } from '@hive/shared/badge.js';
|
import type { BadgeTone } from '@hive/shared/badge.js';
|
||||||
import { Header } from './components/Header.js';
|
import { Header } from './components/Header.js';
|
||||||
|
import { MetaNav } from './components/MetaNav.js';
|
||||||
import { StatusChips } from './components/StatusChips.js';
|
import { StatusChips } from './components/StatusChips.js';
|
||||||
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
|
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
|
||||||
import { HeaderPill } from './components/HeaderPill.js';
|
import { HeaderPill } from './components/HeaderPill.js';
|
||||||
|
|
@ -57,6 +58,22 @@ export function Root() {
|
||||||
const { todos, refresh: refreshTodos } = useTodos();
|
const { todos, refresh: refreshTodos } = useTodos();
|
||||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||||
const liveStreamRef = useRef<LiveStreamHandle>(null);
|
const liveStreamRef = useRef<LiveStreamHandle>(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 <title> 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 = (
|
const termInput = (
|
||||||
<footer class="agent-composer">
|
<footer class="agent-composer">
|
||||||
<TermInput
|
<TermInput
|
||||||
|
|
@ -140,7 +157,12 @@ export function Root() {
|
||||||
|
|
||||||
return (
|
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
|
<StatusChips
|
||||||
aliveLabel={`${alive.glyph} ${alive.text}`}
|
aliveLabel={`${alive.glyph} ${alive.text}`}
|
||||||
aliveTone={alive.tone}
|
aliveTone={alive.tone}
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,41 @@
|
||||||
// stylesheet — no new visual language needed for the chrome itself.
|
// stylesheet — no new visual language needed for the chrome itself.
|
||||||
import type { ComponentChildren } from 'preact';
|
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 {
|
export interface HeaderProps {
|
||||||
label: string;
|
label: string;
|
||||||
hiveLabel?: string | null;
|
hiveLabel?: string | null;
|
||||||
children?: ComponentChildren;
|
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
|
/** Right-cluster flyout triggers (inbox/todos pills today, the
|
||||||
* overflow menu button lands here in a later commit) — mirrors the
|
* overflow menu button lands here in a later commit) — mirrors the
|
||||||
* old markup's `.agent-header-pills` third column. */
|
* old markup's `.agent-header-pills` third column. */
|
||||||
pills?: ComponentChildren;
|
pills?: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
export function Header({ label, hiveLabel, children, nav, pills }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header class="agent-header">
|
<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-main">
|
||||||
<div class="agent-header-row agent-header-title-row">
|
<div class="agent-header-row agent-header-title-row">
|
||||||
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
||||||
|
{nav}
|
||||||
</div>
|
</div>
|
||||||
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
||||||
<div class="agent-header-row">{children}</div>
|
<div class="agent-header-row">{children}</div>
|
||||||
|
|
|
||||||
44
frontend/packages/agent/src/components/MetaNav.tsx
Normal file
44
frontend/packages/agent/src/components/MetaNav.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,20 @@ export interface AgentState {
|
||||||
available_efforts: string[];
|
available_efforts: string[];
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
inbox: InboxRow[];
|
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
|
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue