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:
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
Reference in New Issue
Block a user