Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/agent/src/components/StatusChips.tsx
iris b1254f89cd shared dropdown: don't reopen when the trigger is clicked while open
mara, #3775: clicking a badge with its own dropdown open reopens it
instead of closing it. Root cause: the outside-click listener only
excludes the dropdown's own ref, not the sibling trigger that opened
it — a click on the trigger closes via that listener (pointerdown
fires first), then the trigger's own onClick toggle fires straight
after and reopens it, since its closure reads the pre-close state.

Dropdown now takes an optional anchorRef (the trigger's wrapper,
which callers already have for CSS positioning) and excludes it from
the outside-click check too — the same shape MetaNav's own hand-rolled
popover already uses correctly. Wired into StatusChips's Picker +
StatusMenu and swarm-ui's ComponentsPage demo (the only three Dropdown
consumers).

Verified two ways: reverted the fix, rebuilt, confirmed the bug
reproduces via a raw-CDP interaction test (two real clicks dispatched
through headless chromium, not just a static screenshot); restored
the fix, rebuilt, confirmed it passes.
2026-08-30 15:29:59 +02:00

231 lines
6.6 KiB
TypeScript

// <StatusChips> — the agent page's status row: the consolidated status
// badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in
// its dropdown), the model + effort pickers, and context/cost usage.
// This is the concrete fix for the design guide's own named anti-example
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu
// while the current model/effort only showed as a disconnected chip) —
// every badge here IS the control for what it displays, composed from
// `@hive/shared`'s `Badge`/`Dropdown` (../../shared/src/badge,
// ../../shared/src/dropdown). Presentational only: formatting +
// selection state live in the caller (`Root.tsx`, via the
// `useAgentState` hook), so this component can still be demoed and
// reviewed against plain sample data independent of live `/api/state`.
import { useRef, useState } from 'preact/hooks';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import './StatusChips.css';
export interface StatusChipsProps {
statusLabel: string;
statusTone: BadgeTone;
statusTooltip?: string;
model: string;
/** Concrete model id the last completed turn actually ran on, e.g.
* "claude-sonnet-4-5-20260805" — shown as the badge's tooltip so the
* operator can tell what an alias resolved to. `undefined` before any
* turn has completed. */
resolvedModel?: string;
availableModels: string[];
onSelectModel: (name: string) => void;
effort: string;
availableEfforts: string[];
onSelectEffort: (level: string) => void;
ctxLabel?: string;
costLabel?: string;
paused: boolean;
onTogglePause: () => void;
lastTurnLabel?: string;
thinking: boolean;
onCancelTurn: () => Promise<void>;
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: 'fast',
sonnet: 'balanced',
opus: 'powerful',
};
const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: '',
medium: 'default',
high: '',
xhigh: '',
max: '',
};
function Picker({
label,
value,
options,
descriptions,
onSelect,
title,
}: {
label: string;
value: string;
options: string[];
descriptions: Record<string, string>;
onSelect: (v: string) => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const dropdownOptions: DropdownOption[] = options.map((name) => ({
value: name,
label: name,
description: descriptions[name] || undefined,
}));
return (
<div class="status-chip-anchor" ref={anchorRef}>
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} title={title} />
<Dropdown
open={open}
options={dropdownOptions}
activeValue={value}
label={`select ${label}`}
onSelect={(v) => {
onSelect(v);
setOpen(false);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}
// The consolidated alive/thinking/paused badge (mara: "why do we
// have separate alive badge? ... it could be one badge that shows
// thinking / idle / paused / ... with a dropdown that opens when
// clicked to pause/unpause or cancel turn"). Cancel-turn is a
// click-again-to-confirm row rather than a native confirm dialog (mara:
// "make the menu entry a 'click again to confirm'") — armed state resets
// whenever the dropdown closes, so a stray reopen never fires it early.
function StatusMenu({
statusLabel,
statusTone,
statusTooltip,
paused,
onTogglePause,
thinking,
onCancelTurn,
}: Pick<
StatusChipsProps,
'statusLabel' | 'statusTone' | 'statusTooltip' | 'paused' | 'onTogglePause' | 'thinking' | 'onCancelTurn'
>) {
const [open, setOpen] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
function close() {
setOpen(false);
setConfirmCancel(false);
}
const options: DropdownOption[] = [{ value: 'toggle-pause', label: paused ? '▶ resume' : '⏸ pause' }];
if (thinking) {
options.push({
value: 'cancel-turn',
label: confirmCancel ? '■ click again to confirm' : '■ cancel turn',
danger: true,
});
}
return (
<div class="status-chip-anchor" ref={anchorRef}>
<Badge
value={statusLabel}
tone={statusTone}
title={statusTooltip}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
<Dropdown
open={open}
options={options}
label="agent status"
onSelect={(value) => {
if (value === 'toggle-pause') {
onTogglePause();
close();
return;
}
// value === 'cancel-turn': first click arms it, second fires.
if (!confirmCancel) {
setConfirmCancel(true);
return;
}
onCancelTurn();
close();
}}
onClose={close}
anchorRef={anchorRef}
/>
</div>
);
}
export function StatusChips({
statusLabel,
statusTone,
statusTooltip,
model,
resolvedModel,
availableModels,
onSelectModel,
effort,
availableEfforts,
onSelectEffort,
ctxLabel,
costLabel,
paused,
onTogglePause,
lastTurnLabel,
thinking,
onCancelTurn,
}: StatusChipsProps) {
return (
<div class="status-chips">
<StatusMenu
statusLabel={statusLabel}
statusTone={statusTone}
statusTooltip={statusTooltip}
paused={paused}
onTogglePause={onTogglePause}
thinking={thinking}
onCancelTurn={onCancelTurn}
/>
<Picker
label="model"
value={model}
options={availableModels}
descriptions={MODEL_DESCRIPTIONS}
onSelect={onSelectModel}
title={resolvedModel ? `resolved to ${resolvedModel}` : undefined}
/>
{availableEfforts.length ? (
<Picker
label="effort"
value={effort}
options={availableEfforts}
descriptions={EFFORT_DESCRIPTIONS}
onSelect={onSelectEffort}
/>
) : null}
{ctxLabel ? (
<Badge
label="ctx"
value={ctxLabel}
title="tokens used in the current context window"
/>
) : null}
{costLabel ? (
<Badge
label="cost"
value={costLabel}
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
/>
) : null}
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
</div>
);
}