diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a45cf9..0e457e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.227] — 2026-06-03 — Release GU (stage-p11 — keep the active New Chat visible in the sidebar) + +### Fixed +- A freshly-created **New Chat** now stays visible and selected in the sidebar before its first message is sent. The sidebar intentionally filters inactive 0-message sessions, but that filter also hid the *currently active* blank chat until the user sent a turn — so starting a New Chat could make the selected row vanish from the list. The active ephemeral session is now injected into the sidebar render rows (only when the server-side list omits it), while inactive empty sessions stay filtered as before. Starting a New Chat from a CLI-filtered sidebar also switches the source filter back to WebUI so the active chat isn't immediately hidden (#3408, @AJV20). + ## [v0.51.226] — 2026-06-03 — Release GT (stage-p9 — mobile composer context-usage ring + activity-feed default-expand setting) ### Added diff --git a/static/sessions.js b/static/sessions.js index 7f80ec12..07e3955f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(_){} @@ -3732,6 +3733,41 @@ 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); + // Only re-inject the active FRESHLY-CREATED 0-message ephemeral chat. An active + // conversation that already has messages and was filtered out by the search + // query must stay filtered — re-adding it here would pollute unrelated search + // results with the current chat (#3408 review, Codex). + if(activeRow && Number(activeRow.message_count||0)<=0){ + return [activeRow,...rows]; + } + return rows; +} + function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; @@ -3892,14 +3928,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) || diff --git a/tests/test_active_empty_session_sidebar.py b/tests/test_active_empty_session_sidebar.py new file mode 100644 index 00000000..8997f201 --- /dev/null +++ b/tests/test_active_empty_session_sidebar.py @@ -0,0 +1,48 @@ +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 + + +def test_active_row_reinjection_gated_to_zero_message_ephemeral_only(): + """#3408 review (Codex): _ensureActiveSessionRowPresent must only re-add the + active FRESHLY-CREATED 0-message chat after search-merge. An active conversation + that already has messages and was filtered out by the search query must stay + filtered — re-adding it would pollute unrelated search results with the current + chat.""" + start = SESSIONS_JS.index("function _ensureActiveSessionRowPresent(rows, sourceRows)") + end = SESSIONS_JS.index("function clearOptimisticSessionStreaming", start) + body = SESSIONS_JS[start:end] + + # The reinjection is gated on a 0-message check, not an unconditional prepend. + assert "Number(activeRow.message_count||0)<=0" in body + assert "[activeRow,...rows]" in body + # The unconditional return that shipped in the original PR must be gone. + assert "return activeRow?[activeRow,...rows]:rows;" not in body diff --git a/tests/test_session_copy_link_action.py b/tests/test_session_copy_link_action.py index 9928a89f..b06f248c 100644 --- a/tests/test_session_copy_link_action.py +++ b/tests/test_session_copy_link_action.py @@ -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