From 48a1745bb59d6ff047a679c55b5e227b53c87d0a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 02:59:35 +0200 Subject: [PATCH] =?UTF-8?q?agent:=20OverflowMenu=20=E2=80=94=20trimmed=20t?= =?UTF-8?q?o=20rebuild=20+=20dashboard=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/agent/src/Root.tsx | 5 ++ .../agent/src/components/OverflowMenu.css | 19 ++++++ .../agent/src/components/OverflowMenu.tsx | 66 +++++++++++++++++++ .../packages/agent/src/lib/rebuildAction.ts | 11 ++++ 4 files changed, 101 insertions(+) create mode 100644 frontend/packages/agent/src/components/OverflowMenu.css create mode 100644 frontend/packages/agent/src/components/OverflowMenu.tsx create mode 100644 frontend/packages/agent/src/lib/rebuildAction.ts diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index ede56cd3..7126b129 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -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() { <> setOpenPanel('inbox')} /> setOpenPanel('todos')} /> + {/* Needs dashboard_port from a loaded snapshot — omitted (like the + * old page's populateOverflowMenu) until the first /api/state + * resolves. */} + {state ? : null} ); // Kept mounted regardless of `openPanel` (open/closed toggles just the diff --git a/frontend/packages/agent/src/components/OverflowMenu.css b/frontend/packages/agent/src/components/OverflowMenu.css new file mode 100644 index 00000000..987f14ae --- /dev/null +++ b/frontend/packages/agent/src/components/OverflowMenu.css @@ -0,0 +1,19 @@ +/* Anchors 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; +} diff --git a/frontend/packages/agent/src/components/OverflowMenu.tsx b/frontend/packages/agent/src/components/OverflowMenu.tsx new file mode 100644 index 00000000..d698cb53 --- /dev/null +++ b/frontend/packages/agent/src/components/OverflowMenu.tsx @@ -0,0 +1,66 @@ +// — 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 ( +
+ setOpen((o) => !o)} expanded={open} /> + +
+ ); +} diff --git a/frontend/packages/agent/src/lib/rebuildAction.ts b/frontend/packages/agent/src/lib/rebuildAction.ts new file mode 100644 index 00000000..448dea06 --- /dev/null +++ b/frontend/packages/agent/src/lib/rebuildAction.ts @@ -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(); +}