fix(ui): scroll selected item into view on slash command dropdown keyboard navigation (closes #838)

* fix(ui): scroll selected item into view on slash command dropdown keyboard nav

navigateCmdDropdown() in commands.js now calls scrollIntoView({block:'nearest'})
after updating the .selected class, so the highlighted item stays visible
when the dropdown overflows and the user navigates with ↓/↑. Closes #838.

* test: lock in scrollIntoView for slash command dropdown navigation (#838)

4 regression tests in test_cmd_dropdown_scroll_838.py:
- navigateCmdDropdown calls scrollIntoView on the selected item
- Uses {block:"nearest"} (minimum-distance scroll, not jumpy)
- Scroll call comes AFTER the .selected classList.add (correct target)
- .cmd-dropdown has overflow-y:auto so the dropdown itself is the scroll
  container (scrollIntoView does not bubble up to the viewport)

Full suite: 1749 passed, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-21 22:55:09 -07:00
committed by GitHub
parent 11fd0d8412
commit 9b628c27ab
3 changed files with 77 additions and 21 deletions

View File

@@ -1,28 +1,12 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.144] — 2026-04-22 ## [v0.50.145] — 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
### Fixed ### Fixed
- **Stale model no longer shows as "(unavailable)" in the model picker** — users with - **Slash command dropdown scrolls to keep highlighted item visible** — pressing ↓/↑
only `custom_providers` configured (no OpenAI key) were seeing "GPT-5.4 Mini (unavailable)" to navigate the autocomplete list no longer lets the selected item move out of the
appear in the picker, visually grouped under their custom provider section and selected visible dropdown area. Added `scrollIntoView({block:'nearest'})` after updating the
as the default model. Two root causes: (1) `_resolve_compatible_session_model()` in selected class in `navigateCmdDropdown()`. Closes #838. (#839)
`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 `<option>` for unavailable models, which visually inherited the last
rendered provider heading in the picker due to missing `<optgroup>` context. Fixed to
silently reset to the first available model instead. Closes #829. (#831)
## [v0.50.141] — 2026-04-22 ## [v0.50.141] — 2026-04-22

View File

@@ -733,6 +733,9 @@ function navigateCmdDropdown(dir){
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1; if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0; if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected'); 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(){ function selectCmdDropdownItem(){

View File

@@ -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"
)