diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 40b64fa6..b586c428 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -142,10 +142,8 @@ export function Root() { <>
{}} @@ -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} > — 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 +// — 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 ( +
+ setOpen((o) => !o)} + expanded={open} + /> + { + 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} + /> +
+ ); +} + 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 (
- - + ) : null} - {lastTurnLabel ? {lastTurnLabel} : null} - {thinking ? ( - - ) : null}
); } diff --git a/frontend/packages/shared/src/dropdown/Dropdown.css b/frontend/packages/shared/src/dropdown/Dropdown.css index a24e2856..2b51ab22 100644 --- a/frontend/packages/shared/src/dropdown/Dropdown.css +++ b/frontend/packages/shared/src/dropdown/Dropdown.css @@ -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 diff --git a/frontend/packages/shared/src/dropdown/Dropdown.tsx b/frontend/packages/shared/src/dropdown/Dropdown.tsx index ab9c86b7..69e76d8f 100644 --- a/frontend/packages/shared/src/dropdown/Dropdown.tsx +++ b/frontend/packages/shared/src/dropdown/Dropdown.tsx @@ -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)} > {opt.label}