diff --git a/api/helpers.py b/api/helpers.py
index 5c984f0..fd04bda 100644
--- a/api/helpers.py
+++ b/api/helpers.py
@@ -211,17 +211,19 @@ def get_profile_cookie(handler) -> str | None:
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
- name='default' clears the cookie (max-age=0).
- Any other valid profile name sets it for the browser session.
- httponly=True: the JS reads profile from /api/profile/active JSON, never
- from document.cookie, so httponly exposure is unnecessary.
+ Always persist the selected profile in the cookie, including 'default'.
+ Clearing the cookie causes the backend to fall back to process-global
+ _active_profile, which can unexpectedly switch clients back to another
+ 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
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]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
- if name == 'default':
- cookie[PROFILE_COOKIE_NAME]['max-age'] = '0'
return cookie[PROFILE_COOKIE_NAME].OutputString()
diff --git a/static/panels.js b/static/panels.js
index e9a48d6..885d5c1 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -843,6 +843,9 @@ async function loadProfilesPanel() {
panel.innerHTML = `
${esc(t('profiles_no_profiles'))}
`;
return;
}
+ const activeName = (S.activeProfile && data.profiles.some(p => p.name === S.activeProfile))
+ ? S.activeProfile
+ : (data.active || 'default');
for (const p of data.profiles) {
const card = document.createElement('div');
card.className = 'profile-card';
@@ -854,7 +857,7 @@ async function loadProfilesPanel() {
const gwDot = p.gateway_running
? ``
: ``;
- const isActive = p.name === data.active;
+ const isActive = p.name === activeName;
const activeBadge = isActive ? `${esc(t('profile_active'))}` : '';
const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : '';
card.innerHTML = `
@@ -880,7 +883,9 @@ function renderProfileDropdown(data) {
if (!dd) return;
dd.innerHTML = '';
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) {
const opt = document.createElement('div');
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
@@ -1005,7 +1010,9 @@ async function switchToProfile(name) {
S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {}
}
- updateWorkspaceChip();
+ // Keep topbar chips (workspace/profile) in sync after creating the
+ // new profile-scoped session.
+ syncTopbar();
await renderSessionList();
showToast(t('profile_switched_new_conversation', name));
} else {
diff --git a/tests/test_issue803.py b/tests/test_issue803.py
index 9190362..f882e14 100644
--- a/tests/test_issue803.py
+++ b/tests/test_issue803.py
@@ -33,12 +33,11 @@ class TestProfileCookieHelpers:
assert 'SameSite=Lax' 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
s = build_profile_cookie('default')
- assert 'Max-Age=0' in s
- # Empty value indicates clear
- assert 'hermes_profile=""' in s or 'hermes_profile=;' in s
+ assert 'hermes_profile=default' in s
+ assert 'Max-Age=0' not in s
def test_get_profile_cookie_returns_none_when_absent(self):
from api.helpers import get_profile_cookie
diff --git a/tests/test_sprint40_ui_polish.py b/tests/test_sprint40_ui_polish.py
index 0cb798f..b71141c 100644
--- a/tests/test_sprint40_ui_polish.py
+++ b/tests/test_sprint40_ui_polish.py
@@ -202,10 +202,10 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
"""Verify that switchToProfile() applies the profile default workspace
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,
- the code must call updateWorkspaceChip() so the chip reflects the
- new profile's default workspace instead of showing 'No active workspace'."""
+ the code must call syncTopbar() so the profile/workspace chips reflect
+ the new profile's default workspace."""
# Find the sessionInProgress block
idx = PANELS_JS.find('if (sessionInProgress)')
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,
"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_update_chip = block.find('updateWorkspaceChip()')
- self.assertGreater(pos_update_chip, -1,
- "updateWorkspaceChip() must be called in the sessionInProgress branch")
- self.assertGreater(pos_update_chip, pos_new_session,
- "updateWorkspaceChip() must be called AFTER newSession(false)")
+ pos_sync_topbar = block.find('syncTopbar()')
+ self.assertGreater(pos_sync_topbar, -1,
+ "syncTopbar() must be called in the sessionInProgress branch")
+ self.assertGreater(pos_sync_topbar, pos_new_session,
+ "syncTopbar() must be called AFTER newSession(false)")
def test_profile_default_workspace_applied_to_new_session(self):
"""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 "
"to persist the new workspace after newSession(false)")
- def test_update_workspace_chip_before_render_session_list(self):
- """updateWorkspaceChip() should be called before renderSessionList()
- so the chip is correct when the UI re-renders."""
+ def test_sync_topbar_before_render_session_list(self):
+ """syncTopbar() should be called before renderSessionList()
+ so the chips are correct when the UI re-renders."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
- pos_chip = block.find('updateWorkspaceChip()')
+ pos_sync = block.find('syncTopbar()')
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.assertLess(pos_chip, pos_render,
- "updateWorkspaceChip() must be called before renderSessionList()")
+ self.assertLess(pos_sync, pos_render,
+ "syncTopbar() must be called before renderSessionList()")
if __name__ == '__main__':