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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
tests/test_1432_newchat_and_1423_profile_input.py
Normal file
122
tests/test_1432_newchat_and_1423_profile_input.py
Normal 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)"
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user