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

@ -69,29 +69,34 @@ structurally rather than for one specific trigger. Two columns:
unprivileged process can't make directly).
- **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden
when empty; click opens the matching flyout in the side panel.
- **Links badge** (`🔗`): opens a popover listing this agent's
`agent_links()`-sourced navigation (stats, screen when VNC is
enabled, forge profile + config-repo mirror when the agent has a
forge account, any `hyperhive.dashboardLinks` extras) as real
`<a>` elements — not a `Dropdown`-style command list, so
ctrl/middle-click and "copy link address" keep working. Each
`AgentLink.kind` resolves differently: `container` → same-origin
path; `forge``state.forge_public_url + url`, and the link is
dropped entirely when that's unset (never guessed from
- **Links badge** (`🔗`): opens a popover listing `↑ dashboard`
(back-link to the host dashboard, `${dashboardBase}dashboard.html`)
followed by this agent's `agent_links()`-sourced navigation (stats,
screen when VNC is enabled, forge profile + config-repo mirror when
the agent has a forge account, any `hyperhive.dashboardLinks`
extras) — all as real `<a>` elements, not a `Dropdown`-style
command list, so ctrl/middle-click and "copy link address" keep
working. Each `AgentLink.kind` resolves differently: `container`
same-origin path; `forge``state.forge_public_url + url`, and the
link is dropped entirely when that's unset (never guessed from
`<host>:3000`); `external` → already absolute. Same source
(`GET /api/state`'s `links` field) also feeds
`DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for
both.
- **Overflow badge** (`⋯`): a `Dropdown` with two rows —
`↑ dashboard` (link) and `↻ rebuild container` (select-twice
confirm, same action as the dashboard R3BU1LD button). Everything
else that used to live here (model/effort pickers, new-session,
logout) has a better home now: pickers are real badges above, and
`/new-session` / `/logout` are typed slash commands with their own
type-twice confirm (see below) — a modal doesn't fit a text-input
flow, and burying rare-but-important actions in one flat menu was
the design guide's own named anti-example.
both. There is no separate overflow (`⋯`) menu any more — it used
to hold exactly this dashboard link plus a rebuild-container action
(mara, hyperhive#3704: "remove rebuild button, move link to
dashboards into links menu") — rebuild had no real discoverability
need of its own (the dashboard's own R3BU1LD button already covers
it) so it's gone outright, and the dashboard link moved here,
leaving nothing to justify a separate menu. Everything else that
used to live in the old overflow menu (model/effort pickers,
new-session, logout) already had a better home before this: pickers
are real badges above, and `/new-session` / `/logout` are typed
slash commands with their own type-twice confirm (see below) — a
modal doesn't fit a text-input flow, and burying rare-but-important
actions in one flat menu was the design guide's own named
anti-example.
- No header cancel-turn button any more — `/cancel` (slash command,
below) is the only path; the turn-loop state badge already shows
`thinking` as the discoverability cue.

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();
}