diff --git a/CHANGELOG.md b/CHANGELOG.md index 08937e6a..efda435c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,12 @@ # Hermes Web UI -- Changelog -## [v0.50.144] — 2026-04-22 - -### Added -- **Refresh button in Tasks/Scheduled Jobs panel** — a ↺ button next to "+ New job" - reloads the job list without a full page reload. Dims while fetching and restores on - completion (using `finally`). Also wires a `hermes:cron_created` window event so the - list auto-refreshes when a job is created from chat. Closes #835. (#837) - -## [v0.50.143] — 2026-04-22 +## [v0.50.145] — 2026-04-22 ### Fixed -- **Stale model no longer shows as "(unavailable)" in the model picker** — users with - only `custom_providers` configured (no OpenAI key) were seeing "GPT-5.4 Mini (unavailable)" - appear in the picker, visually grouped under their custom provider section and selected - as the default model. Two root causes: (1) `_resolve_compatible_session_model()` in - `api/routes.py` had a blanket skip for `active_provider == "custom"` that prevented - stale cross-provider session models (e.g. `openai/gpt-5.4-mini` from a pre-v0.50 default) - from ever being cleaned up. Fixed to only skip normalization when the model's prefix is - actually routable by a group in the catalog. (2) `renderSession()` in `static/ui.js` - injected a bare `` context. Fixed to - silently reset to the first available model instead. Closes #829. (#831) - +- **Slash command dropdown scrolls to keep highlighted item visible** — pressing ↓/↑ + to navigate the autocomplete list no longer lets the selected item move out of the + visible dropdown area. Added `scrollIntoView({block:'nearest'})` after updating the + selected class in `navigateCmdDropdown()`. Closes #838. (#839) ## [v0.50.141] — 2026-04-22 diff --git a/static/commands.js b/static/commands.js index 2cbe9525..a9d939c6 100644 --- a/static/commands.js +++ b/static/commands.js @@ -733,6 +733,9 @@ function navigateCmdDropdown(dir){ if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1; if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0; items[_cmdSelectedIdx].classList.add('selected'); + // Scroll the newly highlighted item into view so it stays visible when the + // dropdown overflows and the user navigates with keyboard (#838). + items[_cmdSelectedIdx].scrollIntoView({block:'nearest'}); } function selectCmdDropdownItem(){ diff --git a/tests/test_cmd_dropdown_scroll_838.py b/tests/test_cmd_dropdown_scroll_838.py new file mode 100644 index 00000000..7f5b923b --- /dev/null +++ b/tests/test_cmd_dropdown_scroll_838.py @@ -0,0 +1,69 @@ +"""Tests for #838 — slash command dropdown keyboard navigation keeps the +selected item in view.""" +import os +import re + + +_SRC = os.path.join(os.path.dirname(__file__), "..") + + +def _read(name): + return open(os.path.join(_SRC, name), encoding="utf-8").read() + + +class TestNavigateCmdDropdownScroll: + """navigateCmdDropdown must scroll the newly selected item into view so + keyboard navigation on a long list doesn't leave the highlight below the + visible area of the dropdown.""" + + def test_navigate_calls_scroll_into_view(self): + js = _read("static/commands.js") + m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL) + assert m, "navigateCmdDropdown not found" + fn = m.group(0) + assert 'scrollIntoView' in fn, ( + "navigateCmdDropdown must call scrollIntoView on the newly " + "selected item so ↓/↑ keeps the highlight visible (#838)" + ) + + def test_scroll_uses_nearest_block_alignment(self): + """`{block:'nearest'}` is the correct option: scrolls only when + needed, minimum distance — won't jump the list around on every + arrow-key press when the item is already in view.""" + js = _read("static/commands.js") + m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL) + assert m + fn = m.group(0) + assert "block:'nearest'" in fn or 'block: "nearest"' in fn, ( + "scrollIntoView should use {block:'nearest'} to scroll the " + "minimum amount needed" + ) + + def test_scroll_after_selected_class_update(self): + """The scroll call must come AFTER adding the .selected class so + the correct item is targeted.""" + js = _read("static/commands.js") + m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL) + assert m + fn = m.group(0) + selected_pos = fn.find("classList.add('selected')") + scroll_pos = fn.find("scrollIntoView") + assert selected_pos != -1 and scroll_pos != -1 + assert selected_pos < scroll_pos, ( + "scrollIntoView must run after classList.add('selected') so it " + "scrolls the newly-highlighted item into view" + ) + + def test_cmd_dropdown_is_scroll_container(self): + """Regression guard: the .cmd-dropdown must have overflow-y:auto + (or similar) so scrollIntoView finds it as the scroll ancestor + rather than bubbling up to the viewport.""" + css = _read("static/style.css") + m = re.search(r'\.cmd-dropdown\s*\{[^}]+\}', css) + assert m, ".cmd-dropdown rule not found" + block = m.group(0) + assert 'overflow-y:auto' in block or 'overflow-y: auto' in block or \ + 'overflow:auto' in block or 'overflow: auto' in block, ( + ".cmd-dropdown must have overflow-y:auto so scrollIntoView " + "scrolls within the dropdown, not the whole page" + )