fix: new-chat guard ignores in-flight streams (#1432) + profile form auto-capitalizes typed values (#1423)

Two unrelated UX bugs, both small surgical fixes with regression tests.

Issue #1432 — "+" button doesn't open new chat during streaming
================================================================
Reported by @Olyno: clicking "+" after sending a first message keeps
redirecting to the same chat instead of opening a new blank conversation,
making parallel chats impossible until the first response finishes.

Root cause:
  static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session
  guard from #1171 that skipped newSession() when message_count===0:

    if(S.session && (S.session.message_count||0)===0){
      $('msg').focus(); closeMobileSidebar(); return;
    }

  But during the first user turn of a brand-new session, message_count is
  still 0 server-side because the user message hasn't been merged into
  s.messages yet. The guard treated that as "empty" and silently dropped
  the click, blocking parallel chats for the entire stream duration.

Fix:
  Tighten the predicate to also exclude in-flight state:

    if(S.session
       && (S.session.message_count||0)===0
       && !S.busy
       && !S.session.active_stream_id
       && !S.session.pending_user_message){
      $('msg').focus(); closeMobileSidebar(); return;
    }

  Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight
  signal (active_stream_id || pending_user_message) is the same one
  _restoreSettledSession() in messages.js:1081 already uses to decide
  whether a session is "settled" — keeping both call sites aligned.

  Verified end-to-end: with S.busy=true and pending_user_message set, the
  old guard returned `block=true` (= the bug), the new guard returns
  `block=false` (= fixed). With a truly empty session (no busy, no pending),
  both old and new guards still block — preserving #1171 behavior.

Issue #1423 — Profile name field auto-capitalizes typed values
==============================================================
Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile
"Name" field shows `Hello` after blur/autofill, contradicting the
"Lowercase letters, numbers, hyphens, underscores only" hint right next
to it. The form lowercases on submit so stored data is correct, but the
displayed value during typing is misleading.

Root cause:
  static/panels.js:2532 had only autocomplete="off":

    <input type="text" id="profileFormName"
           placeholder="..." autocomplete="off" required>

  Missing three attributes that actually prevent the misbehavior:
  - autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome,
    WKWebView in the Mac app) auto-capitalize the first letter without it
  - autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello
  - spellcheck="false" — desktop browsers may run spellcheck on blur

Fix:
  Add the three attributes to profileFormName. Also added to
  profileFormBaseUrl since URLs are similarly bad targets for
  autocapitalize/autocorrect. profileFormApiKey is type="password" and
  already has correct browser behavior.

  Verified end-to-end against the live DOM: openProfileCreate() →
  getElementById('profileFormName').getAttribute(...) returns the new
  attributes correctly, with required preserved.

Tests
-----
3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests
in test_1432_newchat_and_1423_profile_input.py).

One pre-existing test had to be widened: tests/test_mobile_layout.py
test_new_conversation_closes_mobile_sidebar grabbed only the first 500
chars of the btnNewChat handler block to scan for closeMobileSidebar.
The new comment block pushed closeMobileSidebar past that window even
though both calls are still present. Bumped the window to 1500 chars
and the shortcut-block lines from 12 to 24 to match the multi-line guard.

Closes #1432
Closes #1423

