fix(#4961): preserve authenticated drafts without starting a session

This commit is contained in:
Rod Boev 2026-06-30 14:26:51 -04:00
parent db5d406d88
commit 6b9686d5ae
3 changed files with 106 additions and 26 deletions

View file

@ -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=>{

View file

@ -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

View file

@ -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"