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

@ -142,10 +142,8 @@ export function Root() {
<>
<Header label="…" pills={pills}>
<StatusChips
aliveLabel="… connecting"
aliveTone="neutral"
stateLabel="… booting"
stateTone="neutral"
statusLabel="… connecting"
statusTone="neutral"
model=""
availableModels={[]}
onSelectModel={() => {}}
@ -175,6 +173,24 @@ export function Root() {
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
const stateAge = fmtAge(Date.now() - state.turn_state_since * 1000);
// Consolidated status badge (mara: "why do we have separate alive
// badge? ... one badge that shows thinking / idle / paused").
// Not online → the alive-table's own reading (rate-limited/needs-login/
// offline), no age (nothing to age). Online + paused → 'paused'
// overrides the turn-state reading, since a paused agent won't be
// thinking regardless of its last turn_state. Online + running →
// turn-state + age, same as before.
const primaryStatus =
state.status !== 'online'
? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined }
: state.paused
? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined }
: {
glyph: turnDef.glyph,
text: `${turnDef.text} · ${stateAge}`,
tone: turnDef.tone,
tooltip: STATE_TOOLTIPS[effectiveTurnState],
};
const ctx = tokenTotal(state.ctx_usage);
const cost = tokenTotal(state.cost_usage);
@ -186,11 +202,9 @@ export function Root() {
pills={pills}
>
<StatusChips
aliveLabel={`${alive.glyph} ${alive.text}`}
aliveTone={alive.tone}
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
stateTone={turnDef.tone}
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
statusLabel={`${primaryStatus.glyph} ${primaryStatus.text}`}
statusTone={primaryStatus.tone}
statusTooltip={primaryStatus.tooltip}
model={state.model}
resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models}

View file

@ -500,23 +500,10 @@ pre.diff {
title moved into the overflow menu, then into `MetaNav`'s `🔗`
popover (the ` dashboard` item) once the overflow menu itself was
deleted see MetaNav.tsx's file comment. */
.btn-cancel-turn {
font-family: inherit;
font-size: 0.8em;
letter-spacing: 0.08em;
background: transparent;
color: var(--red);
border: 1px solid var(--red);
border-radius: 999px;
padding: 0.2em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, background 0.15s ease;
}
.btn-cancel-turn:hover {
background: color-mix(in srgb, var(--red) 10%, transparent);
box-shadow: 0 0 10px -2px currentColor;
}
/* Orphaned tombstone `.btn-cancel-turn` standalone button moved into
the consolidated status badge's dropdown as a danger-toned row
(StatusChips.tsx's `StatusMenu`, `@hive/shared`'s
`.ui-dropdown-item-danger`). */
/* Orphaned tombstone `.btn-new-session` round-pill moved into the
overflow menu, then out of the header entirely: it's the
`/new-session` slash command now (TermInput.tsx), no button/chip

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

View file

@ -42,6 +42,16 @@
content: '✓ ';
color: var(--purple);
}
/* Destructive action row (cancel turn) ported from the old standalone
`.btn-cancel-turn`'s red-on-transparent look now that it lives here
instead of its own button (mara: "if the cancel turn button moves
into the dropdown, it should be fine"). */
.ui-dropdown-item-danger {
color: var(--red);
}
.ui-dropdown-item-danger:hover {
background: color-mix(in srgb, var(--red) 12%, transparent);
}
.ui-dropdown-item-desc {
font-size: 0.8em;
/* `--muted-on-dim`, not bare `--muted`: an *active* item's row bg is

View file

@ -23,6 +23,8 @@ export interface DropdownOption {
label: ComponentChildren;
/** Optional dim secondary text, e.g. "sonnet (balanced)". */
description?: ComponentChildren;
/** Red-toned row for a destructive action (e.g. cancel turn), not a picker option. */
danger?: boolean;
}
export interface DropdownProps {
@ -67,7 +69,11 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label
key={opt.value}
role="menuitemradio"
aria-checked={opt.value === activeValue}
class={'ui-dropdown-item' + (opt.value === activeValue ? ' ui-dropdown-item-active' : '')}
class={
'ui-dropdown-item' +
(opt.value === activeValue ? ' ui-dropdown-item-active' : '') +
(opt.danger ? ' ui-dropdown-item-danger' : '')
}
onClick={() => onSelect(opt.value)}
>
<span class="ui-dropdown-item-label">{opt.label}</span>