hyperhive/frontend/packages/agent/src/components/Header.tsx
iris d7e70fd195 agent: badges join the pills cluster, not the other way around
mara, live, correcting the prior round: 'oh i meant move badges to
where the dropdowns are not the other way around. this way the height
does not change even on widescreen.'

Prior commit nested pills INTO a new row inside .agent-header-main
(badges + pills sharing a row under the title). Backwards from what
she wanted: badges now join .agent-header-pills instead — the same
header-level column pills already lived in, which already handles a
variable child count via flex-wrap + justify-content: flex-end.
.agent-header-main goes back to just the title + hive-label rows,
which don't wrap in practice, so its height stays stable regardless of
how many badges/pills are showing.

The ResizeObserver dynamic-height fix from the prior commit is
untouched and still does the real work of keeping .agent-status-
overlay's offset correct if anything does wrap — this is a pure
layout-preference change on top of that, not a new bugfix.

Verified at 500/1024/1400px — badges+pills form one right-aligned
cluster next to the title, no overlap, no ResizeObserver console
error. tsc --noEmit clean, build clean, both pre-push lints clean.
2026-08-28 22:47:49 +02:00

99 lines
4.7 KiB
TypeScript

// <Header> — the agent page's identity strip: icon, glyphic title, the
// "swarm / hive" label row, and a slot for the status row
// (`<StatusChips>` — ../status-chips/, kept separate so this component
// has no opinion on what badges exist). Same three-column shape as the
// old `#agent-header` markup (icon · main · pills) — see the `pills`
// prop doc below for what moved into that third column and why.
// Reuses `agent.css`'s existing `.agent-header*`/`.agent-icon` rules
// (loaded globally by index.html) rather than a component-scoped
// stylesheet — no new visual language needed for the chrome itself.
// Measures its own rendered height via ResizeObserver, writes it to
// `--agent-header-real-h` (agent.css's downstream consumers —
// `.agent-status-overlay`, `.agent-main`'s scroll padding — prefer
// this over the static `--agent-header-h` guess). A static guess was
// the root cause of two "login card behind the header" reports on
// this PR (mara, live): any header content that can wrap an extra
// line breaks the guess at whatever width triggers it, and no width
// can be promised safe. Deliberately a SEPARATE var, not an overwrite
// of `--agent-header-h` in place: `.agent-header`'s own `min-height`
// also reads that var — overwriting it in place would make the
// header's own size react to its own just-measured height every
// tick, a real ResizeObserver feedback loop (confirmed live on the
// first pass of this fix). `--agent-header-h` stays the static floor;
// `--agent-header-real-h` is output-only.
import { useEffect, useRef } from 'preact/hooks';
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;
/** The status/badge row (`<StatusChips>`) — lives in the
* `.agent-header-pills` cluster now, alongside `pills` (see below),
* not stacked as its own row under the title. */
children?: ComponentChildren;
/** Flyout triggers (inbox/todos/links pills, the overflow menu
* button) — and, since this round, `children` (the status badges)
* too, both in `.agent-header-pills`, the SAME header-level column
* the old markup used for pills alone. First pass of this ask
* nested pills into a row inside `.agent-header-main` instead —
* backwards, mara corrected live: "i meant move badges to where the
* dropdowns are, not the other way around. this way the height does
* not change even on widescreen" — `.agent-header-main` now only
* ever holds the title + hive-label rows, which don't wrap in
* practice, so it stays a stable height; only this pills-cluster
* column (already used to absorbing a variable pill count) grows to
* fit badges+pills together. Meta-nav (`<MetaNav>`) lives in here
* too, as a fixed-size trigger — not the old markup's inline list in
* the title row (`<nav id="meta-links">`, docs/web-ui/agent.md). */
pills?: ComponentChildren;
}
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
// we want the full visual box padding+border included, the thing
// content below the fixed header actually needs to clear, not the
// content-box-only measurement ResizeObserver's entry defaults to.
const ro = new ResizeObserver(() => {
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<header class="agent-header" ref={ref}>
<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>
</div>
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
</div>
{children || pills ? (
<div class="agent-header-pills">
{children}
{pills}
</div>
) : null}
</header>
);
}