Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

## [Unreleased]

### Fixed
- New blank WebUI chats now stay visible and selected in the sidebar before the
first message is sent, including when New Chat is opened while the sidebar is
filtered to CLI sessions.

## [v0.51.210] — 2026-06-02 — Release GD (stage-batch1 — model-picker multi-slash fix + extensionless preview highlighting)

### Fixed
Expand Down
41 changes: 36 additions & 5 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ async function newSession(flash, options={}){
}
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
S.session=data.session;S.messages=data.session.messages||[];
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
Expand Down Expand Up @@ -3662,6 +3663,34 @@ function upsertActiveSessionForLocalTurn({title='', messageCount=0, timestampMs=
renderSessionListFromCache();
}

function _sessionRowsWithActiveEphemeralSession(rows){
rows=Array.isArray(rows)?rows:[];
if(!S.session||!S.session.session_id) return rows;
const sid=S.session.session_id;
if(rows.some(s=>s&&s.session_id===sid)) return rows;
const nowSec=Math.floor(Date.now()/1000);
const activeRow={
...S.session,
session_id:sid,
title:S.session.title||'New Chat',
display_title:S.session.display_title||S.session.title||'New Chat',
message_count:0,
last_message_at:S.session.last_message_at||S.session.updated_at||nowSec,
updated_at:S.session.updated_at||S.session.last_message_at||nowSec,
profile:S.session.profile||S.activeProfile||'default',
is_streaming:false,
};
return [activeRow,...rows];
}

function _ensureActiveSessionRowPresent(rows, sourceRows){
rows=Array.isArray(rows)?rows:[];
const activeSid=_activeSessionIdForSidebar();
if(!activeSid||rows.some(s=>s&&s.session_id===activeSid)) return rows;
const activeRow=(Array.isArray(sourceRows)?sourceRows:[]).find(s=>s&&s.session_id===activeSid);
return activeRow?[activeRow,...rows]:rows;
}

function clearOptimisticSessionStreaming(sid){
sid=sid||(S.session&&S.session.session_id)||'';
if(!sid) return;
Expand Down Expand Up @@ -3822,14 +3851,16 @@ function renderSessionListFromCache(){
const searchQueryRaw=($('sessionSearch').value||'').trim();
const q=searchQueryRaw.toLowerCase();
const activeSidForSidebar=_activeSessionIdForSidebar();
const sidebarRows=_sessionRowsWithActiveEphemeralSession(_allSessions);
// Merge direct session-id/link matches, title matches, then content matches (deduped).
// Direct matches must not disable content search: if a user pasted the same
// session id into another conversation, that content hit should still appear.
const allMatched=_sessionSearchMergeMatches(_allSessions,searchQueryRaw,_contentSearchResults);
// Never surface ephemeral 0-message sessions in the sidebar — they only become
// real once the first message is sent. The server already filters them, but this
// guard ensures a brand-new active session doesn't flash into the list while
// _allSessions is stale from a prior render (#1171).
const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);
const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);
// Keep inactive ephemeral 0-message sessions out of the sidebar — they only
// become real once the first message is sent. The server already filters them.
// Exception: the active freshly-created chat is injected above so it remains
// visible/selected until the user sends the first turn or switches away.
const withMessages=allMatched.filter(s=>
(s.message_count||0)>0 ||
_sessionAttentionState(s) ||
Expand Down
31 changes: 31 additions & 0 deletions tests/test_active_empty_session_sidebar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")


def test_active_empty_session_is_injected_into_sidebar_rows():
assert "function _sessionRowsWithActiveEphemeralSession(rows)" in SESSIONS_JS
helper_start = SESSIONS_JS.index("function _sessionRowsWithActiveEphemeralSession(rows)")
helper_end = SESSIONS_JS.index("function renderSessionListFromCache()", helper_start)
helper = SESSIONS_JS[helper_start:helper_end]

assert "S.session" in helper
assert "message_count:0" in helper
assert "title:S.session.title||'New Chat'" in helper
assert "rows.some(s=>s&&s.session_id===sid)" in helper


def test_new_session_switches_sidebar_back_to_webui_source():
new_session = SESSIONS_JS[SESSIONS_JS.index("async function newSession"):SESSIONS_JS.index("async function loadSession")]
assert "if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';" in new_session


def test_sidebar_search_uses_active_ephemeral_rows_before_filtering():
render_start = SESSIONS_JS.index("function renderSessionListFromCache()")
render_end = SESSIONS_JS.index("function _showProjectPicker", render_start)
render_body = SESSIONS_JS[render_start:render_end]

assert "const sidebarRows=_sessionRowsWithActiveEphemeralSession(_allSessions);" in render_body
assert "const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);" in render_body
assert "const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);" in render_body
4 changes: 3 additions & 1 deletion tests/test_session_copy_link_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ def test_conversation_filter_merges_direct_title_and_content_matches_without_dro
def test_conversation_filter_keeps_content_search_results_when_query_is_session_id():
assert "function _sessionSearchMergeMatches" in SESSIONS_JS
assert "function _sessionSearchDirectAndTitleMatches" in SESSIONS_JS
assert "const allMatched=_sessionSearchMergeMatches(_allSessions,searchQueryRaw,_contentSearchResults);" in SESSIONS_JS
assert "const sidebarRows=_sessionRowsWithActiveEphemeralSession(_allSessions);" in SESSIONS_JS
assert "const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);" in SESSIONS_JS
assert "const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);" in SESSIONS_JS
assert "const directAndTitleMatches=_sessionSearchDirectAndTitleMatches(_allSessions,currentQ);" in SESSIONS_JS
assert "const directOrTitleIds=new Set(directAndTitleMatches.map(s=>s.session_id));" in SESSIONS_JS
assert "!directOrTitleIds.has(s.session_id)" in SESSIONS_JS
Expand Down
Loading