diff --git a/CHANGELOG.md b/CHANGELOG.md index aab7ade..e36b1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.139] — 2026-04-22 + +### Fixed +- **Default workspace persists after session delete** — the blank new-chat page now shows the configured default workspace even after creating and deleting sessions. Root cause: `newSession()` consumed `S._profileDefaultWorkspace` for a one-shot profile-switch semantic, leaving it null on all subsequent returns to blank state. Fix: introduced `S._profileSwitchWorkspace` as a dedicated one-shot flag for profile switches; `S._profileDefaultWorkspace` is now persistent from boot throughout the session lifecycle. Workspace chip, `promptNewFile`, `promptNewFolder`, and `switchToWorkspace` all continue to work correctly. Closes #823. (#824) + ## [v0.50.138] — 2026-04-22 ### Fixed diff --git a/static/panels.js b/static/panels.js index f26e501..a422dad 100644 --- a/static/panels.js +++ b/static/panels.js @@ -809,6 +809,9 @@ async function switchToWorkspace(path,name){ session_id:S.session.session_id, workspace:path, model:S.session.model })}); S.session.workspace=path; + // Explicit workspace switch = user overriding any pending profile-switch default. + // Clear the one-shot flag so a subsequent newSession() inherits this choice instead. + S._profileSwitchWorkspace=null; syncTopbar(); await loadDir('.'); showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); @@ -953,8 +956,12 @@ async function switchToProfile(name) { _workspaceList = null; await loadWorkspaceList(); if (data.default_workspace) { - // Always store the profile default for new sessions + // Always store the persistent profile default — used for blank-page display + // and workspace auto-bind throughout the session lifecycle (#804, #823). S._profileDefaultWorkspace = data.default_workspace; + // Also set the one-shot flag consumed by newSession() so the first new + // session after a profile switch inherits this workspace (#424). + S._profileSwitchWorkspace = data.default_workspace; if (S.session && !sessionInProgress) { // Empty session (no messages yet) — safe to update it in place diff --git a/static/sessions.js b/static/sessions.js index b7c8024..ef0dbdc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -14,10 +14,13 @@ async function newSession(flash){ updateQueueBadge(); S.toolCalls=[]; clearLiveToolCards(); - // Use profile default workspace for new sessions after a profile switch (one-shot), - // otherwise inherit from the current session (or let server pick the default) - const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null); - S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch + // One-shot profile-switch workspace: applied to the first new session after a profile + // switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the + // persistent boot/settings default) is not consumed and remains available for the + // blank-page display on all subsequent returns to the empty state (#823). + const switchWs=S._profileSwitchWorkspace; + S._profileSwitchWorkspace=null; + const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null); const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs,profile:S.activeProfile||'default'})}); S.session=data.session;S.messages=data.session.messages||[]; S.lastUsage={...(data.session.last_usage||{})}; diff --git a/tests/test_profile_default_workspace_823.py b/tests/test_profile_default_workspace_823.py new file mode 100644 index 0000000..ecb597a --- /dev/null +++ b/tests/test_profile_default_workspace_823.py @@ -0,0 +1,134 @@ +"""Tests for #823 — _profileDefaultWorkspace persists after newSession() (#804 follow-up) + +Root cause: newSession() consumed S._profileDefaultWorkspace for the one-shot +profile-switch semantic (setting it to null after the first new session). This +caused the blank-page default workspace display to regress after any session +was created and then deleted. + +Fix: introduce S._profileSwitchWorkspace as the dedicated one-shot flag for +profile-switch semantics; S._profileDefaultWorkspace is now persistent. +""" +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel): + return (REPO / rel).read_text(encoding='utf-8') + + +class TestProfileDefaultWorkspacePersistence: + """_profileDefaultWorkspace must NOT be nulled by newSession().""" + + def test_new_session_does_not_null_profile_default_workspace(self): + src = read('static/sessions.js') + m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL) + assert m, "newSession not found" + fn = m.group(0) + # The old consume pattern must be gone + assert '_profileDefaultWorkspace=null' not in fn and \ + '_profileDefaultWorkspace = null' not in fn, ( + "newSession must NOT null S._profileDefaultWorkspace — it is the persistent " + "boot/settings default used for blank-page display after session delete (#823)" + ) + + def test_new_session_uses_dedicated_switch_workspace_flag(self): + src = read('static/sessions.js') + m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL) + assert m + fn = m.group(0) + assert '_profileSwitchWorkspace' in fn, ( + "newSession must read S._profileSwitchWorkspace for the one-shot " + "profile-switch inherit, not S._profileDefaultWorkspace" + ) + # It should null the switch flag, not the default + assert '_profileSwitchWorkspace=null' in fn or '_profileSwitchWorkspace = null' in fn, ( + "newSession must null S._profileSwitchWorkspace after consuming it" + ) + + def test_new_session_still_inherits_default_workspace(self): + """newSession must still pass a workspace to /api/session/new — + now via the _profileSwitchWorkspace → current session → _profileDefaultWorkspace chain.""" + src = read('static/sessions.js') + m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL) + assert m + fn = m.group(0) + # inheritWs must be computed and passed to /api/session/new + assert 'inheritWs' in fn or 'inherit' in fn.lower(), ( + "newSession must compute an inheritWs from switch/current/default workspace" + ) + assert '_profileDefaultWorkspace' in fn, ( + "newSession must fall through to S._profileDefaultWorkspace as last resort" + ) + + +class TestProfileSwitchWorkspaceSetter: + """panels.js must set _profileSwitchWorkspace on profile switch.""" + + def test_panels_sets_profile_switch_workspace(self): + src = read('static/panels.js') + # Find the profile-switch workspace block + assert 'S._profileSwitchWorkspace' in src, ( + "panels.js must set S._profileSwitchWorkspace during profile switch " + "so newSession() can apply it to the first new session" + ) + + def test_panels_still_sets_profile_default_workspace(self): + src = read('static/panels.js') + assert 'S._profileDefaultWorkspace = data.default_workspace' in src, ( + "panels.js must still set S._profileDefaultWorkspace (persistent default) " + "alongside S._profileSwitchWorkspace" + ) + + def test_both_set_together_in_same_block(self): + src = read('static/panels.js') + default_pos = src.find('S._profileDefaultWorkspace = data.default_workspace') + switch_pos = src.find('S._profileSwitchWorkspace = data.default_workspace') + assert default_pos != -1, "S._profileDefaultWorkspace setter not found" + assert switch_pos != -1, "S._profileSwitchWorkspace setter not found" + # Both must be set within 200 chars of each other (same block) + assert abs(default_pos - switch_pos) < 300, ( + "_profileDefaultWorkspace and _profileSwitchWorkspace must be set " + "together in the same profile-switch workspace block" + ) + + + def test_switch_to_workspace_clears_profile_switch_workspace(self): + """Opus Q4: when the user manually changes workspace, the pending one-shot + switch flag should be cleared so a subsequent newSession() inherits the + user's explicit choice rather than the stale profile-switch default.""" + src = read('static/panels.js') + m = re.search(r'async function switchToWorkspace\(.*?\n\}', src, re.DOTALL) + assert m, "switchToWorkspace not found" + fn = m.group(0) + assert '_profileSwitchWorkspace=null' in fn or '_profileSwitchWorkspace = null' in fn, ( + "switchToWorkspace must null S._profileSwitchWorkspace after a manual switch " + "so the next newSession() inherits the user's explicit workspace choice" + ) + + +class TestBlankPageAfterSessionDelete: + """After all sessions are deleted, blank page must still show default workspace.""" + + def test_sync_workspace_displays_reads_profile_default(self): + """syncWorkspaceDisplays relies on S._profileDefaultWorkspace which must + still be set after a session is created and deleted.""" + src = read('static/panels.js') + m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL) + assert m, "syncWorkspaceDisplays not found" + fn = m.group(0) + assert '_profileDefaultWorkspace' in fn, ( + "syncWorkspaceDisplays must read S._profileDefaultWorkspace as fallback" + ) + + def test_prompt_new_file_reads_profile_default(self): + """promptNewFile on blank page reads _profileDefaultWorkspace which must + be non-null even after a newSession() + deleteSession() cycle.""" + src = read('static/ui.js') + m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL) + assert m, "promptNewFile not found" + fn = m.group(0) + assert '_profileDefaultWorkspace' in fn, ( + "promptNewFile must read S._profileDefaultWorkspace (must persist after newSession)" + )