Reported by @Olyno (#1432, GitHub)
This commit is contained in:
nesquena-hermes
2026-05-02 00:52:41 +00:00
parent 0dd4dd39c4
commit 26d0f45791
5 changed files with 163 additions and 10 deletions

View File

@@ -1,5 +1,11 @@
# Hermes Web UI -- Changelog
## [Unreleased]
### Fixed
- **New-chat button (`+`) and Cmd/Ctrl+K were no-ops while the first message was streaming** (#1432, closes #1432) — the empty-session guard from #1171 (`message_count===0` → focus composer instead of creating a new session) didn't account for in-flight streams, where the user's message hasn't been merged into `s.messages` server-side yet. Clicking `+` during the first response of a brand-new session was silently dropped, so users couldn't actually start a parallel conversation. The guard now also requires `!S.busy && !S.session.active_stream_id && !S.session.pending_user_message` — the same in-flight signal already used by `_restoreSettledSession()` in `messages.js:1081`. (`static/boot.js`)
- **Profile-name field auto-capitalized typed values despite the "lowercase only" hint** (#1423, closes #1423) — the input had `autocomplete="off"` but was missing `autocapitalize="none"`, `autocorrect="off"`, and `spellcheck="false"`, so mobile keyboards (iOS Safari/WKWebView, Android Chrome) silently capitalized the first letter and desktop spellcheck could rewrite the value on blur. The form lowercases on submit, so stored data was always correct — the bug was a misleading display during typing. Same attributes added to the Base URL field for the same reason (URLs are not natural-language text). The API key field is `type="password"` and already has correct browser behavior. (`static/panels.js`)
## [v0.50.261] — 2026-05-02
### Changed

View File

@@ -689,9 +689,24 @@ window._micPendingSend=window._micPendingSend||false;
})();
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();closeMobileSidebar();return;}
// If the current session has no messages AND nothing is in flight, just focus
// the composer rather than creating another empty session that will clutter the
// sidebar list (#1171).
//
// The "nothing in flight" half is critical (#1432): if the user clicks + while
// their first message is still streaming (or queued), `message_count` is still 0
// server-side because the user turn hasn't been merged yet. The old guard treated
// that as "empty" and made + a no-op for the entire stream duration, so users
// couldn't actually start a parallel chat. Use the same in-flight signal as
// `_restoreSettledSession()` in messages.js: an active stream id or a queued
// pending user message means the session is real, not empty.
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus();closeMobileSidebar();return;
}
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
};
$('btnDownload').onclick=()=>{
@@ -839,9 +854,17 @@ document.addEventListener('keydown',async e=>{
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
// If the current session has no messages AND nothing is in flight, just focus
// the composer rather than creating another empty session that will clutter
// the sidebar list (#1171). See the matching guard in $('btnNewChat').onclick
// and bug #1432 for why the in-flight check is needed.
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus();return;
}
// Cmd/Ctrl+K should always create a new conversation, even while the current
// one is still streaming. The old !S.busy guard meant users had to wait for
// a long generation to finish before they could start something new — exactly

View File

@@ -2529,7 +2529,7 @@ function _renderProfileForm(){
<form class="detail-form" onsubmit="event.preventDefault(); saveProfileForm();">
<div class="detail-form-row">
<label for="profileFormName">${esc(t('profile_name_label') || 'Name')}</label>
<input type="text" id="profileFormName" placeholder="${esc(t('profile_name_placeholder') || 'lowercase, a-z 0-9 hyphens')}" autocomplete="off" required>
<input type="text" id="profileFormName" placeholder="${esc(t('profile_name_placeholder') || 'lowercase, a-z 0-9 hyphens')}" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false" required>
<div class="detail-form-hint">${esc(t('profile_name_rule') || 'Lowercase letters, numbers, hyphens, underscores only.')}</div>
</div>
<div class="detail-form-row">
@@ -2539,7 +2539,7 @@ function _renderProfileForm(){
</div>
<div class="detail-form-row">
<label for="profileFormBaseUrl">${esc(t('profile_base_url_label') || 'Base URL')}</label>
<input type="text" id="profileFormBaseUrl" placeholder="${esc(t('profile_base_url_placeholder') || 'Optional, e.g. http://localhost:11434')}" autocomplete="off">
<input type="text" id="profileFormBaseUrl" placeholder="${esc(t('profile_base_url_placeholder') || 'Optional, e.g. http://localhost:11434')}" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false">
</div>
<div class="detail-form-row">
<label for="profileFormApiKey">${esc(t('profile_api_key_label') || 'API key')}</label>

View File

@@ -0,0 +1,122 @@
"""
Regression tests for #1432 (new-chat empty-session guard ignores in-flight streams)
and #1423 (profile name input lacks autocapitalize/spellcheck attrs).
Both bugs ship as static-asset diffs verified by reading the JS files.
"""
import os
import re
STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'static')
def _read(filename):
return open(os.path.join(STATIC_DIR, filename), encoding='utf-8').read()
class TestIssue1432NewChatGuardInFlight:
"""`+` button and Cmd/Ctrl+K must create a new chat even while the current
session is still streaming. The empty-session guard from #1171 was checking
`message_count===0` only, which is true the entire time the first user
message is in flight (server-side count not yet updated). The guard now
also requires `!S.busy && !S.session.active_stream_id &&
!S.session.pending_user_message` — same in-flight signal used at
`static/messages.js:_restoreSettledSession()`.
"""
def test_btnNewChat_handler_checks_in_flight_state(self):
src = _read('boot.js')
# Locate the btnNewChat onclick handler
m = re.search(
r"\$\('btnNewChat'\)\.onclick=async\(\)=>\{(.*?)\};",
src, re.DOTALL,
)
assert m, "btnNewChat onclick handler not found in boot.js"
body = m.group(1)
# The empty-session guard must check all three in-flight signals
assert 'message_count' in body, \
"btnNewChat guard missing message_count check"
assert 'S.busy' in body, \
"btnNewChat guard missing S.busy check (#1432)"
assert 'active_stream_id' in body, \
"btnNewChat guard missing active_stream_id check (#1432)"
assert 'pending_user_message' in body, \
"btnNewChat guard missing pending_user_message check (#1432)"
def test_cmdK_handler_checks_in_flight_state(self):
src = _read('boot.js')
# Locate the Cmd/Ctrl+K branch — it sits inside a keydown listener
idx = src.find("(e.metaKey||e.ctrlKey)&&e.key==='k'")
assert idx >= 0, "Cmd/Ctrl+K handler not found in boot.js"
# Read the next ~1500 chars (handler body)
body = src[idx:idx + 1500]
assert 'message_count' in body, \
"Cmd/Ctrl+K guard missing message_count check"
assert 'S.busy' in body, \
"Cmd/Ctrl+K guard missing S.busy check (#1432)"
assert 'active_stream_id' in body, \
"Cmd/Ctrl+K guard missing active_stream_id check (#1432)"
assert 'pending_user_message' in body, \
"Cmd/Ctrl+K guard missing pending_user_message check (#1432)"
def test_in_flight_signal_matches_restoreSettledSession(self):
"""The new in-flight check uses the same signal as the canonical
'session is in flight' detector at messages.js:_restoreSettledSession.
Verifying both files use the same shape so future refactors don't
diverge."""
msgs_src = _read('messages.js')
# The canonical detector
assert 'session.active_stream_id||session.pending_user_message' in msgs_src, \
"Canonical in-flight detector at _restoreSettledSession changed shape — " \
"boot.js #1432 fix uses the same signals; keep them aligned"
class TestIssue1423ProfileFormAutocapitalize:
"""Profile name and base-url inputs must suppress browser
auto-capitalization, autocorrect, and spell-check. Without these
attributes, mobile keyboards (iOS/Android) capitalize the first letter
and desktop spellcheck can rewrite the typed value on blur — even though
the placeholder/hint says lowercase only. The form lowercases on submit
so stored data is correct; the bug is purely a misleading display."""
def _profile_input_html(self, input_id):
src = _read('panels.js')
# Match the input element — pull the full opening tag
m = re.search(
rf'<input\s+[^>]*id="{re.escape(input_id)}"[^>]*>',
src,
)
return m.group(0) if m else None
def test_profile_name_has_autocapitalize_none(self):
html = self._profile_input_html('profileFormName')
assert html, "profileFormName input not found in panels.js"
assert 'autocapitalize="none"' in html, \
f"profileFormName missing autocapitalize=\"none\" (#1423): {html}"
def test_profile_name_has_spellcheck_false(self):
html = self._profile_input_html('profileFormName')
assert html, "profileFormName input not found"
assert 'spellcheck="false"' in html, \
f"profileFormName missing spellcheck=\"false\" (#1423): {html}"
def test_profile_name_has_autocorrect_off(self):
html = self._profile_input_html('profileFormName')
assert html, "profileFormName input not found"
assert 'autocorrect="off"' in html, \
f"profileFormName missing autocorrect=\"off\" (#1423): {html}"
def test_profile_name_keeps_required(self):
"""Regression guard: required must still be present."""
html = self._profile_input_html('profileFormName')
assert ' required' in html, \
f"profileFormName lost required attribute: {html}"
def test_profile_baseurl_has_autocapitalize_none(self):
"""Base URL inputs are equally bad targets for autocapitalize."""
html = self._profile_input_html('profileFormBaseUrl')
assert html, "profileFormBaseUrl input not found"
assert 'autocapitalize="none"' in html, \
f"profileFormBaseUrl missing autocapitalize=\"none\" (#1423)"
assert 'spellcheck="false"' in html, \
f"profileFormBaseUrl missing spellcheck=\"false\" (#1423)"

View File

@@ -463,14 +463,16 @@ def test_new_conversation_closes_mobile_sidebar():
# Handler is now multi-line — search for the full block rather than a single line.
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
# Find the handler block and verify closeMobileSidebar appears in it.
# The handler grew comments after #1432 (in-flight guard refactor), so use a
# generous window to cover the full handler body.
idx = boot_js.find("$('btnNewChat').onclick")
handler_block = boot_js[idx:idx+500]
handler_block = boot_js[idx:idx+1500]
assert "closeMobileSidebar" in handler_block, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+12])
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+24])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"