rebase #5170 onto current master (union-resolved busy-mode boot conflicts: keep persisted pref on settings-load-fail + preserve placeholder-hint/showBusyPlaceholderHint)

This commit is contained in:
nesquena-hermes committed 2026-07-01 01:40:53 +00:00
1 parent 05aa473141
commit 2ec7df8a27
4 files changed
+193 -10

No files matched your search

+36 -2
View File
@@ -940,6 +940,35 @@ function _micToastKeyForRecognitionError(error){
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
// ── Busy input mode eager default (#5167) ───────────────────────────────────
// The Busy input mode preference (queue/interrupt/steer) is read on the send
// path via `window._busyInputMode||'queue'`. The authoritative value only
// arrives once the async boot IIFE below resolves the `/api/settings` fetch.
// Without an eager value, every send during that boot window silently falls
// back to 'queue', ignoring a saved 'steer'/'interrupt' preference (worse on
// slow/contended environments like WSL2, see #5132). Mirror the resolved value
// into localStorage — the same synchronous-source pattern used by hermes-lang /
// hermes-theme — so the very first send after a reload honors the saved choice.
const _BUSY_INPUT_MODES=['queue','interrupt','steer'];
function _normalizeBusyInputMode(mode){
return _BUSY_INPUT_MODES.includes(mode)?mode:'queue';
}
function _persistBusyInputMode(mode){
const m=_normalizeBusyInputMode(mode);
try{localStorage.setItem('hermes-busy-input-mode',m);}catch(_){}
return m;
}
function _readPersistedBusyInputMode(){
let stored=null;
try{stored=localStorage.getItem('hermes-busy-input-mode');}catch(_){}
return _normalizeBusyInputMode(stored);
}
window._persistBusyInputMode=_persistBusyInputMode;
window._readPersistedBusyInputMode=_readPersistedBusyInputMode;
// Eager default set BEFORE the async settings fetch resolves so first sends in
// the boot window honor the persisted preference instead of defaulting to queue.
window._busyInputMode=_readPersistedBusyInputMode();
// ── Extension TTS-engine registry (registerHermesTtsEngine) ──────────────────
// Defined at MODULE scope (not inside the voice-mode IIFE below) so the public
// API exists even on browsers without SpeechRecognition / speechSynthesis — an
@@ -2528,7 +2557,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
};
window._busyInputMode=(s.busy_input_mode||'queue');
window._busyInputMode=_persistBusyInputMode(s.busy_input_mode);
window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint;
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._autoScrollFollow=s.auto_scroll_follow!==false;
@@ -2652,7 +2681,12 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
window._structuredCodeAutoTreeLines=10;
window._sidebarDensity='compact';
window._pinnedSessionsLimit=3;
window._busyInputMode='queue';
// Settings load failed: keep the persisted busy-input-mode preference (the
// eager default already read it from the localStorage mirror) instead of
// clobbering it with 'queue', so a saved 'steer'/'interrupt' still applies
// when the server is unreachable (#5167). The placeholder-hint has no
// persisted mirror, so it defaults off on failure.
window._busyInputMode=_readPersistedBusyInputMode();
window._showBusyPlaceholderHint=false;
window._sessionEndlessScrollEnabled=false;
window._autoScrollFollow=true;
+3 -1
View File
@@ -10417,7 +10417,9 @@ function _applySavedSettingsUi(saved, body, opts){
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
window._busyInputMode=body.busy_input_mode||'queue';
window._busyInputMode=(typeof _persistBusyInputMode==='function')
? _persistBusyInputMode(body.busy_input_mode)
: (body.busy_input_mode||'queue');
window._sessionEndlessScrollEnabled=!!body.session_endless_scroll;
window._autoScrollFollow=body.auto_scroll_follow!==false;
window._largeTextPasteAsAttachment=body.large_text_paste_as_attachment!==false;
+18 -7
View File
@@ -384,19 +384,30 @@ class TestSendBusyBranchDispatch:
class TestBootAndPanelsWiring:
def test_boot_init_default_path(self):
"""Boot success path initialises window._busyInputMode from settings."""
assert "window._busyInputMode=(s.busy_input_mode||'queue')" in BOOT_JS
"""Boot success path initialises window._busyInputMode from settings.
#5167: the assignment now routes through _persistBusyInputMode() so the
resolved value is mirrored into localStorage (the synchronous source the
eager default reads) while still being the single source of truth once
/api/settings resolves.
"""
assert "window._busyInputMode=_persistBusyInputMode(s.busy_input_mode)" in BOOT_JS
def test_boot_init_fallback_path(self):
"""Boot fallback path (settings load failed) initialises to safe default."""
# The fallback should set window._busyInputMode='queue'
assert "window._busyInputMode='queue'" in BOOT_JS
"""Boot fallback path (settings load failed) keeps the persisted preference.
#5167: instead of hard-clobbering to 'queue', the catch path re-reads the
persisted mirror so a saved 'steer'/'interrupt' still applies when the
server is unreachable. _readPersistedBusyInputMode() normalizes to 'queue'.
"""
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS
def test_panels_load_save_apply(self):
assert "settingsBusyInputMode" in PANELS_JS, "panels.js must load the setting"
assert "body.busy_input_mode" in PANELS_JS, "saveSettings must include busy_input_mode in body"
assert "window._busyInputMode=body.busy_input_mode" in PANELS_JS, (
"_applySavedSettingsUi must propagate busy_input_mode to the global"
assert "_persistBusyInputMode(body.busy_input_mode)" in PANELS_JS, (
"_applySavedSettingsUi must propagate busy_input_mode to the global "
"and persist it (so the next reload's eager default honors the save) (#5167)"
)
def test_index_html_dropdown_has_three_options(self):
+136
View File
@@ -0,0 +1,136 @@
"""Regression tests for the busy_input_mode boot race (#5167).
The Busy input mode preference (queue/interrupt/steer) was ignored for any send
that happened before the async boot IIFE resolved `await api('/api/settings')`,
because `window._busyInputMode` had NO eager default — it was `undefined` during
the boot window, so the send path's `window._busyInputMode||'queue'` silently
fell back to 'queue'. Re-saving from the settings panel set the global
synchronously, which is why "re-save fixes it".
The fix gives `window._busyInputMode` a deterministic value at script top, read
synchronously from a localStorage mirror ('hermes-busy-input-mode') that the
resolved settings + the save handler both keep in sync — mirroring how
hermes-lang / hermes-theme are bootstrapped from a synchronous source.
Reported by @b3nw. Issue: #5167.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestEagerDefault:
"""window._busyInputMode must be set eagerly, before the async settings fetch."""
def test_eager_default_assigned_at_module_scope(self):
"""An eager top-level assignment must exist so first sends honor the preference."""
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS, (
"boot.js must eagerly initialise window._busyInputMode from the persisted "
"mirror at module scope so sends during the boot window don't default to queue"
)
def test_eager_default_precedes_async_settings_fetch(self):
"""The eager assignment must appear BEFORE the async boot IIFE's /api/settings await.
This is the whole point of the fix: the value must be deterministic during
the window between page load and the settings fetch resolving.
"""
eager_idx = BOOT_JS.find("window._busyInputMode=_readPersistedBusyInputMode()")
assert eager_idx >= 0, "eager default assignment not found"
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
await_idx = BOOT_JS.find("const s=await api('/api/settings')")
assert await_idx >= 0, "async settings fetch not found"
assert eager_idx < await_idx, (
"the eager window._busyInputMode default must be set BEFORE the async "
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
)
def test_eager_default_precedes_send_definition(self):
"""The eager default in boot.js loads after messages.js (defer order), but the
assignment itself must sit at top level so it runs during script evaluation,
not inside a later-firing callback."""
eager_idx = BOOT_JS.find("window._busyInputMode=_readPersistedBusyInputMode()")
# Must be at the start of a line (top-level statement), not indented inside a fn.
line_start = BOOT_JS.rfind("\n", 0, eager_idx) + 1
assert BOOT_JS[line_start:eager_idx].strip() == "", (
"the eager default must be a top-level statement (not nested in a function "
"or callback) so it runs during script evaluation"
)
class TestSyncMirrorHelpers:
"""Helper functions backing the synchronous localStorage mirror."""
def test_persist_helper_defined_and_exposed(self):
assert "function _persistBusyInputMode(" in BOOT_JS
assert "window._persistBusyInputMode=_persistBusyInputMode" in BOOT_JS
def test_read_helper_defined_and_exposed(self):
assert "function _readPersistedBusyInputMode(" in BOOT_JS
assert "window._readPersistedBusyInputMode=_readPersistedBusyInputMode" in BOOT_JS
def test_mirror_uses_dedicated_localstorage_key(self):
assert "localStorage.setItem('hermes-busy-input-mode'" in BOOT_JS, (
"persist helper must write the busy mode to a dedicated localStorage key"
)
assert "localStorage.getItem('hermes-busy-input-mode')" in BOOT_JS, (
"read helper must read the busy mode from the same localStorage key"
)
def test_normalize_validates_against_known_modes(self):
"""Unknown/garbage persisted values must normalize to 'queue', never crash."""
assert "function _normalizeBusyInputMode(" in BOOT_JS
assert "['queue','interrupt','steer']" in BOOT_JS
def test_localstorage_access_is_guarded(self):
"""localStorage can throw (privacy mode / disabled). Helpers must try/catch."""
idx = BOOT_JS.find("function _persistBusyInputMode(")
body = BOOT_JS[idx:idx + 400]
assert "try{" in body and "catch(_)" in body, (
"_persistBusyInputMode must guard localStorage access against exceptions"
)
idx = BOOT_JS.find("function _readPersistedBusyInputMode(")
body = BOOT_JS[idx:idx + 400]
assert "try{" in body and "catch(_)" in body, (
"_readPersistedBusyInputMode must guard localStorage access against exceptions"
)
class TestAssignmentSitesSyncMirror:
"""Every place that sets window._busyInputMode must keep the mirror in sync."""
def test_boot_success_path_persists(self):
assert "window._busyInputMode=_persistBusyInputMode(s.busy_input_mode)" in BOOT_JS
def test_boot_catch_path_reads_persisted(self):
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS
def test_panels_save_persists(self):
assert "_persistBusyInputMode(body.busy_input_mode)" in PANELS_JS, (
"the settings save handler must persist busy_input_mode so the next "
"reload's eager default reads the saved value"
)
def test_panels_save_guards_helper_availability(self):
"""panels.js loads before boot.js (defer order); the save handler must
guard the helper with typeof so a regression in load order can't throw."""
idx = PANELS_JS.find("_persistBusyInputMode(body.busy_input_mode)")
assert idx >= 0
window = PANELS_JS[max(0, idx - 120):idx]
assert "typeof _persistBusyInputMode==='function'" in window, (
"panels.js save handler must guard _persistBusyInputMode with a typeof check"
)
class TestReadSitesUnchanged:
"""The defensive read sites must keep their ||'queue' fallback (belt + braces)."""
def test_messages_read_site_intact(self):
assert "const busyMode=window._busyInputMode||'queue';" in MESSAGES_JS
def test_ui_read_site_intact(self):
assert "const busyMode=window._busyInputMode||'queue';" in UI_JS