diff --git a/static/boot.js b/static/boot.js index 60a00f93..6d7ba8d8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -194,6 +194,19 @@ function handleWorkspaceClose(){ closeWorkspacePanel(); } +async function _maybeBindFreshDefaultWorkspaceSession(){ + if(S.session) return false; + if(_workspacePanelMode!=='browse') return false; + if(!S._profileDefaultWorkspace) return false; + try{ + await newSession(false, {awaitWorkspaceLoad: true}); + return true; + }catch(e){ + console.warn('[hermes] failed to bind fresh default workspace session', e); + return false; + } +} + /** * Set a tooltip on a button, preferring the custom CSS tooltip (`data-tooltip`) * when the element opts in via the `has-tooltip` class. Falls back to the @@ -2481,6 +2494,7 @@ 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(); syncWorkspacePanelState(); $('emptyState').style.display=''; await renderSessionList(); diff --git a/tests/test_issue4877_fresh_blank_boot_workspace_bind.py b/tests/test_issue4877_fresh_blank_boot_workspace_bind.py new file mode 100644 index 00000000..497a4b48 --- /dev/null +++ b/tests/test_issue4877_fresh_blank_boot_workspace_bind.py @@ -0,0 +1,156 @@ +"""Behavioral regression for #4877 fresh blank boot workspace binding.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = ROOT / "static" / "boot.js" +NODE = shutil.which("node") + +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) + assert start >= 0, f"{name}() function must exist" + brace = source.find("{", source.find(")", start)) + assert brace > start, f"{name}() function body must start" + depth = 0 + in_string = None + escaped = False + in_line_comment = False + in_block_comment = False + for idx in range(brace, len(source)): + ch = source[idx] + nxt = source[idx + 1] if idx + 1 < len(source) else "" + if in_line_comment: + if ch == "\n": + in_line_comment = False + continue + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + continue + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == in_string: + in_string = None + continue + if ch == "/" and nxt == "/": + in_line_comment = True + continue + if ch == "/" and nxt == "*": + in_block_comment = True + continue + if ch in ("'", '"', "`"): + in_string = ch + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return source[start : idx + 1] + raise AssertionError(f"could not extract {name}()") + + +def _run_node(script: str) -> dict: + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + +def _helper_driver(panel_mode: str, default_workspace: str | None, *, reject: bool = False) -> dict: + helper = _extract_async_function(BOOT_JS.read_text(encoding="utf-8"), "_maybeBindFreshDefaultWorkspaceSession") + default_workspace_repr = json.dumps(default_workspace) + script = textwrap.dedent( + f""" + let calls = []; + let shouldReject = {str(reject).lower()}; + let S = {{ + session: null, + _profileDefaultWorkspace: {default_workspace_repr}, + }}; + var _workspacePanelMode = {json.dumps(panel_mode)}; + function newSession(a, b) {{ + calls.push({{arg0:a,arg1:b}}); + return shouldReject ? Promise.reject(new Error('bind-failed')) : Promise.resolve({{}}); + }} + {helper} + _maybeBindFreshDefaultWorkspaceSession().then((bound)=>{{ + process.stdout.write(JSON.stringify({{bound,calls}})); + }}).catch(err=>{{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + return _run_node(script) + + +@node_test +def test_blank_boot_open_panel_auto_binds_default_workspace_session(): + result = _helper_driver("browse", "/default-workspace") + + assert result["bound"] is True + assert result["calls"] == [{"arg0": False, "arg1": {"awaitWorkspaceLoad": True}}] + + +@node_test +def test_blank_boot_closed_panel_does_not_create_session(): + result = _helper_driver("closed", "/default-workspace") + + assert result["bound"] is False + assert result["calls"] == [] + + +@node_test +def test_blank_boot_without_default_workspace_does_not_create_session(): + result = _helper_driver("browse", None) + + assert result["bound"] is False + assert result["calls"] == [] + + +@node_test +def test_blank_boot_bind_failure_falls_back_cleanly(): + result = _helper_driver("browse", "/default-workspace", reject=True) + + assert result["bound"] is False + assert len(result["calls"]) == 1 + + +def test_no_saved_session_branch_restores_panel_pref_before_bind_attempt(): + src = BOOT_JS.read_text(encoding="utf-8") + marker = "// no saved session - show empty state, wait for user to hit +" + marker_idx = src.find(marker) + assert marker_idx >= 0, "no-saved-session path not found" + start = marker_idx + sync_idx = src.find("syncWorkspacePanelState();", start) + assert sync_idx > start, "no-saved-session path must still call syncWorkspacePanelState()" + segment = src[start:sync_idx] + 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_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"