diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd401e1..543e7e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/boot.js b/static/boot.js index e4134258..15e51e9d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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 diff --git a/static/panels.js b/static/panels.js index b744cebb..2be1fa33 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2529,7 +2529,7 @@ function _renderProfileForm(){
- +
${esc(t('profile_name_rule') || 'Lowercase letters, numbers, hyphens, underscores only.')}
@@ -2539,7 +2539,7 @@ function _renderProfileForm(){
- +
diff --git a/tests/test_1432_newchat_and_1423_profile_input.py b/tests/test_1432_newchat_and_1423_profile_input.py new file mode 100644 index 00000000..05c1dd6e --- /dev/null +++ b/tests/test_1432_newchat_and_1423_profile_input.py @@ -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']*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)" diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index f8acd21f..904130e2 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -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"