Skip to content
375 changes: 288 additions & 87 deletions api/config.py

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions static/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const COMMANDS=[
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh|max', subArgs:['show','hide','none','minimal','low','medium','high','xhigh','max'], noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh|max|ultra', subArgs:['show','hide','none','minimal','low','medium','high','xhigh','max','ultra'], noEcho:true},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
{name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true},
];
Expand Down Expand Up @@ -1832,13 +1832,13 @@ function cmdReasoning(args){
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
// Keep this WebUI effort list in sync with hermes-agent#29248.
const EFFORTS=['none','minimal','low','medium','high','xhigh','max'];
const EFFORTS=['none','minimal','low','medium','high','xhigh','max','ultra'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh|max';
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh|max|ultra';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
Expand Down Expand Up @@ -1867,7 +1867,7 @@ function cmdReasoning(args){
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg,..._reasoningEffortContext()})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
Expand Down
1 change: 1 addition & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
<div class="reasoning-option" data-effort="high">High</div>
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
<div class="reasoning-option" data-effort="max">Max</div>
<div class="reasoning-option" data-effort="ultra">Ultra</div>
</div>
<div class="composer-toolsets-dropdown" id="composerToolsetsDropdown">
<div class="toolsets-dropdown-desc" id="toolsetsDropdownDesc"></div>
Expand Down
3 changes: 2 additions & 1 deletion static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2639,7 +2639,8 @@
.composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-reasoning-chevron{display:none;}
.composer-reasoning-label{display:inline-flex;align-items:center;justify-content:center;min-width:1.6ch;font-size:11px;font-weight:700;letter-spacing:0;text-transform:none;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;overflow:hidden;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;overflow:hidden;max-height:min(60vh,calc(100vh - 96px),480px);overflow-y:auto;overscroll-behavior:contain;}
@supports (height:100dvh){.composer-reasoning-dropdown{max-height:min(60dvh,calc(100dvh - 96px),480px);}}
.composer-reasoning-dropdown.open{display:block;}
.reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .12s;}
.reasoning-option:hover{background:rgba(255,255,255,.07);}
Expand Down
1 change: 1 addition & 0 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -5057,6 +5057,7 @@ function _formatReasoningEffortLabel(effort){
if(effort==='high') return 'High';
if(effort==='xhigh') return 'XHigh';
if(effort==='max') return 'Max';
if(effort==='ultra') return 'Ultra';
return effort.charAt(0).toUpperCase()+effort.slice(1);
}

Expand Down
3 changes: 3 additions & 0 deletions tests/test_issue1103_reasoning_chip_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def test_reasoning_chip_html_starts_hidden():
assert 'data-effort="max"' in src, (
"composer reasoning dropdown must include Max option"
)
assert 'data-effort="ultra"' in src, (
"composer reasoning dropdown must include Ultra option"
)


def test_ui_js_passes_model_context_to_reasoning_api():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_reasoning_chip_js_behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,8 @@ def test_default_toggle_undefined_keeps_prior_behavior(self, driver_meta_path):
# (off). The earlier round-2 fix kept the chip visible but the only rendered
# option was "None" — so a user could turn thinking OFF but never back ON.
# These tests drive the actual _applyReasoningOptions against a simulated
# dropdown containing all 8 options from static/index.html (Default/none/
# minimal/low/medium/high/xhigh/max) and pin which are visible per tier.
# dropdown containing all 9 options from static/index.html (Default/none/
# minimal/low/medium/high/xhigh/max/ultra) and pin which are visible per tier.
# ─────────────────────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -419,7 +419,7 @@ def test_default_toggle_undefined_keeps_prior_behavior(self, driver_meta_path):
}

// Options mirror static/index.html's composerReasoningDropdown exactly.
const options = ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(makeOption);
const options = ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].map(makeOption);
const optionEls = options;

const els = {
Expand Down
161 changes: 161 additions & 0 deletions tests/test_reasoning_dropdown_viewport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""#6018 gate blocker 4 — the 9-row reasoning dropdown must keep every row
reachable on short viewports.

The composer reasoning dropdown opens UPWARD (``bottom:calc(100% + 4px)``)
above its chip. With nine rows (Default..Ultra) and no height cap, a short
viewport (e.g. 390×300 landscape keyboard-overlap, or a small phone) pushes
the top rows — including the override-clearing ``Default`` row — above the
viewport with ``overflow:hidden`` and no scroll path to reach them.

Real-browser measurement (Playwright) of the production markup + styles:
the ``#composerReasoningDropdown`` block from ``static/index.html`` is mounted
inside a composer-footer-shaped anchor at the bottom of the page, with the real
``static/style.css`` applied. For desktop (1280×800), mobile portrait
(390×844), and the reviewer's short-landscape case (390×300) we assert:

* the dropdown fits inside the viewport (no row above y=0);
* every row — first (``Default``) and last (``Ultra``) — is reachable:
either directly visible or scrollable-to via ``overflow-y:auto``;
* after scrolling to top/bottom, both boundary rows are inside the dropdown's
visible box.

Skips cleanly where Playwright or a chromium binary is unavailable (matching
the repo's other browser tests, e.g. test_wakeup_card_responsive.py).
"""

import re
from pathlib import Path

import pytest


ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")


def _dropdown_markup() -> str:
"""Extract the real #composerReasoningDropdown block from index.html."""
m = re.search(
r'<div class="composer-reasoning-dropdown"[^>]*id="composerReasoningDropdown"[^>]*>'
r".*?</div>\s*</div>",
INDEX_HTML,
re.S,
)
assert m, "composerReasoningDropdown markup not found in index.html"
# Trim the trailing sibling-closing </div> captured for a complete block.
markup = m.group(0)
assert 'data-effort="ultra"' in markup, "Ultra row missing from dropdown markup"
assert 'data-effort=""' in markup, "Default row missing from dropdown markup"
return markup[: markup.rindex("</div>")]


def _measure(width: int, height: int):
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - dependency missing path
pytest.skip("playwright is unavailable; run the reasoning dropdown viewport test")

playwright = sync_playwright().start()
try:
browser = playwright.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
except Exception as exc: # pragma: no cover - no browser binary in sandbox
playwright.stop()
pytest.skip(f"chromium unavailable for browser measurement: {exc}")

try:
page = browser.new_page(viewport={"width": width, "height": height})
# Composer-footer-shaped anchor pinned to the bottom of the viewport,
# mirroring the production placement of the upward-opening dropdown.
page.set_content(
"<!doctype html><html><head></head><body>"
'<div class="composer-footer" '
'style="position:fixed;left:0;right:0;bottom:0;">'
+ _dropdown_markup()
+ "</div></body></html>"
)
page.add_style_tag(content=STYLE_CSS)
result = page.evaluate(
"""
() => {
const dd = document.getElementById('composerReasoningDropdown');
dd.classList.add('open');
const rows = Array.from(dd.querySelectorAll('.reasoning-option'));
const first = rows[0];
const last = rows[rows.length - 1];
const style = getComputedStyle(dd);
const box = () => dd.getBoundingClientRect();
const inBox = (el) => {
const b = box();
const r = el.getBoundingClientRect();
return r.top >= b.top - 1 && r.bottom <= b.bottom + 1;
};
const out = {
rowCount: rows.length,
firstLabel: first.textContent.trim(),
lastLabel: last.textContent.trim(),
overflowY: style.overflowY,
menuTop: box().top,
menuBottom: box().bottom,
viewportH: window.innerHeight,
scrollable: dd.scrollHeight > dd.clientHeight + 1,
};
dd.scrollTop = 0;
out.firstReachableAtTop = inBox(first);
out.firstTopAtTop = first.getBoundingClientRect().top;
dd.scrollTop = dd.scrollHeight;
out.lastReachableAtBottom = inBox(last);
out.lastBottomAtBottom = last.getBoundingClientRect().bottom;
return out;
}
"""
)
finally:
browser.close()
playwright.stop()
return result


def _assert_all_rows_reachable(m):
# The dropdown itself must sit fully inside the viewport.
assert m["menuTop"] >= -1, f"dropdown top is above the viewport: {m}"
assert m["menuBottom"] <= m["viewportH"] + 1, m
# Nine rows: Default..Ultra.
assert m["rowCount"] == 9, m
assert m["firstLabel"] == "Default", m
assert m["lastLabel"] == "Ultra", m
# Both boundary rows are reachable: at scrollTop=0 the Default row is
# inside the dropdown's box AND on-screen; after scrolling to the bottom
# the Ultra row is inside the box.
assert m["firstReachableAtTop"], f"Default row unreachable: {m}"
assert m["firstTopAtTop"] >= -1, f"Default row rendered above the viewport: {m}"
assert m["lastReachableAtBottom"], f"Ultra row unreachable: {m}"
assert m["lastBottomAtBottom"] <= m["viewportH"] + 1, m
# When content is taller than the capped menu, scrolling must be enabled —
# overflow-y:auto like the sibling model/session dropdowns.
if m["scrollable"]:
assert m["overflowY"] in ("auto", "scroll"), (
f"scrollable dropdown must not clip with overflow:{m['overflowY']}: {m}"
)


def test_desktop_1280x800_every_row_reachable():
_assert_all_rows_reachable(_measure(1280, 800))


def test_mobile_portrait_390x844_every_row_reachable():
_assert_all_rows_reachable(_measure(390, 844))


def test_short_landscape_390x300_default_row_reachable():
# The reviewer's reproduction: 390×300 previously measured menuTop=-50 with
# overflow hidden — the Default row sat off-viewport with no scroll path.
m = _measure(390, 300)
_assert_all_rows_reachable(m)
# At this height the 9-row list cannot fit uncapped; the viewport-bounded
# max-height must engage and hand the overflow to the scroll container.
assert m["scrollable"], f"expected the height cap to engage at 390x300: {m}"
assert m["overflowY"] in ("auto", "scroll"), m
Loading
Loading