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:
parent
d6ca52535f
commit
1e4399d49e
5 changed files with 135 additions and 71 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue