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:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(){
|
||||||
|
|||||||
69
tests/test_cmd_dropdown_scroll_838.py
Normal file
69
tests/test_cmd_dropdown_scroll_838.py
Normal 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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user