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:
commit
c8f2daa990
7 changed files with 167 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>/
|
||||
|
||||
|
|
|
|||
|
|
@ -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>/*
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue