agent: OverflowMenu — trimmed to rebuild + dashboard link

Deliberately trimmed vs. app.js's `populateOverflowMenu`, which held
dashboard-link/rebuild/new-session/logout/model-picker/effort-picker
all in one flat list — the design guide's own named junk-drawer
anti-example. Model/effort already moved to StatusChips' own
Badge+Dropdown controls; new-session/logout now live as TermInput
slash commands (typed, with their own two-step confirm). What's left —
rebuild + a dashboard back-link — is genuinely menu-shaped (rare, not
tied to any other visible control), so it's still a `⋯` menu, just a
much shorter one.

Reuses `@hive/shared`'s Badge+Dropdown pair (same anchored-popover
shape as StatusChips' model/effort pickers), not a bespoke popover.
Rebuild's confirm is the same "select once to arm, select again to
fire" idea as the slash commands, adapted to a menu click. New
`lib/rebuildAction.ts` — same cross-origin-needs-a-real-form-submit
reasoning as pauseAction.ts (hive-c0re's `/api/rebuild/{name}` has no
CORS header either).

Found + fixed a real layout gap in the shared Dropdown while
screenshot-verifying: its CSS anchors left-edge-to-left-edge, correct
for a picker with room to its right, but the overflow trigger is the
header's right-most element, so the menu ran off-screen. Fixed with a
scoped override in this component's own CSS (`.header-overflow-anchor
.ui-dropdown { left: auto; right: 0; }`) rather than changing the
shared default, which is still correct for every other caller.
This commit is contained in:
iris 2026-08-28 02:59:35 +02:00
commit 48a1745bb5
4 changed files with 101 additions and 0 deletions

View file

@ -12,6 +12,7 @@ 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 { useAgentState } from './hooks/useAgentState.js';
import { useTodos } from './hooks/useTodos.js';
import { fmtAge, fmtTokens } from './lib/format.js';
@ -68,6 +69,10 @@ 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 dashboard_port from a loaded snapshot omitted (like the
* old page's populateOverflowMenu) until the first /api/state
* resolves. */}
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
</>
);
// Kept mounted regardless of `openPanel` (open/closed toggles just the

View file

@ -0,0 +1,19 @@
/* 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

@ -0,0 +1,66 @@
// <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

@ -0,0 +1,11 @@
// 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();
}