From ceb3e3b01af818a57813db1ce2985508f2fd66d2 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 01:26:52 +0200 Subject: [PATCH] feat(frontend): per-agent effort-level quick-picker (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/agent/src/agent.css | 15 ++-- frontend/packages/agent/src/app.js | 97 ++++++++++++++++++++++++++ frontend/packages/agent/src/index.html | 1 + 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 8684cbad..b491c8e5 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -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 diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 4d9049cd..53feb7e5 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -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 }); }, diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index f27d2c81..aad9bf90 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -38,6 +38,7 @@ … booting +