diff --git a/api/models.py b/api/models.py
index 75c22b8e..78aebbfe 100644
--- a/api/models.py
+++ b/api/models.py
@@ -4056,6 +4056,14 @@ def _session_is_evictable(s) -> bool:
on-disk ``message_count`` being at least the in-memory message count.
A metadata-only stub is inherently backed by disk, so it is evictable.
+ The persistence requirement holds even for a session with ZERO messages.
+ ``new_session()`` deliberately does not touch disk until the first message
+ (#1171), so between "New Conversation" and the first send, this cache is the
+ session's ONLY copy. Evicting it does not discard a recreatable empty shell:
+ ``get_session()`` has no recreate path and raises ``KeyError``, so the very
+ next ``/api/session/draft`` or ``/api/chat/start`` 404s and the session can
+ never be started. Sessions the user is still composing must stay resident.
+
Anything we cannot positively prove is safe stays resident. Using slightly
more RAM for a session we are unsure about is strictly better than evicting
an active or unsaved session (task safety invariant: a half-done memory fix
@@ -4077,14 +4085,12 @@ def _session_is_evictable(s) -> bool:
if getattr(s, '_loaded_metadata_only', False):
return True
in_memory_count = len(getattr(s, 'messages', None) or [])
- if in_memory_count == 0:
- # A zero-message session has nothing to lose. If it was never persisted
- # (brand new, no sidecar) dropping it only discards an empty shell; the
- # next access recreates it. If it is persisted, it is trivially clean.
- return True
disk_count = _persisted_message_count(sid)
if disk_count is None:
return False # cannot prove it is on disk → keep it resident
+ if in_memory_count == 0:
+ # Persisted and empty → trivially clean, nothing to lose.
+ return True
return disk_count >= in_memory_count
diff --git a/static/index.html b/static/index.html
index 0ca1bc78..8f9c625f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -200,7 +200,7 @@
-
+
@@ -225,7 +225,7 @@
-
+
@@ -253,7 +253,7 @@
-
+
Loading...
@@ -353,8 +353,9 @@
-
+
@@ -1541,11 +1542,20 @@
Enter a new password to set or change it. Leave blank to keep current setting.
-
+
+
The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.
`:'';
- return `${helpHtml}`;
+ // The ${helpHtml}`;
}
function _getOnboardingSelectedModel(){
@@ -392,11 +396,15 @@ function _renderOnboardingBody(){
if(key==='password'){
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
+ // See the api-key field above: the
${t('onboarding_password_help')}
`;
return;
}
diff --git a/static/style.css b/static/style.css
index 0149dadc..2b647fae 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1565,7 +1565,12 @@
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;}
.sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;}
.session-search-field{position:relative;display:flex;align-items:center;width:100%;}
- .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;}
+ .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;-webkit-appearance:none;appearance:none;}
+ /* The search inputs are type="search" so password managers don't mistake them
+ for username fields. Suppress WebKit's native clear affordance: these fields
+ ship their own clear button (e.g. #sessionSearchClear). */
+ .sidebar-search input::-webkit-search-cancel-button,
+ .sidebar-search input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none;}
.session-search input{padding-right:34px;}
.sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
.sidebar-search input::placeholder{color:var(--muted);opacity:.7;}
diff --git a/tests/test_issue4765_sessions_lru_eviction.py b/tests/test_issue4765_sessions_lru_eviction.py
index 620c9747..fb0cff81 100644
--- a/tests/test_issue4765_sessions_lru_eviction.py
+++ b/tests/test_issue4765_sessions_lru_eviction.py
@@ -333,3 +333,41 @@ def test_eviction_skips_active_but_still_bounds_clean_entries(isolated_session_e
# It may briefly sit slightly above cap because actives are non-evictable,
# but it must NOT grow unbounded with the 40 churned sessions.
assert len(SESSIONS) <= _cfg.SESSIONS_MAX + len(actives)
+
+
+def test_unsaved_new_session_survives_churn_and_stays_startable(isolated_session_env):
+ """A brand-new, never-persisted session must not be evicted.
+
+ ``new_session()`` keeps a session in RAM only until its first message
+ (#1171), so the cache is its ONLY copy. The original #4765 predicate treated
+ any zero-message session as evictable, reasoning that an empty shell "is
+ recreated on next access" — but ``get_session()`` has no recreate path and
+ raises ``KeyError``, so ``/api/session/draft`` and ``/api/chat/start`` both
+ 404 and the session can never be started.
+
+ Real-world trigger: a browser password manager autofilled the sidebar
+ conversation filter, whose content search pulls every hit through
+ ``get_session()``. That churn blew past the cap and dropped the session the
+ user was composing in.
+ """
+ from api import config as _cfg
+ from api.config import SESSIONS
+ from api.models import get_session, new_session
+
+ _cfg.SESSIONS_MAX = 5
+
+ composing = new_session()
+ sid = composing.session_id
+ assert not (_cfg.SESSION_DIR / f"{sid}.json").exists(), (
+ "precondition: new_session() must not persist before the first message"
+ )
+
+ # Content-search-style churn: far more persisted sessions than the cap.
+ for i in range(40):
+ _insert(_make_persisted_session(i))
+
+ assert sid in SESSIONS, "unsaved new session was evicted — its only copy is gone"
+
+ # The chokepoint both failing routes go through.
+ assert get_session(sid, metadata_only=True) is not None
+ assert get_session(sid).session_id == sid