From 2b21bb68b83b5b0356f16bab5a4d522f90cb876b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 12 Apr 2026 12:50:32 -0700 Subject: [PATCH] feat: workspace panel state persists across refreshes (#321) localStorage key hermes-webui-workspace-panel saves open/closed on every state change; restored on boot before syncWorkspacePanelState(). 7 new tests, 753 total. --- CHANGELOG.md | 6 ++++ static/boot.js | 6 ++++ static/index.html | 2 +- tests/test_sprint37.py | 82 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/test_sprint37.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dc78bd1..78bb11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ --- +## [v0.50.2] Workspace panel state persists across refreshes + +- **Workspace panel open/closed persists** (localStorage key `hermes-webui-workspace-panel`): Once you open the workspace/files pane, it stays open after a page refresh. Closing it explicitly saves the closed state, which also survives a refresh. The restore happens in the boot sequence before the first render, so there is no flash of the wrong state. Works for both desktop and mobile. + - State is stored as `'open'` or `'closed'` — `'open'` restores as `'browse'` mode; any preview state is re-evaluated normally. + - 7 new tests in `tests/test_sprint37.py`; 753 tests total (up from 746) + ## [v0.50.1] Mobile Enter key inserts newline (PR #315, fixes #269) - **Enter inserts newline on mobile** (closes #269): On touch-primary devices (detected via `matchMedia('(pointer:coarse)')`), the Enter key now inserts a newline instead of sending. Users send via the Send button, which is always visible on mobile. Desktop behavior is unchanged — Enter sends, Shift+Enter inserts a newline. diff --git a/static/boot.js b/static/boot.js index fed5361..24450aa 100644 --- a/static/boot.js +++ b/static/boot.js @@ -40,6 +40,8 @@ function _setWorkspacePanelMode(mode){ if(!layout||!panel)return; _workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed'; const open=_workspacePanelMode!=='closed'; + // Persist open/closed across refreshes (browse/preview → open; closed → closed) + localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ panel.classList.toggle('mobile-open',open); @@ -503,6 +505,10 @@ function applyBotName(){ await loadWorkspaceList(); await loadOnboardingWizard(); _initResizePanels(); + // Restore workspace panel open/closed state from last visit + if(localStorage.getItem('hermes-webui-workspace-panel')==='open'){ + _workspacePanelMode='browse'; + } const saved=localStorage.getItem('hermes-webui-session'); if(saved){ try{await loadSession(saved);syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} diff --git a/static/index.html b/static/index.html index 1db8121..4b1b941 100644 --- a/static/index.html +++ b/static/index.html @@ -526,7 +526,7 @@
System
Instance version and access controls.
- v0.50.1 + v0.50.2
diff --git a/tests/test_sprint37.py b/tests/test_sprint37.py new file mode 100644 index 0000000..4f60b52 --- /dev/null +++ b/tests/test_sprint37.py @@ -0,0 +1,82 @@ +""" +Sprint 37 Tests: Workspace panel open/closed state persists across refreshes via localStorage. +""" +import pathlib +import re + +REPO_ROOT = pathlib.Path(__file__).parent.parent +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text() +HTML = (REPO_ROOT / "static" / "index.html").read_text() + + +# ── Persistence: save on change ─────────────────────────────────────────────── + +def test_workspace_panel_saves_to_localstorage(): + """_setWorkspacePanelMode must call localStorage.setItem with hermes-webui-workspace-panel.""" + assert "hermes-webui-workspace-panel" in BOOT_JS, \ + "boot.js must use localStorage key 'hermes-webui-workspace-panel' to persist panel state" + + +def test_workspace_panel_save_inside_set_mode(): + """localStorage.setItem for panel state must live inside _setWorkspacePanelMode.""" + fn_idx = BOOT_JS.find("function _setWorkspacePanelMode(") + fn_end = BOOT_JS.find("\n}", fn_idx) + 2 + fn_body = BOOT_JS[fn_idx:fn_end] + assert "hermes-webui-workspace-panel" in fn_body, \ + "localStorage save must be inside _setWorkspacePanelMode so every state change is captured" + + +def test_workspace_panel_saves_open_value(): + """When the panel is open, localStorage must be set to 'open'.""" + fn_idx = BOOT_JS.find("function _setWorkspacePanelMode(") + fn_end = BOOT_JS.find("\n}", fn_idx) + 2 + fn_body = BOOT_JS[fn_idx:fn_end] + assert "'open'" in fn_body or '"open"' in fn_body, \ + "_setWorkspacePanelMode must store 'open' for an open panel state" + + +def test_workspace_panel_saves_closed_value(): + """When the panel is closed, localStorage must be set to 'closed'.""" + fn_idx = BOOT_JS.find("function _setWorkspacePanelMode(") + fn_end = BOOT_JS.find("\n}", fn_idx) + 2 + fn_body = BOOT_JS[fn_idx:fn_end] + assert "'closed'" in fn_body or '"closed"' in fn_body, \ + "_setWorkspacePanelMode must store 'closed' for a closed panel state" + + +# ── Persistence: restore on boot ───────────────────────────────────────────── + +def test_workspace_panel_restored_on_boot(): + """Boot IIFE must read hermes-webui-workspace-panel from localStorage and restore the mode.""" + # Find the boot IIFE (the async IIFE at the bottom of boot.js) + iife_idx = BOOT_JS.rfind("(async function") + if iife_idx < 0: + iife_idx = BOOT_JS.rfind("(async()=>{") + iife_body = BOOT_JS[iife_idx:] + assert "hermes-webui-workspace-panel" in iife_body, \ + "Boot IIFE must read 'hermes-webui-workspace-panel' from localStorage to restore panel state on load" + + +def test_workspace_panel_restore_sets_browse_mode(): + """When localStorage says 'open', boot must set _workspacePanelMode to 'browse' before syncing.""" + iife_idx = BOOT_JS.rfind("(async function") + if iife_idx < 0: + iife_idx = BOOT_JS.rfind("(async()=>{") + iife_body = BOOT_JS[iife_idx:] + # The restore block must assign _workspacePanelMode = 'browse' + assert "_workspacePanelMode='browse'" in iife_body or "_workspacePanelMode = 'browse'" in iife_body, \ + "Boot must set _workspacePanelMode='browse' when restoring an open panel" + + +def test_workspace_panel_restore_before_sync(): + """Restore must happen before syncWorkspacePanelState() so the state drives the initial render.""" + iife_idx = BOOT_JS.rfind("(async function") + if iife_idx < 0: + iife_idx = BOOT_JS.rfind("(async()=>{") + iife_body = BOOT_JS[iife_idx:] + restore_pos = iife_body.find("hermes-webui-workspace-panel") + sync_pos = iife_body.find("syncWorkspacePanelState()") + assert restore_pos >= 0, "restore read must be present in boot IIFE" + assert sync_pos >= 0, "syncWorkspacePanelState call must be present in boot IIFE" + assert restore_pos < sync_pos, \ + "Workspace panel restore must happen BEFORE syncWorkspacePanelState() so the correct mode is applied"