feat(frontend): per-agent effort-level quick-picker (#1596)

Adds a reasoning-effort quick-picker to the agent page's overflow menu,
a direct sibling of the existing model quick-picker, wired to the backend
seam from #1597/#1600: /api/state carries effort + available_efforts, the
picker POSTs { effort } to /api/effort (operator-only, mirroring
/api/model), and live updates arrive via the effort_changed SSE event.

The available levels are the backend's to declare — the frontend holds no
hard-coded list; availableEfforts is seeded from state.available_efforts
on cold-load and the picker section is omitted until the backend supplies
the set. Mirrors the model picker otherwise: postEffort(),
renderEffortChip() (updates the new amber effort-chip + button active
states), the cold-load seed, and the effort_changed handler. Build green.

Applies on the next claude session (operator uses the existing
clear-session), per the backend contract.
This commit is contained in:
iris 2026-06-10 01:26:52 +02:00 committed by mara
commit ceb3e3b01a
3 changed files with 108 additions and 5 deletions

View file

@ -275,16 +275,19 @@ h2, h3 {
padding: 0 0.7em 0.1em;
text-transform: uppercase;
}
.overflow-item-model {
.overflow-item-model,
.overflow-item-effort {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.overflow-item-model:hover {
.overflow-item-model:hover,
.overflow-item-effort:hover {
color: var(--fg);
background: color-mix(in srgb, var(--purple) 8%, transparent);
border-color: var(--purple-dim);
}
.overflow-item-model.active {
.overflow-item-model.active,
.overflow-item-effort.active {
color: var(--purple);
border-color: var(--purple-dim);
background: color-mix(in srgb, var(--purple) 6%, transparent);
@ -674,15 +677,17 @@ pre.diff {
font-size: 0.8em;
letter-spacing: 0.05em;
}
.model-chip {
.model-chip,
.effort-chip {
display: inline-block;
padding: 0.1em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 999px;
color: var(--cyan);
font-size: 0.78em;
letter-spacing: 0.04em;
}
.model-chip { color: var(--cyan); }
.effort-chip { color: var(--amber); }
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so

View file

@ -312,6 +312,47 @@ window.marked = marked;
menu.append(btn);
}
// ─── effort quick-picker ───────────────────────────────────────
// One-click shortcuts for each reasoning-effort level the backend
// declares in `availableEfforts` (from `state.available_efforts`).
// The active level is highlighted via the `active` class; see
// `renderEffortChip` which updates `effortPickerBtns` live. Applies
// on the next claude session. Omitted entirely when the backend
// hasn't supplied any levels (older harness / cold-load not yet in).
effortPickerBtns = [];
if (availableEfforts.length) {
const effortSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' });
const effortLabel = el('div', { class: 'overflow-section-label' }, 'effort');
menu.append(effortSep, effortLabel);
// Display-only labels for well-known levels; any level the backend
// sends that isn't here just shows its bare name.
const EFFORT_DESCRIPTIONS = {
medium: 'medium (default)',
high: 'high',
xhigh: 'xhigh (max)',
};
for (const level of availableEfforts) {
const label = EFFORT_DESCRIPTIONS[level] ?? level;
const btn = el('button', {
type: 'button',
class: 'overflow-item overflow-item-effort',
role: 'menuitem',
title: `set reasoning effort to ${level} (applies next session)`,
'data-effort': level,
},
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '◇'),
label,
);
btn.addEventListener('click', () => {
if (currentEffort === level) { closeOverflowMenu(); return; }
closeOverflowMenu();
postEffort(level);
});
effortPickerBtns.push(btn);
menu.append(btn);
}
}
overflowMenuPopulated = true;
}
@ -483,6 +524,17 @@ window.marked = marked;
// the built-in default until the first /api/state cold-load completes.
let availableModels = ['haiku', 'sonnet', 'opus'];
// Effort quick-picker — sibling of the model picker. The operator's
// selected reasoning-effort level applies on the next claude session.
// `currentEffort` highlights the active button + chip; `effortPickerBtns`
// are the menu buttons updated live by `renderEffortChip`. The level list
// is the backend's to declare — seeded from `state.available_efforts` on
// cold-load; empty until then (no hard-coded levels in the frontend), so
// the picker section is omitted until the backend supplies the set.
let currentEffort = null;
let effortPickerBtns = [];
let availableEfforts = [];
const SLASH_COMMANDS = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
@ -515,6 +567,28 @@ window.marked = marked;
}
}
async function postEffort(level) {
try {
const resp = await fetch('api/effort', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ effort: level }),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok && termAPI) {
const text = await resp.text().catch(() => '');
termAPI.row('turn-end-fail', '✗ effort change failed: ' + resp.status
+ (text ? ' — ' + text : ''));
}
// No refreshState — the harness emits `effort_changed` on the
// SSE bus and the chip handler picks it up live.
} catch (err) {
if (termAPI) termAPI.row('turn-end-fail', '✗ effort change failed: ' + err);
}
}
async function postSimple(url, label) {
try {
const resp = await fetch(url, { method: 'POST', redirect: 'manual' });
@ -1038,6 +1112,24 @@ window.marked = marked;
btn.setAttribute('aria-pressed', String(isActive));
}
}
function renderEffortChip(effort) {
currentEffort = effort || null;
const el_ = $('effort-chip');
if (el_) {
if (!effort) { el_.hidden = true; } else {
el_.hidden = false;
el_.textContent = 'effort · ' + effort;
el_.title = `reasoning effort: ${effort}\nset via the operator's effort picker; applies on the next claude session`;
}
}
// Sync effort picker buttons: highlight the active level (exact match).
for (const btn of effortPickerBtns) {
const isActive = !!effort && btn.dataset.effort === effort;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
}
}
// Token badges — two separate chips:
// ctx · N last inference's prompt size = current context window
// utilisation (what to watch for compaction decisions)
@ -1130,6 +1222,9 @@ window.marked = marked;
if (Array.isArray(s.available_models) && s.available_models.length > 0) {
availableModels = s.available_models;
}
if (Array.isArray(s.available_efforts) && s.available_efforts.length > 0) {
availableEfforts = s.available_efforts;
}
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; }
currentLabel = s.label;
// Render server-supplied navigation links — see
@ -1171,6 +1266,7 @@ window.marked = marked;
}
renderAliveBadge(s.status);
renderModelChip(s.model);
renderEffortChip(s.effort);
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
// Open-threads aren't part of /api/state (kept on the broker
// db, fetched via the per-agent socket). Cold-load fetches
@ -1671,6 +1767,7 @@ window.marked = marked;
}
},
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
effort_changed(ev, api) { if (!api.fromHistory) renderEffortChip(ev.effort); },
token_usage_changed(ev, api) {
if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost });
},

View file

@ -38,6 +38,7 @@
<span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="model-chip" class="model-chip" hidden></span>
<span id="effort-chip" class="effort-chip" hidden></span>
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
<span id="last-turn" class="last-turn" hidden></span>