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__':