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.
231 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|