Release v0.51.337 — Release LA (model-picker keyboard nav #2952 + mobile new-chat #3531) (#3857)

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:
nesquena-hermes
2026-06-08 18:47:55 -07:00
committed by GitHub
parent 3063c4930f
commit de4509702d
8 changed files with 134 additions and 4 deletions
+6
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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():