Merge pull request #1435 from nesquena/fix/profile-autocapitalize-and-newchat-guard

fix: new-chat guard ignores in-flight streams (#1432) + profile form auto-capitalizes (#1423)
This commit is contained in:
nesquena-hermes 2026-05-01 18:04:09 -07:00 committed by GitHub
commit c8f2daa990
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 167 additions and 14 deletions

View file

@ -1,5 +1,11 @@
# Hermes Web UI -- Changelog
## [v0.50.262] — 2026-05-02
### 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`. Reported by @Olyno. (`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

@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.245 (April 30, 2026) — 3309 tests collected
> Last updated: v0.50.262 (May 2, 2026) — 3648 tests collected
> Tests: `pytest tests/ --collect-only -q`
> Source: <repo>/

View file

@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated coverage: 3309 tests collected via `pytest tests/ --collect-only -q`. Tests run on every PR via GitHub Actions on Python 3.11, 3.12, and 3.13. The suite covers the bootstrap/static wizard, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, CSS regression coverage for thinking/tool card animation, streaming session persistence, mobile layout breakpoints, locale parity across 9 languages, and ~700 issue/PR-pinned regression tests.
> Automated coverage: 3648 tests collected via `pytest tests/ --collect-only -q`. Tests run on every PR via GitHub Actions on Python 3.11, 3.12, and 3.13. The suite covers the bootstrap/static wizard, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, CSS regression coverage for thinking/tool card animation, streaming session persistence, mobile layout breakpoints, locale parity across 9 languages, and ~700 issue/PR-pinned regression tests.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.50.245, April 30, 2026*
*Total automated tests collected: 3309*
*Last updated: v0.50.262, May 2, 2026*
*Total automated tests collected: 3648*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

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"