mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
Two small, aesthetic-safe UX wins: - #2952 (@Sanjays2402): model-picker arrow-key navigation + Enter-to-select. Highlight reuses existing hover style, invisible until keyboard used. Opus SHIP-safe, live key-drive verified (multi-row traversal + wrap + Enter). - #3531 (@franksong2702): mobile titlebar '+' new-chat button. Shares the existing reload-button styling, mobile-only, mirrors new-chat pending state. 390px screenshot vision-verified: cleanly aligned in the titlebar. Both rebased onto master (CHANGELOG-only / merge-commit conflicts resolved; code verified byte-identical to PR heads). Full suite green on each (8336/8334), ESLint/scope-undef CLEAN, zero blocking bot flags. Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.337] — 2026-06-09 — Release LA (model-picker keyboard nav + mobile new-chat)
|
||||
|
||||
### Added
|
||||
- **Keyboard navigation in the model picker.** With the model dropdown open, ↑/↓ move a highlight through the filtered models (wrapping at the ends) and Enter selects the highlighted one; Escape closes. The highlight reuses the existing hover styling and is invisible until you use the keyboard. (#2952, #2791, @Sanjays2402)
|
||||
- **A "New chat" button in the mobile titlebar.** On narrow screens the app titlebar now shows a `+` button to start a new conversation without opening the sidebar; it shares the existing reload-button styling and mirrors the new-chat in-flight/disabled state. (#3531, @franksong2702)
|
||||
|
||||
## [v0.51.336] — 2026-06-08 — Release KZ (fix inline-thinking streaming perf regression)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -140,6 +140,12 @@
|
||||
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
|
||||
</div>
|
||||
<div class="app-titlebar-spacer" aria-hidden="true"></div>
|
||||
<button class="app-titlebar-new-chat" id="btnTitlebarNewChat" onclick="$('btnNewChat') && $('btnNewChat').click()" type="button" aria-label="New conversation" data-i18n-title="new_conversation" data-i18n-aria-label="new_conversation" title="New conversation">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-titlebar-reload" id="btnReload" onclick="window.location.reload()" type="button" aria-label="Reload" title="Reload page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
|
||||
+4
-2
@@ -562,8 +562,10 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
let _newSessionInFlight=null;
|
||||
const _newSessionPendingText=()=>t('new_session_creating')||'Creating new conversation…';
|
||||
function _setNewSessionPending(pending){
|
||||
const btn=$('btnNewChat');
|
||||
if(btn){
|
||||
const ids=['btnNewChat','btnTitlebarNewChat'];
|
||||
for (let i=0;i<ids.length;i++){
|
||||
const btn=$(ids[i]);
|
||||
if(!btn) continue;
|
||||
btn.disabled=!!pending;
|
||||
btn.setAttribute('aria-busy',pending?'true':'false');
|
||||
}
|
||||
|
||||
+9
-1
@@ -934,6 +934,7 @@
|
||||
:root{--app-titlebar-safe-top:0px;}
|
||||
.pwa-standalone{overscroll-behavior:none;}
|
||||
.pwa-standalone body{-webkit-tap-highlight-color:transparent;}
|
||||
.app-titlebar-new-chat{display:none;}
|
||||
.pwa-standalone .app-titlebar-reload{display:inline-flex;}
|
||||
.pwa-offline .app-titlebar::after{content:'';position:absolute;left:50%;bottom:5px;width:5px;height:5px;border-radius:999px;background:var(--warning);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning) 22%,transparent);transform:translateX(-50%);}
|
||||
.pwa-resumed .app-titlebar-title{animation:pwa-title-resume .6s ease-out;}
|
||||
@@ -948,8 +949,10 @@
|
||||
.pull-to-refresh-indicator.active{opacity:1;transform:translateY(0);}
|
||||
.pull-to-refresh-indicator .ptr-icon{display:inline-block;transition:transform .2s;font-size:16px;}
|
||||
.pull-to-refresh-indicator .ptr-icon.ready{transform:rotate(180deg);color:var(--accent);}
|
||||
/* Reload button — visible only in PWA standalone / fullscreen */
|
||||
/* Reload and mobile new-chat titlebar controls with shared icon-button styles */
|
||||
.app-titlebar-new-chat,
|
||||
.app-titlebar-reload{display:none;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;}
|
||||
.app-titlebar-new-chat:hover,
|
||||
.app-titlebar-reload:hover{background:var(--hover-bg);color:var(--text);}
|
||||
@media (display-mode: standalone), (display-mode: fullscreen){
|
||||
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
|
||||
@@ -2316,6 +2319,7 @@
|
||||
/* Hamburger button (in app titlebar on mobile) */
|
||||
.app-titlebar{justify-content:space-between;}
|
||||
.app-titlebar-hamburger,.app-titlebar-spacer{display:flex;}
|
||||
.app-titlebar-new-chat{display:inline-flex;}
|
||||
.app-titlebar-inner{flex:1 1 auto;}
|
||||
.system-health-panel.insights-card{gap:10px;padding:12px;}
|
||||
.system-health-head{align-items:flex-start;}
|
||||
@@ -2483,7 +2487,9 @@
|
||||
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}
|
||||
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
|
||||
.model-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.model-opt.is-highlighted{background:rgba(255,255,255,.1);outline:1px solid var(--border2);outline-offset:-1px;}
|
||||
.model-opt.active{background:var(--accent-bg);}
|
||||
.model-opt.active.is-highlighted{background:var(--accent-bg);outline:1px solid var(--accent);outline-offset:-1px;}
|
||||
.model-opt-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;}
|
||||
.model-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
|
||||
.model-opt-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
|
||||
@@ -3613,6 +3619,8 @@ main.main > #mainPlugin{display:none;}
|
||||
.panel-head-btn:hover{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]{opacity:.5;cursor:wait;}
|
||||
.panel-head-btn:disabled:hover,.panel-head-btn[aria-busy="true"]:hover{background:transparent;color:var(--muted);}
|
||||
.app-titlebar-new-chat:disabled,.app-titlebar-new-chat[aria-busy="true"]{opacity:.5;cursor:wait;}
|
||||
.app-titlebar-new-chat:disabled:hover,.app-titlebar-new-chat[aria-busy="true"]:hover{background:transparent;color:var(--muted);}
|
||||
.panel-head-btn svg{width:14px;height:14px;display:block;}
|
||||
.panel-head-sub{padding:6px 12px 8px;font-size:11px;color:var(--muted);flex-shrink:0;}
|
||||
.side-menu{display:flex;flex-direction:column;gap:2px;padding:8px;overflow-y:auto;}
|
||||
|
||||
+25
-1
@@ -1806,7 +1806,31 @@ function renderModelDropdown(){
|
||||
};
|
||||
// Event handlers for search input
|
||||
_si.addEventListener('input',()=>_filterModels(_si.value));
|
||||
_si.addEventListener('keydown',e=>{if(e.key==='Enter') {e.preventDefault();}if(e.key==='Escape') {closeModelDropdown();}});
|
||||
// Keyboard navigation through filtered model rows (#2791).
|
||||
const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt'));
|
||||
const _activeRowIndex=(rows)=>rows.findIndex(r=>r.classList.contains('is-highlighted'));
|
||||
const _highlightRow=(rows,idx)=>{
|
||||
for(const r of rows) r.classList.remove('is-highlighted');
|
||||
if(idx<0||idx>=rows.length) return;
|
||||
const row=rows[idx];
|
||||
row.classList.add('is-highlighted');
|
||||
if(typeof row.scrollIntoView==='function') row.scrollIntoView({block:'nearest'});
|
||||
};
|
||||
_si.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'){closeModelDropdown();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){
|
||||
const rows=_visibleModelRows();
|
||||
if(!rows.length){if(e.key==='Enter') e.preventDefault();return;}
|
||||
const cur=_activeRowIndex(rows);
|
||||
if(e.key==='ArrowDown'){e.preventDefault();_highlightRow(rows,cur<0?0:Math.min(rows.length-1,cur+1));return;}
|
||||
if(e.key==='ArrowUp'){e.preventDefault();_highlightRow(rows,cur<=0?rows.length-1:cur-1);return;}
|
||||
if(e.key==='Enter'){
|
||||
e.preventDefault();
|
||||
const pick=cur>=0?rows[cur]:rows[0];
|
||||
if(pick) pick.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
_si.addEventListener('click',e=>e.stopPropagation());
|
||||
// Event handlers for clear button
|
||||
_sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); };
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Regression check for #2791 — keyboard navigation in the model picker.
|
||||
|
||||
The picker is a click-driven dropdown; users asked for arrow-key navigation
|
||||
and Enter-to-select on the existing search input. Verified at the source
|
||||
level so this stays fast.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_arrow_keys_wired_on_search_input():
|
||||
# ArrowDown / ArrowUp / Enter all handled in one keydown listener on _si.
|
||||
assert "ArrowDown" in UI_JS
|
||||
assert "ArrowUp" in UI_JS
|
||||
assert "is-highlighted" in UI_JS
|
||||
|
||||
|
||||
def test_highlight_class_has_style():
|
||||
assert ".model-opt.is-highlighted" in STYLE_CSS
|
||||
|
||||
|
||||
def test_escape_still_closes_dropdown():
|
||||
# Existing Escape-to-close behavior must not regress.
|
||||
assert "if(e.key==='Escape'){closeModelDropdown();return;}" in UI_JS
|
||||
|
||||
|
||||
def test_enter_picks_highlighted_row():
|
||||
# Enter should call .click() on the highlighted (or first) row.
|
||||
snippet = UI_JS[UI_JS.index("ArrowDown"):]
|
||||
assert "pick.click()" in snippet
|
||||
@@ -25,6 +25,8 @@ def test_new_session_sets_visible_pending_state_for_cold_catalog_wait():
|
||||
assert "function _setNewSessionPending(pending)" in src
|
||||
assert "btn.disabled=!!pending" in src
|
||||
assert "btn.setAttribute('aria-busy',pending?'true':'false')" in src
|
||||
assert "const ids=['btnNewChat','btnTitlebarNewChat']" in src
|
||||
assert "btnTitlebarNewChat" in src
|
||||
assert "setComposerStatus(pendingText)" in src
|
||||
assert "t('new_session_creating')" in src
|
||||
|
||||
@@ -33,5 +35,6 @@ def test_new_session_pending_button_style_and_copy_exist():
|
||||
css = _source("static/style.css")
|
||||
i18n = _source("static/i18n.js")
|
||||
assert '.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]' in css
|
||||
assert '.app-titlebar-new-chat:disabled,.app-titlebar-new-chat[aria-busy="true"]' in css
|
||||
assert "cursor:wait" in css
|
||||
assert "new_session_creating: 'Creating new conversation…'" in i18n
|
||||
|
||||
@@ -619,6 +619,54 @@ def test_new_conversation_shortcut_works_while_busy():
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_titlebar_has_new_conversation_button():
|
||||
"""Mobile titlebar shows the New Conversation action and keeps it next to reload."""
|
||||
header_match = re.search(
|
||||
r'<header class="app-titlebar"[^>]*>(?P<body>.*?)</header>',
|
||||
HTML,
|
||||
re.S,
|
||||
)
|
||||
assert header_match, "app-titlebar header block missing"
|
||||
header_html = header_match.group("body")
|
||||
|
||||
idx_btn = header_html.find('id="btnTitlebarNewChat"')
|
||||
idx_reload = header_html.find('id="btnReload"')
|
||||
idx_spacer = header_html.find('class="app-titlebar-spacer"')
|
||||
|
||||
assert idx_btn != -1, "titlebar mobile new chat button should exist"
|
||||
assert idx_reload != -1, "titlebar reload button should remain present"
|
||||
assert idx_spacer != -1, "titlebar spacer should remain present"
|
||||
assert idx_spacer < idx_btn < idx_reload, (
|
||||
"titlebar new chat button must sit left of the reload button on mobile"
|
||||
)
|
||||
assert "btnTitlebarNewChat" in header_html
|
||||
assert "data-i18n-title=\"new_conversation\"" in header_html
|
||||
assert "data-i18n-aria-label=\"new_conversation\"" in header_html
|
||||
assert "aria-label=\"New conversation\"" in header_html
|
||||
assert "title=\"New conversation\"" in header_html
|
||||
assert "$('btnNewChat').click()" in header_html
|
||||
|
||||
|
||||
def test_titlebar_new_chat_button_mobile_visibility_css():
|
||||
"""Keep the titlebar new-chat control mobile-only and reuse reload button styling."""
|
||||
base_rule = _declarations(_rule_body(CSS, ".app-titlebar-new-chat"))
|
||||
assert base_rule.get("display") == "none", "app-titlebar new chat button must be hidden by default"
|
||||
mobile_blocks = "".join(_max_width_media_blocks(640))
|
||||
mobile_rule = _declarations(_rule_body(mobile_blocks, ".app-titlebar-new-chat"))
|
||||
assert mobile_rule.get("display") == "inline-flex", (
|
||||
"app-titlebar new chat button must be visible in mobile layout rules"
|
||||
)
|
||||
desktop_css = re.sub(
|
||||
r"@media\(max-width:640px\).*",
|
||||
"",
|
||||
CSS,
|
||||
flags=re.S,
|
||||
)
|
||||
assert ".app-titlebar-new-chat{display:inline-flex;}" not in desktop_css, (
|
||||
"titlebar new chat button must not be exposed by desktop PWA/fullscreen rules"
|
||||
)
|
||||
|
||||
|
||||
# ── Viewport and scroll safety ────────────────────────────────────────────────
|
||||
|
||||
def test_body_overflow_hidden():
|
||||
|
||||
Reference in New Issue
Block a user