mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
@@ -4324,6 +4324,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"font_size": "default", # small | default | large | xlarge
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
|
||||
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
@@ -4452,6 +4453,9 @@ _SETTINGS_ENUM_VALUES = {
|
||||
"auto_title_refresh_every": {"0", "5", "10", "20"},
|
||||
"busy_input_mode": {"queue", "interrupt", "steer"},
|
||||
}
|
||||
_SETTINGS_INT_RANGES = {
|
||||
"pinned_sessions_limit": (1, 99),
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
"show_token_usage",
|
||||
@@ -4512,6 +4516,15 @@ def save_settings(settings: dict) -> dict:
|
||||
# Validate enum-constrained keys
|
||||
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
||||
continue
|
||||
# Validate bounded integer settings.
|
||||
if k in _SETTINGS_INT_RANGES:
|
||||
try:
|
||||
v = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
min_value, max_value = _SETTINGS_INT_RANGES[k]
|
||||
if v < min_value or v > max_value:
|
||||
continue
|
||||
# Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
|
||||
if k == "language" and (
|
||||
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
|
||||
|
||||
+3
-2
@@ -5767,8 +5767,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
if getattr(existing, "pinned", False) and not getattr(existing, "archived", False)
|
||||
)
|
||||
pinned_ids.discard(body["session_id"])
|
||||
if len(pinned_ids) >= 3:
|
||||
return bad(handler, "Up to 3 sessions can be pinned. Unpin one before pinning another.", 400)
|
||||
pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)
|
||||
if len(pinned_ids) >= pinned_sessions_limit:
|
||||
return bad(handler, f"Up to {pinned_sessions_limit} sessions can be pinned. Unpin one before pinning another.", 400)
|
||||
# Mark in-memory pin state under LOCK so concurrent pin
|
||||
# requests see the increment immediately, even before
|
||||
# save() finishes flushing to disk.
|
||||
|
||||
@@ -1451,6 +1451,7 @@ function applyBotName(){
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
|
||||
window._busyInputMode=(s.busy_input_mode||'queue');
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
@@ -1539,6 +1540,7 @@ function applyBotName(){
|
||||
window._simplifiedToolCalling=true;
|
||||
window._sessionJumpButtonsEnabled=false;
|
||||
window._sidebarDensity='compact';
|
||||
window._pinnedSessionsLimit=3;
|
||||
window._busyInputMode='queue';
|
||||
window._sessionEndlessScrollEnabled=false;
|
||||
window._botName='Hermes';
|
||||
|
||||
@@ -1126,6 +1126,11 @@
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsPinnedSessionsLimit">Pinned conversations limit</label>
|
||||
<input type="number" id="settingsPinnedSessionsLimit" min="1" max="99" step="1" inputmode="numeric" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Maximum number of active conversations that can be pinned in the sidebar. Default is 3.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsAutoTitleRefresh" data-i18n="settings_label_auto_title_refresh">Adaptive title refresh</label>
|
||||
<select id="settingsAutoTitleRefresh" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
|
||||
@@ -5442,6 +5442,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(notifCb) payload.notifications_enabled=notifCb.checked;
|
||||
const sidebarDensitySel=$('settingsSidebarDensity');
|
||||
if(sidebarDensitySel) payload.sidebar_density=sidebarDensitySel.value;
|
||||
const pinnedLimitField=$('settingsPinnedSessionsLimit');
|
||||
if(pinnedLimitField) payload.pinned_sessions_limit=parseInt(pinnedLimitField.value,10);
|
||||
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
|
||||
if(autoTitleRefreshSel) payload.auto_title_refresh_every=parseInt(autoTitleRefreshSel.value,10);
|
||||
const busyInputModeSel=$('settingsBusyInputMode');
|
||||
@@ -5493,6 +5495,7 @@ async function _autosavePreferencesSettings(payload){
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
}
|
||||
if(payload&&Object.prototype.hasOwnProperty.call(payload,'fade_text_effect')) window._fadeTextEffect=!!payload.fade_text_effect;
|
||||
if(saved&&Object.prototype.hasOwnProperty.call(saved,'pinned_sessions_limit')) window._pinnedSessionsLimit=parseInt(saved.pinned_sessions_limit,10)||3;
|
||||
if(payload&&payload.show_tps!==undefined){
|
||||
window._showTps=!!(saved&&saved.show_tps);
|
||||
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
|
||||
@@ -5699,6 +5702,13 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
const showTpsCb=$('settingsShowTps');
|
||||
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const pinnedLimitField=$('settingsPinnedSessionsLimit');
|
||||
if(pinnedLimitField){
|
||||
pinnedLimitField.value=parseInt(settings.pinned_sessions_limit||3,10)||3;
|
||||
window._pinnedSessionsLimit=parseInt(pinnedLimitField.value,10)||3;
|
||||
pinnedLimitField.addEventListener('change',_schedulePreferencesAutosave,{once:false});
|
||||
pinnedLimitField.addEventListener('input',()=>{window._pinnedSessionsLimit=parseInt(pinnedLimitField.value,10)||3;_schedulePreferencesAutosave();},{once:false});
|
||||
}
|
||||
const fadeTextCb=$('settingsFadeTextEffect');
|
||||
if(fadeTextCb){fadeTextCb.checked=!!settings.fade_text_effect;window._fadeTextEffect=fadeTextCb.checked;fadeTextCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
@@ -6604,6 +6614,7 @@ async function saveSettings(andClose){
|
||||
const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
const showPreviousMessagingSessions=!!($('settingsShowPreviousMessagingSessions')||{}).checked;
|
||||
const pinnedSessionsLimit=parseInt(($('settingsPinnedSessionsLimit')||{}).value,10)||3;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const theme=($('settingsTheme')||{}).value||'dark';
|
||||
const skin=($('settingsSkin')||{}).value||'default';
|
||||
@@ -6628,6 +6639,7 @@ async function saveSettings(andClose){
|
||||
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.show_previous_messaging_sessions=showPreviousMessagingSessions;
|
||||
body.pinned_sessions_limit=pinnedSessionsLimit;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
|
||||
|
||||
+11
-3
@@ -1481,6 +1481,14 @@ function _sessionSnapshotById(sid){
|
||||
function _pinnedSessionCount(){
|
||||
return (_allSessions||[]).filter(s=>s&&s.pinned&&!s.archived).length;
|
||||
}
|
||||
function _pinnedSessionsLimit(){
|
||||
const limit=parseInt(window._pinnedSessionsLimit||3,10);
|
||||
return (Number.isFinite(limit)&&limit>0)?limit:3;
|
||||
}
|
||||
function _pinnedSessionsLimitMessage(){
|
||||
const limit=_pinnedSessionsLimit();
|
||||
return `Only ${limit} conversations can be pinned. Unpin one before pinning another.`;
|
||||
}
|
||||
function _worktreeSessionCount(ids){
|
||||
return (ids||[]).reduce((count,sid)=>{
|
||||
const session=_sessionSnapshotById(sid);
|
||||
@@ -1791,15 +1799,15 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
}
|
||||
));
|
||||
}
|
||||
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;
|
||||
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_pinnedSessionsLimit();
|
||||
menu.appendChild(_buildSessionAction(
|
||||
session.pinned?t('session_unpin'):t('session_pin'),
|
||||
pinLimitReached?'Only 3 conversations can be pinned':(session.pinned?t('session_unpin_desc'):t('session_pin_desc')),
|
||||
pinLimitReached?_pinnedSessionsLimitMessage():(session.pinned?t('session_unpin_desc'):t('session_pin_desc')),
|
||||
session.pinned?ICONS.pin:ICONS.unpin,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
if(pinLimitReached){
|
||||
if(typeof showToast==='function') showToast('Only 3 conversations can be pinned. Unpin one before pinning another.',3000,'error');
|
||||
if(typeof showToast==='function') showToast(_pinnedSessionsLimitMessage(),3000,'error');
|
||||
return;
|
||||
}
|
||||
const newPinned=!session.pinned;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Regression checks for configurable pinned session limits."""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
def make_session(created, title):
|
||||
payload = {
|
||||
"title": title,
|
||||
"messages": [{"role": "user", "content": "keep this conversation handy"}],
|
||||
"model": "test/pin-limit-setting",
|
||||
}
|
||||
d, status = post("/api/session/import", payload)
|
||||
assert status == 200
|
||||
sid = d["session"]["session_id"]
|
||||
created.append(sid)
|
||||
return sid
|
||||
|
||||
|
||||
def test_pin_limit_setting_is_exposed_and_wired_through_ui():
|
||||
assert '"pinned_sessions_limit": 3' in CONFIG_PY
|
||||
assert '"pinned_sessions_limit": (1, 99)' in CONFIG_PY
|
||||
assert 'id="settingsPinnedSessionsLimit"' in INDEX_HTML
|
||||
assert 'type="number"' in INDEX_HTML
|
||||
assert 'min="1"' in INDEX_HTML
|
||||
assert 'max="99"' in INDEX_HTML
|
||||
assert 'payload.pinned_sessions_limit=parseInt(pinnedLimitField.value,10)' in PANELS_JS
|
||||
assert "settings.pinned_sessions_limit" in PANELS_JS
|
||||
assert "window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3" in BOOT_JS
|
||||
assert "function _pinnedSessionsLimit()" in SESSIONS_JS
|
||||
assert "_pinnedSessionCount()>=_pinnedSessionsLimit()" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_settings_api_persists_integer_pin_limit_and_rejects_invalid_values():
|
||||
try:
|
||||
d, status = post("/api/settings", {"pinned_sessions_limit": 5})
|
||||
assert status == 200
|
||||
assert d["pinned_sessions_limit"] == 5
|
||||
|
||||
d, status = post("/api/settings", {"pinned_sessions_limit": "7"})
|
||||
assert status == 200
|
||||
assert d["pinned_sessions_limit"] == 7
|
||||
|
||||
d, status = post("/api/settings", {"pinned_sessions_limit": 0})
|
||||
assert status == 200
|
||||
assert d["pinned_sessions_limit"] == 7
|
||||
|
||||
d, status = post("/api/settings", {"pinned_sessions_limit": 100})
|
||||
assert status == 200
|
||||
assert d["pinned_sessions_limit"] == 7
|
||||
finally:
|
||||
post("/api/settings", {"pinned_sessions_limit": 3})
|
||||
|
||||
|
||||
def test_session_pin_endpoint_uses_configured_limit():
|
||||
created = []
|
||||
try:
|
||||
d, status = post("/api/settings", {"pinned_sessions_limit": 4})
|
||||
assert status == 200
|
||||
assert d["pinned_sessions_limit"] == 4
|
||||
|
||||
pinned = [make_session(created, f"Configured pin cap {i}") for i in range(4)]
|
||||
for sid in pinned:
|
||||
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
|
||||
assert status == 200
|
||||
assert d["session"]["pinned"] is True
|
||||
|
||||
fifth = make_session(created, "Configured pin cap overflow")
|
||||
d, status = post("/api/session/pin", {"session_id": fifth, "pinned": True})
|
||||
assert status == 400
|
||||
assert "4 sessions" in d.get("error", "")
|
||||
finally:
|
||||
post("/api/settings", {"pinned_sessions_limit": 3})
|
||||
for sid in created:
|
||||
post("/api/session/delete", {"session_id": sid})
|
||||
@@ -70,12 +70,14 @@ def test_session_pin_endpoint_caps_pinned_sessions_at_three():
|
||||
def test_session_pin_cap_has_backend_and_frontend_guards():
|
||||
assert 'pinned_ids = {' in ROUTES_PY
|
||||
assert 'pinned_ids.update(' in ROUTES_PY
|
||||
assert 'if len(pinned_ids) >= 3:' in ROUTES_PY
|
||||
assert 'Up to 3 sessions can be pinned' in ROUTES_PY
|
||||
assert 'pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)' in ROUTES_PY
|
||||
assert 'if len(pinned_ids) >= pinned_sessions_limit:' in ROUTES_PY
|
||||
assert 'Up to {pinned_sessions_limit} sessions can be pinned' in ROUTES_PY
|
||||
|
||||
assert 'function _pinnedSessionCount()' in SESSIONS_JS
|
||||
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;' in SESSIONS_JS
|
||||
assert "Only 3 conversations can be pinned" in SESSIONS_JS
|
||||
assert 'function _pinnedSessionsLimit()' in SESSIONS_JS
|
||||
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_pinnedSessionsLimit();' in SESSIONS_JS
|
||||
assert 'Only ${limit} conversations can be pinned' in SESSIONS_JS
|
||||
assert ".session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}" in STYLE_CSS
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user