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}>
|
<Header label="…" pills={pills}>
|
||||||
<StatusChips
|
<StatusChips
|
||||||
aliveLabel="… connecting"
|
statusLabel="… connecting"
|
||||||
aliveTone="neutral"
|
statusTone="neutral"
|
||||||
stateLabel="… booting"
|
|
||||||
stateTone="neutral"
|
|
||||||
model=""
|
model=""
|
||||||
availableModels={[]}
|
availableModels={[]}
|
||||||
onSelectModel={() => {}}
|
onSelectModel={() => {}}
|
||||||
|
|
@ -175,6 +173,24 @@ export function Root() {
|
||||||
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
|
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
|
||||||
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
||||||
const stateAge = fmtAge(Date.now() - state.turn_state_since * 1000);
|
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 ctx = tokenTotal(state.ctx_usage);
|
||||||
const cost = tokenTotal(state.cost_usage);
|
const cost = tokenTotal(state.cost_usage);
|
||||||
|
|
||||||
|
|
@ -186,11 +202,9 @@ export function Root() {
|
||||||
pills={pills}
|
pills={pills}
|
||||||
>
|
>
|
||||||
<StatusChips
|
<StatusChips
|
||||||
aliveLabel={`${alive.glyph} ${alive.text}`}
|
statusLabel={`${primaryStatus.glyph} ${primaryStatus.text}`}
|
||||||
aliveTone={alive.tone}
|
statusTone={primaryStatus.tone}
|
||||||
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
|
statusTooltip={primaryStatus.tooltip}
|
||||||
stateTone={turnDef.tone}
|
|
||||||
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
|
|
||||||
model={state.model}
|
model={state.model}
|
||||||
resolvedModel={state.resolved_model ?? undefined}
|
resolvedModel={state.resolved_model ?? undefined}
|
||||||
availableModels={state.available_models}
|
availableModels={state.available_models}
|
||||||
|
|
|
||||||
|
|
@ -500,23 +500,10 @@ pre.diff {
|
||||||
title moved into the overflow menu, then into `MetaNav`'s `🔗`
|
title moved into the overflow menu, then into `MetaNav`'s `🔗`
|
||||||
popover (the `↑ dashboard` item) once the overflow menu itself was
|
popover (the `↑ dashboard` item) once the overflow menu itself was
|
||||||
deleted — see MetaNav.tsx's file comment. */
|
deleted — see MetaNav.tsx's file comment. */
|
||||||
.btn-cancel-turn {
|
/* Orphaned tombstone — `.btn-cancel-turn` standalone button moved into
|
||||||
font-family: inherit;
|
the consolidated status badge's dropdown as a danger-toned row
|
||||||
font-size: 0.8em;
|
(StatusChips.tsx's `StatusMenu`, `@hive/shared`'s
|
||||||
letter-spacing: 0.08em;
|
`.ui-dropdown-item-danger`). */
|
||||||
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-new-session` round-pill moved into the
|
/* Orphaned tombstone — `.btn-new-session` round-pill moved into the
|
||||||
overflow menu, then out of the header entirely: it's the
|
overflow menu, then out of the header entirely: it's the
|
||||||
`/new-session` slash command now (TermInput.tsx), no button/chip
|
`/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
|
// <StatusChips> — the agent page's status row: the consolidated status
|
||||||
// model + effort pickers, context/cost usage, and pause/resume. This is
|
// badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in
|
||||||
// the concrete fix for the design guide's own named anti-example (the
|
// its dropdown), the model + effort pickers, and context/cost usage.
|
||||||
// old page's model/effort *pickers* lived in the `⋯` overflow menu while
|
// This is the concrete fix for the design guide's own named anti-example
|
||||||
// the current model/effort only showed as a disconnected chip) — every
|
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu
|
||||||
// badge here IS the control for what it displays, composed from
|
// 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,
|
// `@hive/shared`'s `Badge`/`Dropdown` (../../shared/src/badge,
|
||||||
// ../../shared/src/dropdown). Presentational only: formatting +
|
// ../../shared/src/dropdown). Presentational only: formatting +
|
||||||
// selection state live in the caller (`Root.tsx`, via the
|
// 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';
|
import './StatusChips.css';
|
||||||
|
|
||||||
export interface StatusChipsProps {
|
export interface StatusChipsProps {
|
||||||
aliveLabel: string;
|
statusLabel: string;
|
||||||
aliveTone: BadgeTone;
|
statusTone: BadgeTone;
|
||||||
stateLabel: string;
|
statusTooltip?: string;
|
||||||
stateTone: BadgeTone;
|
|
||||||
stateTooltip?: string;
|
|
||||||
model: string;
|
model: string;
|
||||||
/** Concrete model id the last completed turn actually ran on, e.g.
|
/** 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
|
* "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({
|
export function StatusChips({
|
||||||
aliveLabel,
|
statusLabel,
|
||||||
aliveTone,
|
statusTone,
|
||||||
stateLabel,
|
statusTooltip,
|
||||||
stateTone,
|
|
||||||
stateTooltip,
|
|
||||||
model,
|
model,
|
||||||
resolvedModel,
|
resolvedModel,
|
||||||
availableModels,
|
availableModels,
|
||||||
|
|
@ -113,18 +179,17 @@ export function StatusChips({
|
||||||
thinking,
|
thinking,
|
||||||
onCancelTurn,
|
onCancelTurn,
|
||||||
}: StatusChipsProps) {
|
}: 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 (
|
return (
|
||||||
<div class="status-chips">
|
<div class="status-chips">
|
||||||
<Badge value={aliveLabel} tone={aliveTone} />
|
<StatusMenu
|
||||||
<Badge value={stateLabel} tone={stateTone} title={stateTooltip} />
|
statusLabel={statusLabel}
|
||||||
|
statusTone={statusTone}
|
||||||
|
statusTooltip={statusTooltip}
|
||||||
|
paused={paused}
|
||||||
|
onTogglePause={onTogglePause}
|
||||||
|
thinking={thinking}
|
||||||
|
onCancelTurn={onCancelTurn}
|
||||||
|
/>
|
||||||
<Picker
|
<Picker
|
||||||
label="model"
|
label="model"
|
||||||
value={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)"
|
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Badge
|
|
||||||
value={paused ? '▶ resume' : '⏸ pause'}
|
|
||||||
tone={paused ? 'warning' : 'neutral'}
|
|
||||||
onClick={onTogglePause}
|
|
||||||
/>
|
|
||||||
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@
|
||||||
content: '✓ ';
|
content: '✓ ';
|
||||||
color: var(--purple);
|
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 {
|
.ui-dropdown-item-desc {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
/* `--muted-on-dim`, not bare `--muted`: an *active* item's row bg is
|
/* `--muted-on-dim`, not bare `--muted`: an *active* item's row bg is
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export interface DropdownOption {
|
||||||
label: ComponentChildren;
|
label: ComponentChildren;
|
||||||
/** Optional dim secondary text, e.g. "sonnet (balanced)". */
|
/** Optional dim secondary text, e.g. "sonnet (balanced)". */
|
||||||
description?: ComponentChildren;
|
description?: ComponentChildren;
|
||||||
|
/** Red-toned row for a destructive action (e.g. cancel turn), not a picker option. */
|
||||||
|
danger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropdownProps {
|
export interface DropdownProps {
|
||||||
|
|
@ -67,7 +69,11 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
aria-checked={opt.value === activeValue}
|
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)}
|
onClick={() => onSelect(opt.value)}
|
||||||
>
|
>
|
||||||
<span class="ui-dropdown-item-label">{opt.label}</span>
|
<span class="ui-dropdown-item-label">{opt.label}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue