agent: remove rebuild button, move dashboard link into the links menu

mara (#3704): 'remove rebuild button, move link to dashboards into
links menu.'

The overflow (⋯) menu existed for exactly two items: the dashboard
back-link and a rebuild-container action. Rebuild is gone outright —
the dashboard's own R3BU1LD button already covers it, this was just a
rarely-used shortcut not worth its own menu. The dashboard link moves
into MetaNav's links popover (now the first item, above stats/forge/
config/extras) instead. With both gone, OverflowMenu had nothing left
to justify existing as a separate component — deleted along with its
CSS and the now-unused rebuildAction.ts (only consumer).

MetaNav gained a dashboardBase prop (Root.tsx already computes this
via resolveDashboardBase for InboxPanel/pause — reused, not
duplicated) and renders the dashboard link as a real <a>, same
treatment as every other item in that popover — no dangling
window.open()-only affordance.

Updated docs/web-ui/agent.md's Header section and the couple of
now-stale OverflowMenu references in index.html's/MetaNav.css's own
comments.

Verified: header now shows a single trailing icon-badge (was two),
popover opens with dashboard first then the agent_links() set.
tsc --noEmit clean, build clean, both pre-push lints clean.
This commit is contained in:
iris 2026-08-28 23:40:56 +02:00
commit 668ccc2278
8 changed files with 71 additions and 143 deletions

View file

@ -15,7 +15,6 @@ import { SidePanel } from './components/SidePanel.js';
import { InboxPanel } from './components/InboxPanel.js';
import { TodosPanel } from './components/TodosPanel.js';
import { TermInput } from './components/TermInput.js';
import { OverflowMenu } from './components/OverflowMenu.js';
import { LoginFlow } from './components/LoginFlow.js';
import { useAgentState } from './hooks/useAgentState.js';
import { useTodos } from './hooks/useTodos.js';
@ -89,11 +88,18 @@ export function Root() {
<>
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
{/* Needs a loaded snapshot for their data (links/forge_public_url,
* dashboard_port) both omitted (like the old page's
* populateOverflowMenu) until the first /api/state resolves. */}
{state ? <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} /> : null}
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
{/* Needs a loaded snapshot for its data (links/forge_public_url,
* dashboard_port) omitted until the first /api/state resolves,
* same as the old page's populateOverflowMenu (now deleted
* dashboard-link moved here, rebuild removed outright, see
* MetaNav.tsx's file comment). */}
{state ? (
<MetaNav
links={state.links}
forgePublicUrl={state.forge_public_url}
dashboardBase={resolveDashboardBase(state.dashboard_port)}
/>
) : null}
</>
);
// Kept mounted regardless of `openPanel` (open/closed toggles just the

View file

@ -3,10 +3,8 @@
same "reuse the values, not the component" call `.login-card`
(LoginFlow.css) already made, for the same reason: this popover's
items are real `<a>` tags, not `Dropdown`'s command-dispatch
`<button>`s. Right-anchored, same as OverflowMenu.css's
`.header-overflow-anchor` MetaNav sits in the same right-hand
pills cluster, so a left anchor would push it off-screen the same
way. */
`<button>`s. Right-anchored MetaNav sits in the header's right-hand
pills cluster, so a left anchor would push it off-screen. */
.meta-nav-anchor {
position: relative;
display: inline-block;

View file

@ -2,26 +2,27 @@
// ("🔗") in the pills cluster that opens a popover listing
// 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).
//
// truth — same list also feeds the dashboard card's icon strip) — plus
// a `↑ dashboard` back-link, formerly the overflow menu's job (mara:
// "remove rebuild button, move link to dashboards into links menu" —
// the overflow `⋯` trigger existed for exactly two items, dashboard-
// link and rebuild; rebuild's gone outright, the dashboard's own
// R3BU1LD button already covers it, dashboard-link moves here, so
// `OverflowMenu` had nothing left to justify existing and is deleted).
// v1 of this rendered every link inline in the header's title row —
// mara, live: "the login card looks like it is behind the header"
// (a variable-width link list can grow the title row past the fixed
// `--agent-header-h` the rest of the page is offset against — see
// Header.tsx's comment) and "the links should have the same popout as
// the links in the nav bar of swarm-ui." This is that: same shape as
// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon
// trigger, a popover on click, real `<a>` items (not a command-dispatch
// button list) so ctrl/middle-click and "copy link address" keep
// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only,
// same shape as the header's `⋯` overflow trigger — same touch target,
// same hover/expanded treatment, one visual language for every header
// trigger) rather than a bespoke button. The popover isn't built from
// `@hive/shared`'s `Dropdown` — its items are always `<button>`s
// (command dispatch), which would lose real link semantics — but
// matches its `.ui-dropdown` visual values exactly (see MetaNav.css),
// the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` already made for the same reason.
// the links in the nav bar of swarm-ui." This is that: one quiet icon
// trigger (reuses `@hive/shared`'s `Badge`, icon-only), a popover on
// click, real `<a>` items — not `@hive/shared`'s `Dropdown` (its items
// are always `<button>`s for command dispatch, which would lose real
// link semantics like ctrl/middle-click and "copy link address") —
// but matching its `.ui-dropdown` visual values exactly (see
// MetaNav.css), the same "reuse the values, not the component" call
// `LoginFlow`'s `.login-card` already made for the same reason.
import { useEffect, useRef, useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import type { AgentLink } from '../types.js';
@ -30,9 +31,12 @@ import './MetaNav.css';
export interface MetaNavProps {
links: AgentLink[];
forgePublicUrl: string | null;
/** Absolute base URL of the host dashboard (`resolveDashboardBase`)
* the `↑ dashboard` item links to `${dashboardBase}dashboard.html`. */
dashboardBase: string;
}
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@ -58,13 +62,24 @@ export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
// entirely (never guessed from `<host>:3000`); `external` is already
// absolute; `container` is a same-origin path.
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
if (visible.length === 0) return null;
// No early-return-on-empty any more — the dashboard link below is
// always present, so the trigger always has at least one item.
return (
<div class="meta-nav-anchor" ref={rootRef}>
<Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} />
{open ? (
<div class="meta-nav-popover" role="menu" aria-label="agent links">
<a
class="meta-nav-item"
href={`${dashboardBase}dashboard.html`}
target="_blank"
rel="noopener"
role="menuitem"
onClick={() => setOpen(false)}
>
dashboard
</a>
{visible.map((lnk) => {
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
return (

View file

@ -1,19 +0,0 @@
/* Anchors <Dropdown> under the "⋯" trigger same positioning
contract as StatusChips.css's `.status-chip-anchor`, kept as its own
rule here rather than reused across components (see this file's
sibling .tsx comment) so OverflowMenu doesn't depend on StatusChips'
incidental CSS. */
.header-overflow-anchor {
position: relative;
display: inline-block;
}
/* @hive/shared's Dropdown.css anchors left-edge-to-left-edge, correct
for a picker with room to its right (StatusChips' model/effort). The
overflow trigger is the header's right-most element instead, so a
left anchor pushes the menu off-screen flip to right-edge-to-
right-edge here, scoped to this one consumer rather than changing
the shared default (which is still correct for every other caller). */
.header-overflow-anchor .ui-dropdown {
left: auto;
right: 0;
}

View file

@ -1,66 +0,0 @@
// <OverflowMenu> — the header's `⋯` trigger. Deliberately trimmed vs.
// app.js's `populateOverflowMenu`: that one held dashboard-link/rebuild/
// new-session/logout/model-picker/effort-picker all in one flat list —
// the design guide's own named anti-example for a junk drawer. The
// model/effort pickers already moved to StatusChips' own Badge+Dropdown
// controls (this rewrite's first commit); new-session/logout now live
// as TermInput slash commands (typed, not menu-clicked, and already
// have their own two-step confirm). What's left here — rebuild + a
// dashboard back-link — are genuinely menu-shaped (rare, not tied to
// any other visible control), so they're still a `⋯` menu, just a much
// shorter one.
//
// Reuses `@hive/shared`'s `Badge`+`Dropdown` — same anchored-popover
// pair StatusChips' model/effort pickers use, not a bespoke popover.
// Rebuild's confirm is the same "select once to arm, select again to
// fire" pattern as TermInput's `/new-session`/`/logout` — adapted here
// to a menu click instead of a typed repeat, but the same "requires
// deliberate repetition, not a modal" idea.
import { useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import { submitRebuild } from '../lib/rebuildAction.js';
import './OverflowMenu.css';
export interface OverflowMenuProps {
label: string;
dashboardBase: string;
}
export function OverflowMenu({ label, dashboardBase }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const [armed, setArmed] = useState(false);
function close() {
setOpen(false);
setArmed(false);
}
const options: DropdownOption[] = [
{ value: 'dashboard', label: '↑ dashboard' },
{ value: 'rebuild', label: armed ? '↻ rebuild — click again to confirm' : '↻ rebuild container' },
];
function onSelect(value: string) {
if (value === 'dashboard') {
window.open(`${dashboardBase}dashboard.html`, '_blank', 'noopener');
close();
return;
}
if (value === 'rebuild') {
if (armed) {
submitRebuild(dashboardBase, label);
close();
} else {
setArmed(true);
}
}
}
return (
<div class="header-overflow-anchor">
<Badge value="⋯" title="more actions" onClick={() => setOpen((o) => !o)} expanded={open} />
<Dropdown open={open} options={options} label="more actions" onSelect={onSelect} onClose={close} />
</div>
);
}

View file

@ -9,7 +9,7 @@
<link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css">
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
StatusChips/OverflowMenu/SidePanel/...) MUST load after agent.css —
StatusChips/SidePanel/...) MUST load after agent.css —
`.login-card`/`.meta-nav-popover`/etc. share class-selector
specificity with agent.css's legacy rules, so load order is what
decides which one wins the cascade (see LoginFlow.css/MetaNav.css

View file

@ -1,11 +0,0 @@
// Rebuild POST to the *dashboard's* origin — same cross-origin-needs-a-
// real-form-submit reasoning as pauseAction.ts's `submitPauseResume`
// (hive-c0re's `/api/rebuild/{name}` returns a plain 200, no CORS
// header, so a cross-origin `fetch` can't read the result).
export function submitRebuild(dashboardBase: string, label: string): void {
const form = document.createElement('form');
form.method = 'POST';
form.action = `${dashboardBase}api/rebuild/${label}`;
document.body.appendChild(form);
form.submit();
}