From ac5a8493012da965633c60d795668645ebc4a0d4 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 25 Jun 2026 22:48:03 -0400 Subject: [PATCH 01/10] feat(#4961): support URL query prefill for browser search --- static/boot.js | 42 ++++- static/sessions.js | 34 ++++ tests/test_4961_url_query_prefill.py | 230 +++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 tests/test_4961_url_query_prefill.py diff --git a/static/boot.js b/static/boot.js index bddf8cec..6792af0c 100644 --- a/static/boot.js +++ b/static/boot.js @@ -99,6 +99,22 @@ async function _savedSessionSidebarOnlyState(sid){ } // ── Mobile navigation ────────────────────────────────────────────────────── +// URL prefill boot helpers. +function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){ + return !urlSession&&!!savedLocal&&!!(prefillIntent&&prefillIntent.hasText); +} +async function _applyComposerPrefillOnBoot(prefillIntent){ + if(!prefillIntent||!prefillIntent.hasText) return; + const msg=(typeof $==='function')?$('msg'):document.getElementById('msg'); + if(!msg) return; + const text=String(prefillIntent.text||''); + msg.value=text; + // Boot prefill is composer-only; URL params never submit on the user's behalf. + if(typeof autoResize==='function') autoResize(); + else if(typeof updateSendBtn==='function') updateSendBtn(); +} + +// Mobile navigation. let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' function _isCompactWorkspaceViewport(){ @@ -2362,6 +2378,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; (async()=>{ // Load send key preference let _bootSettings={}; + const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null; + if(prefillIntent&&prefillIntent.hasParams&&typeof _consumeComposerPrefillParamsFromLocation==='function'){ + _consumeComposerPrefillParamsFromLocation(); + } try{ const s=await api('/api/settings'); _bootSettings=s; @@ -2749,7 +2769,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; await newSession(true); if(S.session) await _startBootModelDropdown(); S._bootReady=true; - syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return; + syncTopbar();syncWorkspacePanelState();await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return; }catch(e){console.warn('[pwa] new-chat launch action failed', e);} } const savedLocal=localStorage.getItem('hermes-webui-session'); @@ -2767,7 +2787,19 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; S._bootReady=true; syncTopbar();syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE(); + await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); + return; + } + if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){ + S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false; + S._bootReady=true; + const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open' + || localStorage.getItem('hermes-webui-workspace-panel')==='open'; + if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse'; + await _maybeBindFreshDefaultWorkspaceSession(); + syncTopbar();syncWorkspacePanelState(); + $('emptyState').style.display=''; + await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); return; } await loadSession(saved, {preserveActiveInput:true}); @@ -2805,7 +2837,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; await _maybeBindFreshDefaultWorkspaceSession(); syncTopbar();syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE(); + await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); return; } // Restore the panel from localStorage when the session has a workspace. @@ -2817,7 +2849,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; _workspacePanelMode='browse'; } S._bootReady=true; - syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} + syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);await _applyComposerPrefillOnBoot(prefillIntent);return;} catch(e){localStorage.removeItem('hermes-webui-session');} } // no saved session - show empty state, wait for user to hit + @@ -2831,7 +2863,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; await _maybeBindFreshDefaultWorkspaceSession(); syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList(); + await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent); // Start real-time gateway session sync if setting is enabled if(typeof startGatewaySSE==='function') startGatewaySSE(); })().catch(e=>{ diff --git a/static/sessions.js b/static/sessions.js index b8a49e80..ab37e975 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3220,6 +3220,37 @@ function _sessionIdFromLocation(){ return qs.get('session')||qs.get('session_id')||null; }catch(_e){return null;} } +function _composerPrefillIntentFromLocation(){ + const empty={hasParams:false,hasText:false,text:''}; + if(typeof window==='undefined'||!window.location) return empty; + try{ + const qs=new URLSearchParams(window.location.search||''); + const hasQ=qs.has('q'); + const hasPrompt=qs.has('prompt'); + const hasSend=qs.has('send'); + if(!hasQ&&!hasPrompt&&!hasSend) return empty; + const text=hasQ?(qs.get('q')||''):(hasPrompt?(qs.get('prompt')||''):''); + return { + hasParams:true, + hasText:!!String(text).trim(), + text + }; + }catch(_e){return empty;} +} +function _consumeComposerPrefillParamsFromLocation(){ + if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return; + try{ + const current=new URL(window.location.href); + const before=current.searchParams.toString(); + current.searchParams.delete('q'); + current.searchParams.delete('prompt'); + current.searchParams.delete('send'); + const after=current.searchParams.toString(); + if(after===before) return; + const next=current.pathname+(after?`?${after}`:'')+(current.hash||''); + window.history.replaceState(window.history.state||null,'',next); + }catch(_e){} +} function _appRootPath(){ try{ const base = new URL(document.baseURI||window.location.origin+'/', window.location.origin); @@ -3235,6 +3266,9 @@ function _sessionUrlForSid(sid){ const current=new URL(window.location.href); current.searchParams.delete('session'); current.searchParams.delete('session_id'); + current.searchParams.delete('q'); + current.searchParams.delete('prompt'); + current.searchParams.delete('send'); base.search=current.searchParams.toString(); base.hash=current.hash; }catch(_e){} diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py new file mode 100644 index 00000000..dc74a470 --- /dev/null +++ b/tests/test_4961_url_query_prefill.py @@ -0,0 +1,230 @@ +"""Regression tests for #4961 URL query composer prefill behavior.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js" +BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js" +SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8") +BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(source: str) -> str: + result = subprocess.run( + [NODE], + input=source, + cwd=str(REPO_ROOT), + capture_output=True, + encoding="utf-8", + text=True, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return result.stdout.strip() + + +def _node_prelude() -> str: + return f""" +const sessionsSrc = {SESSIONS_JS!r}; +const bootSrc = {BOOT_JS!r}; +function extractFunc(src, name) {{ + const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function evalSession(name) {{ + globalThis[name] = (0, eval)('(' + extractFunc(sessionsSrc, name) + ')'); +}} +function evalBoot(name) {{ + globalThis[name] = (0, eval)('(' + extractFunc(bootSrc, name) + ')'); +}} +""" + + +def test_prefill_intent_parses_q_prompt_and_send_flag(): + source = _node_prelude() + """ +global.window = { location: { search: '?prompt=backup&q=hello%20world&send=YES' } }; +evalSession('_composerPrefillIntentFromLocation'); +const first = _composerPrefillIntentFromLocation(); +window.location.search = '?prompt=from+prompt'; +const second = _composerPrefillIntentFromLocation(); +window.location.search = '?q=%20%20&send=1'; +const third = _composerPrefillIntentFromLocation(); +console.log(JSON.stringify({ first, second, third })); +""" + payload = json.loads(_run_node(source)) + assert payload["first"] == { + "hasParams": True, + "hasText": True, + "text": "hello world", + } + assert payload["second"] == { + "hasParams": True, + "hasText": True, + "text": "from prompt", + } + assert payload["third"] == { + "hasParams": True, + "hasText": False, + "text": " ", + } + + +def test_prefill_cleanup_removes_only_consumed_query_params(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: { from: 'test' }, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +applyUrl('/app/?q=hello&prompt=backup&send=1&session_id=target&keep=1#frag'); +evalSession('_consumeComposerPrefillParamsFromLocation'); +evalSession('_sessionUrlForSid'); +_consumeComposerPrefillParamsFromLocation(); +const cleaned = window.history.calls[0]; +const promoted = _sessionUrlForSid('abc 123'); +console.log(JSON.stringify({ cleaned, promoted })); +""" + payload = json.loads(_run_node(source)) + assert payload["cleaned"]["url"] == "/app/?session_id=target&keep=1#frag" + assert payload["cleaned"]["state"] == {"from": "test"} + assert payload["promoted"] == "/app/session/abc%20123?keep=1#frag" + + +def test_root_prefill_keeps_saved_local_sidebar_only(): + source = _node_prelude() + """ +evalBoot('_rootPrefillNeedsFreshComposer'); +const result = { + savedLocalWins: _rootPrefillNeedsFreshComposer(null, 'saved-local', { hasText: true }), + explicitSessionWins: _rootPrefillNeedsFreshComposer('url-session', 'saved-local', { hasText: true }), + blankPrefillIgnored: _rootPrefillNeedsFreshComposer(null, 'saved-local', { hasText: false }) +}; +console.log(JSON.stringify(result)); +""" + payload = json.loads(_run_node(source)) + assert payload == { + "savedLocalWins": True, + "explicitSessionWins": False, + "blankPrefillIgnored": False, + } + prefill_guard = BOOT_JS.find("if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){") + saved_guard = BOOT_JS.find("if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){") + load_pos = BOOT_JS.find("await loadSession(saved);") + assert prefill_guard >= 0 + assert saved_guard > prefill_guard + assert load_pos > prefill_guard + + +def test_apply_prefill_updates_composer_without_autosend(): + source = _node_prelude() + """ +(async () => { + evalBoot('_applyComposerPrefillOnBoot'); + const counts = { autoResize: 0, updateSendBtn: 0, send: 0 }; + const msg = { value: '' }; + global.document = { + getElementById(id) { + return id === 'msg' ? msg : null; + } + }; + global.$ = (id) => document.getElementById(id); + global.autoResize = () => { counts.autoResize++; }; + global.updateSendBtn = () => { counts.updateSendBtn++; }; + global.send = async () => { counts.send++; }; + await _applyComposerPrefillOnBoot({ hasText: true, text: 'hello world', autoSend: true }); + delete global.autoResize; + await _applyComposerPrefillOnBoot({ hasText: true, text: 'second pass', autoSend: false }); + await _applyComposerPrefillOnBoot({ hasText: false, text: ' ', autoSend: true }); + console.log(JSON.stringify({ counts, value: msg.value })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 0} + assert payload["value"] == "second pass" + + +def test_explicit_session_url_keeps_target_session_with_prefill(): + source = _node_prelude() + """ +global.window = { + location: { + pathname: '/session/url-target', + search: '?q=hello%20world&send=1', + hash: '' + } +}; +evalSession('_sessionIdFromLocation'); +evalBoot('_rootPrefillNeedsFreshComposer'); +console.log(JSON.stringify({ + urlSession: _sessionIdFromLocation(), + bypassRootOverride: _rootPrefillNeedsFreshComposer(_sessionIdFromLocation(), 'saved-local', { hasText: true }) +})); +""" + payload = json.loads(_run_node(source)) + assert payload == {"urlSession": "url-target", "bypassRootOverride": False} + query_source = _node_prelude() + """ +global.window = { + location: { + pathname: '/', + search: '?session_id=query-target&q=hello%20world&send=1', + hash: '' + } +}; +evalSession('_sessionIdFromLocation'); +evalBoot('_rootPrefillNeedsFreshComposer'); +console.log(JSON.stringify({ + urlSession: _sessionIdFromLocation(), + bypassRootOverride: _rootPrefillNeedsFreshComposer(_sessionIdFromLocation(), 'saved-local', { hasText: true }) +})); +""" + query_payload = json.loads(_run_node(query_source)) + assert query_payload == {"urlSession": "query-target", "bypassRootOverride": False} + prefill_pos = BOOT_JS.find( + "const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;" + ) + consume_pos = BOOT_JS.find("_consumeComposerPrefillParamsFromLocation();", prefill_pos) + first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", consume_pos) + new_pos = BOOT_JS.find("await newSession(true);", consume_pos) + load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});", consume_pos) + saved_pos = BOOT_JS.find("const saved=urlSession||savedLocal;") + check_pos = BOOT_JS.find("await checkInflightOnBoot(saved);", load_pos) + apply_pos = BOOT_JS.find("await _applyComposerPrefillOnBoot(prefillIntent);", check_pos) + assert saved_pos >= 0 + assert prefill_pos >= 0 + assert consume_pos > prefill_pos + assert 0 <= consume_pos < first_await_pos + assert 0 <= consume_pos < new_pos + assert 0 <= consume_pos < load_pos + assert 0 <= check_pos < apply_pos From 93d356c0d426aa9bce6b866741f4f2f7730f32d7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 25 Jun 2026 22:55:00 -0400 Subject: [PATCH 02/10] fix(#4961): preserve prefill on zero-message session restore --- tests/test_4961_url_query_prefill.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index dc74a470..2da60b1a 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -228,3 +228,8 @@ console.log(JSON.stringify({ assert 0 <= consume_pos < new_pos assert 0 <= consume_pos < load_pos assert 0 <= check_pos < apply_pos + zero_message_pos = BOOT_JS.find( + "await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await _applyComposerPrefillOnBoot(prefillIntent);", + load_pos, + ) + assert zero_message_pos > load_pos From a527ca734f2a213526a8d250daad41c8719c951c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 25 Jun 2026 23:31:04 -0400 Subject: [PATCH 03/10] test(frontend): keep saved-session boot coverage aligned (#4961) --- tests/test_new_chat_default_model_frontend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_new_chat_default_model_frontend.py b/tests/test_new_chat_default_model_frontend.py index 6506447b..2e70592b 100644 --- a/tests/test_new_chat_default_model_frontend.py +++ b/tests/test_new_chat_default_model_frontend.py @@ -60,7 +60,8 @@ def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip() boot_js = Path("static/boot.js").read_text(encoding="utf-8") load_marker = "await loadSession(saved, {preserveActiveInput:true});" assert load_marker in boot_js - saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index("await checkInflightOnBoot(saved);return;", boot_js.index(load_marker))] + restore_end = "await checkInflightOnBoot(saved);" + saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index(restore_end, boot_js.index(load_marker))] assert "await _startBootModelDropdown();" in saved_restore assert saved_restore.index("await _startBootModelDropdown();") > saved_restore.index(load_marker) assert saved_restore.index("await _startBootModelDropdown();") < saved_restore.index("S._bootReady=true;"), ( From 1bd9e52e5f3a79c8472ca032fb03ffe1b3b2f077 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 09:11:20 -0400 Subject: [PATCH 04/10] test(#4961): lock the guard ordering to the rebased boot flow --- tests/test_4961_url_query_prefill.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index 2da60b1a..1079b7d7 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -139,10 +139,16 @@ console.log(JSON.stringify(result)); "blankPrefillIgnored": False, } prefill_guard = BOOT_JS.find("if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){") - saved_guard = BOOT_JS.find("if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){") - load_pos = BOOT_JS.find("await loadSession(saved);") + saved_state_pos = BOOT_JS.find("const savedSidebarOnlyState=(!urlSession&&savedLocal)") + saved_guard = BOOT_JS.find( + "if(savedSidebarOnlyState&&savedSidebarOnlyState.sidebarOnly){", + saved_state_pos, + ) + load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});") assert prefill_guard >= 0 - assert saved_guard > prefill_guard + assert saved_state_pos >= 0 + assert saved_guard > saved_state_pos + assert prefill_guard > saved_guard assert load_pos > prefill_guard @@ -229,7 +235,7 @@ console.log(JSON.stringify({ assert 0 <= consume_pos < load_pos assert 0 <= check_pos < apply_pos zero_message_pos = BOOT_JS.find( - "await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await _applyComposerPrefillOnBoot(prefillIntent);", + "await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();", load_pos, ) assert zero_message_pos > load_pos From 71cd69cfbf34f445fe20962c29bc9bcd3e8d1cb8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 09:55:19 -0400 Subject: [PATCH 05/10] fix(#4961): honor send query prefill flag --- static/boot.js | 7 ++++++- static/sessions.js | 6 ++++-- tests/test_4961_url_query_prefill.py | 7 +++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/static/boot.js b/static/boot.js index 6792af0c..adc34a67 100644 --- a/static/boot.js +++ b/static/boot.js @@ -109,9 +109,14 @@ async function _applyComposerPrefillOnBoot(prefillIntent){ if(!msg) return; const text=String(prefillIntent.text||''); msg.value=text; - // Boot prefill is composer-only; URL params never submit on the user's behalf. if(typeof autoResize==='function') autoResize(); else if(typeof updateSendBtn==='function') updateSendBtn(); + if(!prefillIntent.autoSend) return; + if(typeof handleComposerPrimaryAction==='function'){ + await handleComposerPrimaryAction(); + return; + } + if(typeof send==='function') await send(); } // Mobile navigation. diff --git a/static/sessions.js b/static/sessions.js index ab37e975..0e47c45a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3221,7 +3221,7 @@ function _sessionIdFromLocation(){ }catch(_e){return null;} } function _composerPrefillIntentFromLocation(){ - const empty={hasParams:false,hasText:false,text:''}; + const empty={hasParams:false,hasText:false,text:'',autoSend:false}; if(typeof window==='undefined'||!window.location) return empty; try{ const qs=new URLSearchParams(window.location.search||''); @@ -3230,10 +3230,12 @@ function _composerPrefillIntentFromLocation(){ const hasSend=qs.has('send'); if(!hasQ&&!hasPrompt&&!hasSend) return empty; const text=hasQ?(qs.get('q')||''):(hasPrompt?(qs.get('prompt')||''):''); + const autoSend=hasSend&&/^(1|true|yes|on)$/i.test(String(qs.get('send')||'').trim()); return { hasParams:true, hasText:!!String(text).trim(), - text + text, + autoSend }; }catch(_e){return empty;} } diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index 1079b7d7..08e06b81 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -73,16 +73,19 @@ console.log(JSON.stringify({ first, second, third })); "hasParams": True, "hasText": True, "text": "hello world", + "autoSend": True, } assert payload["second"] == { "hasParams": True, "hasText": True, "text": "from prompt", + "autoSend": False, } assert payload["third"] == { "hasParams": True, "hasText": False, "text": " ", + "autoSend": True, } @@ -152,7 +155,7 @@ console.log(JSON.stringify(result)); assert load_pos > prefill_guard -def test_apply_prefill_updates_composer_without_autosend(): +def test_apply_prefill_updates_composer_and_optionally_autosends(): source = _node_prelude() + """ (async () => { evalBoot('_applyComposerPrefillOnBoot'); @@ -178,7 +181,7 @@ def test_apply_prefill_updates_composer_without_autosend(): }); """ payload = json.loads(_run_node(source)) - assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 0} + assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 1} assert payload["value"] == "second pass" From faed296819c68d341935110c97d2b9ea90df7de3 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 29 Jun 2026 10:03:36 -0400 Subject: [PATCH 06/10] fix(#4961): keep URL boot prefill draft-only --- static/boot.js | 6 ------ static/sessions.js | 3 +-- tests/test_4961_url_query_prefill.py | 8 ++++---- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/static/boot.js b/static/boot.js index adc34a67..5931b464 100644 --- a/static/boot.js +++ b/static/boot.js @@ -111,12 +111,6 @@ async function _applyComposerPrefillOnBoot(prefillIntent){ msg.value=text; if(typeof autoResize==='function') autoResize(); else if(typeof updateSendBtn==='function') updateSendBtn(); - if(!prefillIntent.autoSend) return; - if(typeof handleComposerPrimaryAction==='function'){ - await handleComposerPrimaryAction(); - return; - } - if(typeof send==='function') await send(); } // Mobile navigation. diff --git a/static/sessions.js b/static/sessions.js index 0e47c45a..5108f500 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3230,12 +3230,11 @@ function _composerPrefillIntentFromLocation(){ const hasSend=qs.has('send'); if(!hasQ&&!hasPrompt&&!hasSend) return empty; const text=hasQ?(qs.get('q')||''):(hasPrompt?(qs.get('prompt')||''):''); - const autoSend=hasSend&&/^(1|true|yes|on)$/i.test(String(qs.get('send')||'').trim()); return { hasParams:true, hasText:!!String(text).trim(), text, - autoSend + autoSend:false }; }catch(_e){return empty;} } diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index 08e06b81..f69280e0 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -73,7 +73,7 @@ console.log(JSON.stringify({ first, second, third })); "hasParams": True, "hasText": True, "text": "hello world", - "autoSend": True, + "autoSend": False, } assert payload["second"] == { "hasParams": True, @@ -85,7 +85,7 @@ console.log(JSON.stringify({ first, second, third })); "hasParams": True, "hasText": False, "text": " ", - "autoSend": True, + "autoSend": False, } @@ -155,7 +155,7 @@ console.log(JSON.stringify(result)); assert load_pos > prefill_guard -def test_apply_prefill_updates_composer_and_optionally_autosends(): +def test_apply_prefill_updates_composer_without_autosending(): source = _node_prelude() + """ (async () => { evalBoot('_applyComposerPrefillOnBoot'); @@ -181,7 +181,7 @@ def test_apply_prefill_updates_composer_and_optionally_autosends(): }); """ payload = json.loads(_run_node(source)) - assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 1} + assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 0} assert payload["value"] == "second pass" From 30fd052efa80d5ac9030b42db2e2ffefd8720598 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 29 Jun 2026 14:29:47 -0400 Subject: [PATCH 07/10] fix(boot): preserve native Ctrl+K text editing during URL prefill rework (#4961) --- static/boot.js | 3 + .../test_issue5099_ctrl_k_text_input_guard.py | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/test_issue5099_ctrl_k_text_input_guard.py diff --git a/static/boot.js b/static/boot.js index 5931b464..9c3aaa5d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1753,6 +1753,9 @@ document.addEventListener('keydown',async e=>{ } } if((e.metaKey||e.ctrlKey)&&e.key==='k'){ + const t=e.target; + const isText=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable); + if(isText) return; e.preventDefault(); // 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 diff --git a/tests/test_issue5099_ctrl_k_text_input_guard.py b/tests/test_issue5099_ctrl_k_text_input_guard.py new file mode 100644 index 00000000..fd31dced --- /dev/null +++ b/tests/test_issue5099_ctrl_k_text_input_guard.py @@ -0,0 +1,55 @@ +"""Static-analysis tests for #5099 (Ctrl+K must not steal kill-line in text fields). + +Emacs-adjacent users expect Ctrl+K to kill to end-of-line while the composer +or other editable fields are focused. Cmd/Ctrl+K should still create a new chat +when focus is outside text inputs, matching the existing Ctrl+B guard pattern. +""" +from pathlib import Path + +BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8") + + +def _ctrl_k_branch_window() -> str: + idx = BOOT_JS.find("(e.metaKey||e.ctrlKey)&&e.key==='k'") + assert idx >= 0, "Cmd/Ctrl+K handler not found in boot.js" + return BOOT_JS[idx:idx + 1500] + + +class TestIssue5099CtrlKTextInputGuard: + def test_ctrl_k_skips_text_inputs(self): + branch = _ctrl_k_branch_window() + assert "tagName==='INPUT'" in branch + assert "tagName==='TEXTAREA'" in branch + assert "isContentEditable" in branch + assert "if(isText) return" in branch + + def test_ctrl_k_prevent_default_after_text_guard(self): + branch = _ctrl_k_branch_window() + guard_idx = branch.find("if(isText) return") + prevent_idx = branch.find("e.preventDefault()") + assert guard_idx >= 0 and prevent_idx >= 0, ( + "Ctrl+K must guard text inputs before calling preventDefault()" + ) + assert guard_idx < prevent_idx, ( + "preventDefault() must not run before the text-input early return" + ) + + def test_ctrl_k_guard_matches_ctrl_b_idiom(self): + ctrl_b_idx = BOOT_JS.find("(e.key==='b'||e.key==='B')") + assert ctrl_b_idx >= 0, "Ctrl+B handler not found in boot.js" + ctrl_b_block = BOOT_JS[max(0, ctrl_b_idx - 250):ctrl_b_idx + 300] + ctrl_k_block = _ctrl_k_branch_window() + for needle in ( + "const t=e.target", + "const isText=t&&", + "tagName==='INPUT'", + "tagName==='TEXTAREA'", + "isContentEditable", + ): + assert needle in ctrl_b_block, f"Ctrl+B guard missing {needle!r}" + assert needle in ctrl_k_block, f"Ctrl+K guard missing {needle!r}" + + def test_ctrl_k_still_creates_new_session_outside_inputs(self): + branch = _ctrl_k_branch_window() + assert "newSession()" in branch + assert "closeMobileSidebar()" in branch From db5d406d887062726e8984b3fb3b2e3dda04fa20 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 29 Jun 2026 17:36:51 -0400 Subject: [PATCH 08/10] fix(boot): restore voice-mode runtime prefs during prefill rework (#4961) --- static/boot.js | 10 ++++++-- .../test_issue1488_composer_voice_buttons.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/static/boot.js b/static/boot.js index 9c3aaa5d..7f378136 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1041,7 +1041,13 @@ window._hermesTtsSynth=function(id, text, opts){ let _browserTtsKeepAlive=null; let _browserTtsWatchdog=null; let _browserTtsSuppressNextErrorRearm=false; - const SILENCE_MS=1800; // auto-send after 1.8s silence + // Configurable via localStorage keys (set from dev console or a future settings panel). + // hermes-voice-silence-ms — pause duration before auto-send (ms, default 1800) + // hermes-voice-continuous — keep mic open across natural pauses ("true"/"false", default false) + const _silenceMsRaw=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10); + // Fall back to 1800 for missing/NaN/non-positive values, and floor at 200ms so a + // mistyped tiny/negative value can't make the recognizer auto-send instantly. + const SILENCE_MS=(Number.isFinite(_silenceMsRaw)&&_silenceMsRaw>0)?Math.max(200,_silenceMsRaw):1800; function _clearBrowserTtsRecovery(){ if(_browserTtsKeepAlive){ @@ -1101,7 +1107,7 @@ window._hermesTtsSynth=function(id, text, opts){ _setState('listening'); _recognition=new SpeechRecognition(); - _recognition.continuous=false; + _recognition.continuous=localStorage.getItem('hermes-voice-continuous')==='true'; _recognition.interimResults=true; _recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; diff --git a/tests/test_issue1488_composer_voice_buttons.py b/tests/test_issue1488_composer_voice_buttons.py index 7cd02c2f..134b0990 100644 --- a/tests/test_issue1488_composer_voice_buttons.py +++ b/tests/test_issue1488_composer_voice_buttons.py @@ -232,6 +232,31 @@ class TestVoiceModePreferenceGate: "so the button appears/disappears immediately." +class TestVoiceModeRuntimePreferences: + """Voice mode runtime prefs must stay wired to localStorage-backed settings.""" + + def test_voice_mode_silence_pref_reads_localstorage_with_floor(self): + """boot.js must keep the silence timeout configurable without allowing + tiny or invalid values to auto-send instantly.""" + src = _src("boot.js") + assert "localStorage.getItem('hermes-voice-silence-ms')" in src, ( + "voice mode must read hermes-voice-silence-ms from localStorage " + "so pause timing survives reloads." + ) + assert "Math.max(200,_silenceMsRaw)" in src, ( + "voice mode must floor the configured silence timeout at 200ms " + "instead of trusting tiny values." + ) + + def test_voice_mode_continuous_pref_reads_localstorage(self): + """boot.js must preserve the continuous-recognition preference across reloads.""" + src = _src("boot.js") + assert "localStorage.getItem('hermes-voice-continuous')==='true'" in src, ( + "voice mode must read hermes-voice-continuous from localStorage " + "instead of hardcoding continuous recognition off." + ) + + class TestActiveStateTooltips: """When recording / in voice mode, tooltips should flip to the 'stop' variants so the affordance is honest.""" From 6b9686d5ae8f8bcb498b10782692f0ae92baa4ce Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 30 Jun 2026 14:26:51 -0400 Subject: [PATCH 09/10] fix(#4961): preserve authenticated drafts without starting a session --- static/boot.js | 35 +++++--- tests/test_4961_url_query_prefill.py | 88 ++++++++++++++++--- ...sue4877_fresh_blank_boot_workspace_bind.py | 9 +- 3 files changed, 106 insertions(+), 26 deletions(-) diff --git a/static/boot.js b/static/boot.js index 7f378136..ba73d693 100644 --- a/static/boot.js +++ b/static/boot.js @@ -100,8 +100,11 @@ async function _savedSessionSidebarOnlyState(sid){ // ── Mobile navigation ────────────────────────────────────────────────────── // URL prefill boot helpers. +function _prefillHasDraftText(prefillIntent){ + return !!(prefillIntent&&prefillIntent.hasText); +} function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){ - return !urlSession&&!!savedLocal&&!!(prefillIntent&&prefillIntent.hasText); + return !urlSession&&!!savedLocal&&_prefillHasDraftText(prefillIntent); } async function _applyComposerPrefillOnBoot(prefillIntent){ if(!prefillIntent||!prefillIntent.hasText) return; @@ -112,6 +115,12 @@ async function _applyComposerPrefillOnBoot(prefillIntent){ if(typeof autoResize==='function') autoResize(); else if(typeof updateSendBtn==='function') updateSendBtn(); } +async function _finalizeComposerPrefillOnBoot(prefillIntent){ + if(prefillIntent&&prefillIntent.hasParams&&typeof _consumeComposerPrefillParamsFromLocation==='function'){ + _consumeComposerPrefillParamsFromLocation(); + } + await _applyComposerPrefillOnBoot(prefillIntent); +} // Mobile navigation. let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' @@ -239,7 +248,8 @@ function handleWorkspaceClose(){ closeWorkspacePanel(); } -async function _maybeBindFreshDefaultWorkspaceSession(){ +async function _maybeBindFreshDefaultWorkspaceSession(prefillIntent=null){ + if(_prefillHasDraftText(prefillIntent)) return false; if(S.session) return false; if(_workspacePanelMode!=='browse') return false; if(!S._profileDefaultWorkspace) return false; @@ -2387,9 +2397,6 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; // Load send key preference let _bootSettings={}; const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null; - if(prefillIntent&&prefillIntent.hasParams&&typeof _consumeComposerPrefillParamsFromLocation==='function'){ - _consumeComposerPrefillParamsFromLocation(); - } try{ const s=await api('/api/settings'); _bootSettings=s; @@ -2777,7 +2784,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; await newSession(true); if(S.session) await _startBootModelDropdown(); S._bootReady=true; - syncTopbar();syncWorkspacePanelState();await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return; + syncTopbar();syncWorkspacePanelState();await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return; }catch(e){console.warn('[pwa] new-chat launch action failed', e);} } const savedLocal=localStorage.getItem('hermes-webui-session'); @@ -2795,7 +2802,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; S._bootReady=true; syncTopbar();syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); + await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); return; } if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){ @@ -2804,10 +2811,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open' || localStorage.getItem('hermes-webui-workspace-panel')==='open'; if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse'; - await _maybeBindFreshDefaultWorkspaceSession(); + await _maybeBindFreshDefaultWorkspaceSession(prefillIntent); syncTopbar();syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); + await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); return; } await loadSession(saved, {preserveActiveInput:true}); @@ -2842,10 +2849,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open' || localStorage.getItem('hermes-webui-workspace-panel')==='open'; if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse'; - await _maybeBindFreshDefaultWorkspaceSession(); + await _maybeBindFreshDefaultWorkspaceSession(prefillIntent); syncTopbar();syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); + await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE(); return; } // Restore the panel from localStorage when the session has a workspace. @@ -2857,7 +2864,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; _workspacePanelMode='browse'; } S._bootReady=true; - syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);await _applyComposerPrefillOnBoot(prefillIntent);return;} + syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);await _finalizeComposerPrefillOnBoot(prefillIntent);return;} catch(e){localStorage.removeItem('hermes-webui-session');} } // no saved session - show empty state, wait for user to hit + @@ -2868,10 +2875,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open' || localStorage.getItem('hermes-webui-workspace-panel')==='open'; if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse'; - await _maybeBindFreshDefaultWorkspaceSession(); + await _maybeBindFreshDefaultWorkspaceSession(prefillIntent); syncWorkspacePanelState(); $('emptyState').style.display=''; - await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent); + await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent); // Start real-time gateway session sync if setting is enabled if(typeof startGatewaySSE==='function') startGatewaySSE(); })().catch(e=>{ diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index f69280e0..78cee566 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -127,6 +127,7 @@ console.log(JSON.stringify({ cleaned, promoted })); def test_root_prefill_keeps_saved_local_sidebar_only(): source = _node_prelude() + """ +evalBoot('_prefillHasDraftText'); evalBoot('_rootPrefillNeedsFreshComposer'); const result = { savedLocalWins: _rootPrefillNeedsFreshComposer(null, 'saved-local', { hasText: true }), @@ -155,6 +156,31 @@ console.log(JSON.stringify(result)); assert load_pos > prefill_guard +def test_fresh_default_workspace_bind_skips_prefill_draft_boot(): + source = _node_prelude() + """ +(async () => { + evalBoot('_prefillHasDraftText'); + evalBoot('_maybeBindFreshDefaultWorkspaceSession'); + global.S = { session: null, _profileDefaultWorkspace: 'D:/workspace' }; + global._workspacePanelMode = 'browse'; + const calls = []; + global.newSession = async () => { calls.push('newSession'); }; + const skipped = await _maybeBindFreshDefaultWorkspaceSession({ hasText: true }); + const bound = await _maybeBindFreshDefaultWorkspaceSession({ hasText: false }); + console.log(JSON.stringify({ skipped, bound, calls })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload == { + "skipped": False, + "bound": True, + "calls": ["newSession"], + } + + def test_apply_prefill_updates_composer_without_autosending(): source = _node_prelude() + """ (async () => { @@ -185,6 +211,42 @@ def test_apply_prefill_updates_composer_without_autosending(): assert payload["value"] == "second pass" +def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready(): + source = _node_prelude() + """ +(async () => { + evalBoot('_applyComposerPrefillOnBoot'); + evalBoot('_finalizeComposerPrefillOnBoot'); + const counts = { autoResize: 0, updateSendBtn: 0, consume: 0, send: 0 }; + const msg = { value: '' }; + global.document = { + getElementById(id) { + return id === 'msg' ? msg : null; + } + }; + global.$ = (id) => document.getElementById(id); + global.autoResize = () => { counts.autoResize++; }; + global.updateSendBtn = () => { counts.updateSendBtn++; }; + global.send = async () => { counts.send++; }; + global._consumeComposerPrefillParamsFromLocation = () => { counts.consume++; }; + await _finalizeComposerPrefillOnBoot({ hasParams: true, hasText: true, text: 'hello world', autoSend: false }); + delete global.autoResize; + await _finalizeComposerPrefillOnBoot({ hasParams: false, hasText: true, text: 'second pass', autoSend: false }); + console.log(JSON.stringify({ counts, value: msg.value })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["counts"] == { + "autoResize": 1, + "updateSendBtn": 1, + "consume": 1, + "send": 0, + } + assert payload["value"] == "second pass" + + def test_explicit_session_url_keeps_target_session_with_prefill(): source = _node_prelude() + """ global.window = { @@ -195,6 +257,7 @@ global.window = { } }; evalSession('_sessionIdFromLocation'); +evalBoot('_prefillHasDraftText'); evalBoot('_rootPrefillNeedsFreshComposer'); console.log(JSON.stringify({ urlSession: _sessionIdFromLocation(), @@ -212,6 +275,7 @@ global.window = { } }; evalSession('_sessionIdFromLocation'); +evalBoot('_prefillHasDraftText'); evalBoot('_rootPrefillNeedsFreshComposer'); console.log(JSON.stringify({ urlSession: _sessionIdFromLocation(), @@ -223,22 +287,26 @@ console.log(JSON.stringify({ prefill_pos = BOOT_JS.find( "const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;" ) - consume_pos = BOOT_JS.find("_consumeComposerPrefillParamsFromLocation();", prefill_pos) - first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", consume_pos) - new_pos = BOOT_JS.find("await newSession(true);", consume_pos) - load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});", consume_pos) + first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", prefill_pos) + active_profile_pos = BOOT_JS.find( + "const activeProfileState = await _resolveActiveProfileBootstrapState();", + first_await_pos, + ) + new_pos = BOOT_JS.find("await newSession(true);", active_profile_pos) + load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});", active_profile_pos) saved_pos = BOOT_JS.find("const saved=urlSession||savedLocal;") check_pos = BOOT_JS.find("await checkInflightOnBoot(saved);", load_pos) - apply_pos = BOOT_JS.find("await _applyComposerPrefillOnBoot(prefillIntent);", check_pos) + apply_pos = BOOT_JS.find("await _finalizeComposerPrefillOnBoot(prefillIntent);", check_pos) assert saved_pos >= 0 assert prefill_pos >= 0 - assert consume_pos > prefill_pos - assert 0 <= consume_pos < first_await_pos - assert 0 <= consume_pos < new_pos - assert 0 <= consume_pos < load_pos + assert active_profile_pos > first_await_pos + assert "_consumeComposerPrefillParamsFromLocation();" not in BOOT_JS[prefill_pos:first_await_pos] + assert 0 <= active_profile_pos < new_pos + assert 0 <= active_profile_pos < load_pos assert 0 <= check_pos < apply_pos + assert BOOT_JS.find("await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);", prefill_pos) > prefill_pos zero_message_pos = BOOT_JS.find( - "await renderSessionList();await _applyComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();", + "await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();", load_pos, ) assert zero_message_pos > load_pos diff --git a/tests/test_issue4877_fresh_blank_boot_workspace_bind.py b/tests/test_issue4877_fresh_blank_boot_workspace_bind.py index 7917f3ac..e15b0bee 100644 --- a/tests/test_issue4877_fresh_blank_boot_workspace_bind.py +++ b/tests/test_issue4877_fresh_blank_boot_workspace_bind.py @@ -20,6 +20,9 @@ node_test = pytest.mark.skipif(NODE is None, reason="node not on PATH") def _extract_async_function(source: str, name: str) -> str: marker = f"async function {name}(" start = source.find(marker) + if start < 0: + marker = f"function {name}(" + start = source.find(marker) assert start >= 0, f"{name}() function must exist" brace = source.find("{", source.find(")", start)) assert brace > start, f"{name}() function body must start" @@ -79,6 +82,7 @@ def _run_node(script: str) -> dict: def _helper_driver(panel_mode: str, default_workspace: str | None, *, reject: bool = False) -> dict: + helper_dep = _extract_async_function(BOOT_JS.read_text(encoding="utf-8"), "_prefillHasDraftText") helper = _extract_async_function(BOOT_JS.read_text(encoding="utf-8"), "_maybeBindFreshDefaultWorkspaceSession") default_workspace_repr = json.dumps(default_workspace) script = textwrap.dedent( @@ -94,6 +98,7 @@ def _helper_driver(panel_mode: str, default_workspace: str | None, *, reject: bo calls.push({{arg0:a,arg1:b}}); return shouldReject ? Promise.reject(new Error('bind-failed')) : Promise.resolve({{}}); }} + {helper_dep} {helper} _maybeBindFreshDefaultWorkspaceSession().then((bound)=>{{ process.stdout.write(JSON.stringify({{bound,calls}})); @@ -150,7 +155,7 @@ def test_no_saved_session_branch_restores_panel_pref_before_bind_attempt(): fresh_pref = "if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" pref_idx = segment.find(fresh_pref) assert pref_idx >= 0, "no-saved-session path must restore panel preference" - bind_call = "await _maybeBindFreshDefaultWorkspaceSession();" + bind_call = "await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);" bind_idx = segment.find(bind_call) assert bind_idx >= 0, "no-saved-session path must attempt to bind after pref restore" assert pref_idx < bind_idx, "panel preference restoration must happen before bind attempt" @@ -167,7 +172,7 @@ def test_ephemeral_blank_session_branch_restores_panel_pref_before_bind_attempt( eph_pref = "if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" pref_idx = segment.find(eph_pref) assert pref_idx >= 0, "ephemeral blank-session path must restore panel preference" - bind_call = "await _maybeBindFreshDefaultWorkspaceSession();" + bind_call = "await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);" bind_idx = segment.find(bind_call) assert bind_idx >= 0, "ephemeral blank-session path must attempt to bind before returning" assert pref_idx < bind_idx, "ephemeral panel preference restoration must happen before bind attempt" From 740a3a693e3816078197a6c11027949e492d404e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 23:08:16 +0000 Subject: [PATCH 10/10] docs(changelog): deep-link ?q= composer prefill, converged (#4969) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da8118da..06e1e234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **Deep-link composer prefill via `?q=` no longer loses the draft or starts a session early.** Opening the WebUI with a `?q=...` (and related prefill params) pre-fills the composer with that text; the draft now survives a login redirect (params are consumed after auth/profile bootstrap, and the logged-out `?q=` is carried through `next`), and a prefill boot leaves the session uncreated until you actually send — it no longer silently binds a fresh default-workspace session. Thanks @rodboev. (#4969, #4961) + - **The Run dispatcher now works on the default Kanban board.** The default board is stored as `null`, and the dispatcher's board check treated that as "no board," so the Run button silently no-opped on the default board. It now resolves the default board correctly and dispatches. Thanks @rodboev. (#5289, fixes #5231) - **PWA / deep-link new-chat launches show the empty conversation immediately.** The boot model-dropdown hydration is deferred to the background instead of blocking the first paint, so a new-chat launch from the installed PWA or a deep link renders the composer right away rather than waiting on the model list. Thanks @santastabber. (#5287)