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