diff --git a/static/boot.js b/static/boot.js index 56efabc4..180a03e5 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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; diff --git a/static/panels.js b/static/panels.js index 0a4141ec..5304fa35 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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; diff --git a/tests/test_1062_busy_input_modes.py b/tests/test_1062_busy_input_modes.py index b7eb9ce5..680e09fc 100644 --- a/tests/test_1062_busy_input_modes.py +++ b/tests/test_1062_busy_input_modes.py @@ -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): diff --git a/tests/test_5167_busy_input_mode_boot.py b/tests/test_5167_busy_input_mode_boot.py new file mode 100644 index 00000000..ff0f7700 --- /dev/null +++ b/tests/test_5167_busy_input_mode_boot.py @@ -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