diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c0b290aa..2ed2f45a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/static/index.html b/static/index.html
index 5a8a90106..611a1a8f2 100644
--- a/static/index.html
+++ b/static/index.html
@@ -140,6 +140,12 @@
+
+
+
+
+
+
diff --git a/static/sessions.js b/static/sessions.js
index c02875732..282c8d249 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -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 #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;}
diff --git a/static/ui.js b/static/ui.js
index 6a1944bb5..d9fb1dac9 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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(); };
diff --git a/tests/test_2791_model_picker_keyboard_nav.py b/tests/test_2791_model_picker_keyboard_nav.py
new file mode 100644
index 000000000..749100242
--- /dev/null
+++ b/tests/test_2791_model_picker_keyboard_nav.py
@@ -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
diff --git a/tests/test_issue2518_new_session_inflight.py b/tests/test_issue2518_new_session_inflight.py
index ff71d95dc..912cedbae 100644
--- a/tests/test_issue2518_new_session_inflight.py
+++ b/tests/test_issue2518_new_session_inflight.py
@@ -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
diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py
index 9f2662bb6..606f407eb 100644
--- a/tests/test_mobile_layout.py
+++ b/tests/test_mobile_layout.py
@@ -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'',
+ 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():