fix: correct webui profile switching state — v0.50.150 (PR #849 by @migueltavares)

Three related profile-switching fixes:
- Always persist hermes_profile=default cookie when switching back to default (was being cleared with max-age=0, causing fallback to process-global profile)
- Replace undefined updateWorkspaceChip() with syncTopbar() in the sessionInProgress branch of switchToProfile()
- Make sidebar/dropdown active-profile rendering prefer S.activeProfile client state when available, with safe fallback

Tests: 1854 passing.
This commit is contained in:
Miguel Tavares
2026-04-22 17:27:01 +01:00
committed by GitHub
parent 418d77443c
commit f42f1c69ca
4 changed files with 38 additions and 30 deletions

View File

@@ -211,17 +211,19 @@ def get_profile_cookie(handler) -> str | None:
def build_profile_cookie(name: str) -> str: def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie. """Build a Set-Cookie header value for the hermes_profile cookie.
name='default' clears the cookie (max-age=0). Always persist the selected profile in the cookie, including 'default'.
Any other valid profile name sets it for the browser session. Clearing the cookie causes the backend to fall back to process-global
httponly=True: the JS reads profile from /api/profile/active JSON, never _active_profile, which can unexpectedly switch clients back to another
from document.cookie, so httponly exposure is unnecessary. profile.
Set HttpOnly because the UI reads the active profile from
/api/profile/active JSON and does not need to access this cookie via
document.cookie.
""" """
import http.cookies as _hc import http.cookies as _hc
cookie = _hc.SimpleCookie() cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = '' if name == 'default' else name cookie[PROFILE_COOKIE_NAME] = name
cookie[PROFILE_COOKIE_NAME]['path'] = '/' cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax' cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
if name == 'default':
cookie[PROFILE_COOKIE_NAME]['max-age'] = '0'
return cookie[PROFILE_COOKIE_NAME].OutputString() return cookie[PROFILE_COOKIE_NAME].OutputString()

View File

@@ -843,6 +843,9 @@ async function loadProfilesPanel() {
panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`; panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`;
return; return;
} }
const activeName = (S.activeProfile && data.profiles.some(p => p.name === S.activeProfile))
? S.activeProfile
: (data.active || 'default');
for (const p of data.profiles) { for (const p of data.profiles) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'profile-card'; card.className = 'profile-card';
@@ -854,7 +857,7 @@ async function loadProfilesPanel() {
const gwDot = p.gateway_running const gwDot = p.gateway_running
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>` ? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`; : `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
const isActive = p.name === data.active; const isActive = p.name === activeName;
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : ''; const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
const defaultBadge = p.is_default ? ` <span style="opacity:.5">${esc(t('profile_default_label'))}</span>` : ''; const defaultBadge = p.is_default ? ` <span style="opacity:.5">${esc(t('profile_default_label'))}</span>` : '';
card.innerHTML = ` card.innerHTML = `
@@ -880,7 +883,9 @@ function renderProfileDropdown(data) {
if (!dd) return; if (!dd) return;
dd.innerHTML = ''; dd.innerHTML = '';
const profiles = data.profiles || []; const profiles = data.profiles || [];
const active = data.active || 'default'; const active = (S.activeProfile && profiles.some(p => p.name === S.activeProfile))
? S.activeProfile
: (data.active || 'default');
for (const p of profiles) { for (const p of profiles) {
const opt = document.createElement('div'); const opt = document.createElement('div');
opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
@@ -1005,7 +1010,9 @@ async function switchToProfile(name) {
S.session.workspace = S._profileDefaultWorkspace; S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {} } catch (_) {}
} }
updateWorkspaceChip(); // Keep topbar chips (workspace/profile) in sync after creating the
// new profile-scoped session.
syncTopbar();
await renderSessionList(); await renderSessionList();
showToast(t('profile_switched_new_conversation', name)); showToast(t('profile_switched_new_conversation', name));
} else { } else {

View File

@@ -33,12 +33,11 @@ class TestProfileCookieHelpers:
assert 'SameSite=Lax' in s assert 'SameSite=Lax' in s
assert 'Path=/' in s assert 'Path=/' in s
def test_build_profile_cookie_default_clears(self): def test_build_profile_cookie_default_persists(self):
from api.helpers import build_profile_cookie from api.helpers import build_profile_cookie
s = build_profile_cookie('default') s = build_profile_cookie('default')
assert 'Max-Age=0' in s assert 'hermes_profile=default' in s
# Empty value indicates clear assert 'Max-Age=0' not in s
assert 'hermes_profile=""' in s or 'hermes_profile=;' in s
def test_get_profile_cookie_returns_none_when_absent(self): def test_get_profile_cookie_returns_none_when_absent(self):
from api.helpers import get_profile_cookie from api.helpers import get_profile_cookie

View File

@@ -202,10 +202,10 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
"""Verify that switchToProfile() applies the profile default workspace """Verify that switchToProfile() applies the profile default workspace
to the new session when a conversation is in progress (fixes #424).""" to the new session when a conversation is in progress (fixes #424)."""
def test_workspace_chip_updated_after_profile_switch(self): def test_topbar_synced_after_profile_switch(self):
"""After await newSession(false) in the sessionInProgress branch, """After await newSession(false) in the sessionInProgress branch,
the code must call updateWorkspaceChip() so the chip reflects the the code must call syncTopbar() so the profile/workspace chips reflect
new profile's default workspace instead of showing 'No active workspace'.""" the new profile's default workspace."""
# Find the sessionInProgress block # Find the sessionInProgress block
idx = PANELS_JS.find('if (sessionInProgress)') idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1, "sessionInProgress branch must exist in panels.js") self.assertGreater(idx, -1, "sessionInProgress branch must exist in panels.js")
@@ -217,13 +217,13 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
self.assertIn('await newSession(false)', block, self.assertIn('await newSession(false)', block,
"sessionInProgress branch must call await newSession(false)") "sessionInProgress branch must call await newSession(false)")
# The fix: updateWorkspaceChip() must be called after newSession(false) # The fix: syncTopbar() must be called after newSession(false)
pos_new_session = block.find('await newSession(false)') pos_new_session = block.find('await newSession(false)')
pos_update_chip = block.find('updateWorkspaceChip()') pos_sync_topbar = block.find('syncTopbar()')
self.assertGreater(pos_update_chip, -1, self.assertGreater(pos_sync_topbar, -1,
"updateWorkspaceChip() must be called in the sessionInProgress branch") "syncTopbar() must be called in the sessionInProgress branch")
self.assertGreater(pos_update_chip, pos_new_session, self.assertGreater(pos_sync_topbar, pos_new_session,
"updateWorkspaceChip() must be called AFTER newSession(false)") "syncTopbar() must be called AFTER newSession(false)")
def test_profile_default_workspace_applied_to_new_session(self): def test_profile_default_workspace_applied_to_new_session(self):
"""After newSession(false) the code must assign S._profileDefaultWorkspace """After newSession(false) the code must assign S._profileDefaultWorkspace
@@ -248,19 +248,19 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
"The sessionInProgress branch must call /api/session/update " "The sessionInProgress branch must call /api/session/update "
"to persist the new workspace after newSession(false)") "to persist the new workspace after newSession(false)")
def test_update_workspace_chip_before_render_session_list(self): def test_sync_topbar_before_render_session_list(self):
"""updateWorkspaceChip() should be called before renderSessionList() """syncTopbar() should be called before renderSessionList()
so the chip is correct when the UI re-renders.""" so the chips are correct when the UI re-renders."""
idx = PANELS_JS.find('if (sessionInProgress)') idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1) self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000] block = PANELS_JS[idx:idx + 1000]
pos_chip = block.find('updateWorkspaceChip()') pos_sync = block.find('syncTopbar()')
pos_render = block.find('await renderSessionList()') pos_render = block.find('await renderSessionList()')
self.assertGreater(pos_chip, -1, "updateWorkspaceChip() must exist in block") self.assertGreater(pos_sync, -1, "syncTopbar() must exist in block")
self.assertGreater(pos_render, -1, "renderSessionList() must exist in block") self.assertGreater(pos_render, -1, "renderSessionList() must exist in block")
self.assertLess(pos_chip, pos_render, self.assertLess(pos_sync, pos_render,
"updateWorkspaceChip() must be called before renderSessionList()") "syncTopbar() must be called before renderSessionList()")
if __name__ == '__main__': if __name__ == '__main__':