agent page: consolidate alive/thinking/paused into one status badge

mara, #3757: fold the separate alive badge into the turn-state badge,
with pause/resume + cancel-turn moved into its dropdown. Cancel-turn is
click-again-to-confirm, not a modal.

model/effort/ctx/cost stay as separate badges — not asked to fold
those in too.
This commit is contained in:
iris 2026-08-30 13:45:40 +02:00 committed by mara
commit 1e4399d49e
5 changed files with 135 additions and 71 deletions

View file

@ -1,9 +1,10 @@
// <StatusChips> — the agent page's status row: alive/turn-state, the
// model + effort pickers, context/cost usage, and pause/resume. 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
// <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
@ -15,11 +16,9 @@ import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import './StatusChips.css';
export interface StatusChipsProps {
aliveLabel: string;
aliveTone: BadgeTone;
stateLabel: string;
stateTone: BadgeTone;
stateTooltip?: string;
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
@ -92,12 +91,79 @@ function Picker({
);
}
// 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);
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">
<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}
/>
</div>
);
}
export function StatusChips({
aliveLabel,
aliveTone,
stateLabel,
stateTone,
stateTooltip,
statusLabel,
statusTone,
statusTooltip,
model,
resolvedModel,
availableModels,
@ -113,18 +179,17 @@ export function StatusChips({
thinking,
onCancelTurn,
}: StatusChipsProps) {
// `.finally()` on the caller's promise (not a `thinking`-flip effect):
// re-enables the button once the request actually settles, success or
// failure alike — ported from app.js's original
// `postCancelTurn().finally(() => { btn.disabled = false; })`. A
// `thinking`-only reset would leave the button stuck disabled on a
// failed request (argus, PR review): the turn is still in flight, so
// `thinking` never flips to trigger a reset.
const [cancelBusy, setCancelBusy] = useState(false);
return (
<div class="status-chips">
<Badge value={aliveLabel} tone={aliveTone} />
<Badge value={stateLabel} tone={stateTone} title={stateTooltip} />
<StatusMenu
statusLabel={statusLabel}
statusTone={statusTone}
statusTooltip={statusTooltip}
paused={paused}
onTogglePause={onTogglePause}
thinking={thinking}
onCancelTurn={onCancelTurn}
/>
<Picker
label="model"
value={model}
@ -156,25 +221,7 @@ export function StatusChips({
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
/>
) : null}
<Badge
value={paused ? '▶ resume' : '⏸ pause'}
tone={paused ? 'warning' : 'neutral'}
onClick={onTogglePause}
/>
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
{thinking ? (
<button
type="button"
class="btn-cancel-turn"
disabled={cancelBusy}
onClick={() => {
setCancelBusy(true);
onCancelTurn().finally(() => setCancelBusy(false));
}}
>
cancel turn
</button>
) : null}
</div>
);
}