From 9a5a3eb6d50ec01a9bb1ea073c4a0e6dc44e1b0d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 05:56:00 -0400 Subject: [PATCH 01/11] fix(#4766): filter /api/sessions by active source bucket --- api/routes.py | 24 ++ static/sessions.js | 28 +- .../test_issue4766_sidebar_source_pushdown.py | 240 ++++++++++++++++++ 3 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue4766_sidebar_source_pushdown.py diff --git a/api/routes.py b/api/routes.py index 25796f0b..c6c4e404 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1598,6 +1598,7 @@ def _session_list_cache_key( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, ) -> tuple: return ( _session_list_cache_profile_scope(active_profile), @@ -1607,6 +1608,7 @@ def _session_list_cache_key( bool(show_cron_sessions), bool(include_archived), source_filter, + sidebar_source, ) @@ -1925,6 +1927,7 @@ def _build_session_list_cache_payload( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, diag=None, ) -> dict: diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None @@ -2097,6 +2100,18 @@ def _build_session_list_cache_payload( ) archived_count = archived_webui_count + archived_cli_count scoped = archived_scoped if include_archived else visible_scoped + webui_session_count = sum( + 1 for s in scoped + if not _is_cli_session_for_settings(s) + ) + cli_session_count = sum( + 1 for s in scoped + if _is_cli_session_for_settings(s) + ) + if sidebar_source == "webui": + scoped = [s for s in scoped if not _is_cli_session_for_settings(s)] + elif sidebar_source == "cli": + scoped = [s for s in scoped if _is_cli_session_for_settings(s)] if not include_archived: diag_stage("filter_archived_sessions") diag_stage("visible_lineage_metadata") @@ -2110,6 +2125,8 @@ def _build_session_list_cache_payload( "archived_count": archived_count, "archived_webui_count": archived_webui_count, "archived_cli_count": archived_cli_count, + "webui_session_count": webui_session_count, + "cli_session_count": cli_session_count, "include_archived": include_archived, "all_profiles": all_profiles, "active_profile": active_profile, @@ -2134,6 +2151,8 @@ def _session_list_payload_to_response(payload: dict) -> dict: "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), "archived_cli_count": int(payload.get("archived_cli_count", 0)), + "webui_session_count": int(payload.get("webui_session_count", 0)), + "cli_session_count": int(payload.get("cli_session_count", 0)), "include_archived": bool(payload.get("include_archived", False)), "all_profiles": bool(payload.get("all_profiles", False)), "active_profile": payload.get("active_profile"), @@ -9733,6 +9752,9 @@ def handle_get(handler, parsed) -> bool: active_profile = get_active_profile_name() all_profiles = _all_profiles_enabled(parsed) include_archived = _query_flag(parsed, "include_archived") + sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None + if sidebar_source not in ("webui", "cli"): + sidebar_source = None key = _session_list_cache_key( active_profile=active_profile, all_profiles=all_profiles, @@ -9741,6 +9763,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, ) # Keep the visible /api/sessions contract unchanged even though the # heavy lifting now lives in the cache builder: profile scoping via @@ -9756,6 +9779,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, diag=diag, ), diag=diag, diff --git a/static/sessions.js b/static/sessions.js index c5606e3a..5863de55 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2673,6 +2673,8 @@ let _showAllProfiles = false; // false = filter to active profile only let _otherProfileCount = 0; // count of sessions from other profiles (server-reported) let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested +let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions +let _serverCliSessionCount = null; // explicit server count for CLI sessions let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions _restoreSessionSourceFilter(); let _sessionActionMenu = null; @@ -3746,6 +3748,14 @@ function _applySessionListPayload(sessData, projData){ _otherProfileCount = sessData.other_profile_count || 0; _archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0); _archivedCliCount = Number(sessData.archived_cli_count ?? 0); + _serverWebuiSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'webui_session_count') + ? Number(sessData.webui_session_count) + : null; + _serverCliSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'cli_session_count') + ? Number(sessData.cli_session_count) + : null; + if (!Number.isFinite(_serverWebuiSessionCount)) _serverWebuiSessionCount = null; + if (!Number.isFinite(_serverCliSessionCount)) _serverCliSessionCount = null; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) @@ -3827,6 +3837,8 @@ async function _runRenderSessionListRefresh(opts, _gen){ try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const qs = new URLSearchParams(); + const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; + qs.set('sidebar_source', requestSidebarSource); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; @@ -5550,12 +5562,14 @@ function renderSessionListFromCache(){ }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar); const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw; const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw); - const renderedWebuiSessionCount=_sessionSourceFilter==='webui' - ? sessions.length - : _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; - const renderedCliSessionCount=_sessionSourceFilter==='cli' - ? sessions.length - : _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; + const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const webuiSessionTabCount=Number.isFinite(_serverWebuiSessionCount) + ? _serverWebuiSessionCount + : renderedWebuiSessionCount; + const cliSessionTabCount=Number.isFinite(_serverCliSessionCount) + ? _serverCliSessionCount + : renderedCliSessionCount; _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; @@ -5616,7 +5630,7 @@ function renderSessionListFromCache(){ const sourceTabs=document.createElement('div'); sourceTabs.className='session-source-tabs'; for(const filter of ['webui','cli']){ - const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount; + const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount; const btn=document.createElement('button'); btn.type='button'; btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':''); diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py new file mode 100644 index 00000000..3331baaf --- /dev/null +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -0,0 +1,240 @@ +"""Regression coverage for issue #4766: `/api/sessions` filters by active sidebar source.""" + +import io +import json +from pathlib import Path +from urllib.parse import urlparse + +import api.profiles as profiles +import api.routes as routes +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + pass + + def json_body(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def _session_rows( + webui_count, + cli_count, + archived_webui_count=0, + archived_cli_count=0, + start=0, +): + rows = [] + for index in range(webui_count): + rows.append( + { + "session_id": f"webui-{start + index}", + "title": "WebUI Session", + "profile": "default", + "archived": index < archived_webui_count, + "message_count": 1, + "updated_at": 1000 + index, + "last_message_at": 1000 + index, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + } + ) + for index in range(cli_count): + rows.append( + { + "session_id": f"cli-{start + index + 10000}", + "title": "Imported CLI session", + "profile": "default", + "archived": index < archived_cli_count, + "message_count": 1, + "updated_at": 2000 + index, + "last_message_at": 2000 + index, + "source": "cli", + "raw_source": "cli", + "session_source": "cli", + "source_tag": "cli", + } + ) + return rows + + +def _handle_sessions(url): + handler = _FakeHandler() + routes.handle_get(handler, urlparse(url)) + return handler + + +@pytest.fixture(autouse=True) +def _clear_cache(): + routes._session_list_cache_clear() + yield + routes._session_list_cache_clear() + + +def _install_common_monkeypatches(monkeypatch, rows): + enriched = [] + row_ids = {str(row["session_id"]) for row in rows if row.get("session_id")} + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False) + monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda rows: enriched.append([r["session_id"] for r in rows])) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) + monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(row_ids & {str(sid) for sid in ids})) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + return enriched + + +def test_sidebar_source_webui_excludes_cli_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + enriched = _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 30 + assert all(r["session_id"].startswith("webui-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + assert body["archived_count"] == 0 + expected = { + row["session_id"] for row in rows + if not row["archived"] and row["session_id"].startswith("webui-") + } + assert set(enriched[0]) == expected + + +def test_sidebar_source_cli_excludes_webui_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=cli") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 20 + assert all(r["session_id"].startswith("cli-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + + +def test_sidebar_source_omitted_returns_all_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 50 + assert len([r for r in body["sessions"] if r["session_id"].startswith("webui-")]) == 30 + assert len([r for r in body["sessions"] if r["session_id"].startswith("cli-")]) == 20 + + +def test_sidebar_source_returns_cross_bucket_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + webui_rows = [r for r in rows if r["session_id"].startswith("webui-")] + cli_rows = [r for r in rows if r["session_id"].startswith("cli-")] + + body = handler.json_body() + assert handler.status == 200 + assert body["webui_session_count"] == len(webui_rows) + assert body["cli_session_count"] == len(cli_rows) + + +def test_sidebar_source_preserves_archived_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + body = handler.json_body() + + assert handler.status == 200 + assert body["archived_webui_count"] == 2 + assert body["archived_cli_count"] == 3 + assert body["archived_count"] == 5 + assert len([r for r in body["sessions"] if r["archived"]]) == 2 + + +def test_sidebar_source_varies_cache_key(): + key_webui = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="webui", + ) + key_cli = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="cli", + ) + key_omitted = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source=None, + ) + + assert key_webui != key_cli + assert key_webui != key_omitted + assert key_cli != key_omitted + + +def test_frontend_sends_sidebar_source_param(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui';" in src + assert "qs.set('sidebar_source', requestSidebarSource);" in src + assert "_serverWebuiSessionCount" in src + assert "_serverCliSessionCount" in src + assert "Number.isFinite(_serverWebuiSessionCount)" in src + assert "Number.isFinite(_serverCliSessionCount)" in src + assert "_sessionSourceLabel(filter,count)" in src + + +def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src + + +def test_payload_row_count_regression(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + body = handler.json_body() + + assert handler.status == 200 + assert len(body["sessions"]) == 30 From 73a209aca7d1c34571bf8a2fec1c236e2edf992f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:10:16 -0400 Subject: [PATCH 02/11] fix(#4766): close review gaps in sidebar source filtering --- api/routes.py | 9 +- static/sessions.js | 2 +- .../test_issue4766_sidebar_source_pushdown.py | 84 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/api/routes.py b/api/routes.py index c6c4e404..019fbf1c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2145,14 +2145,12 @@ def _session_list_payload_to_response(payload: dict) -> dict: for s in runtime_rows: item = _sidebar_session_response_item(s) if isinstance(s, dict) else {} safe_merged.append(item) - return { + response = { "sessions": safe_merged, "cli_count": int(payload.get("cli_count", 0)), "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), "archived_cli_count": int(payload.get("archived_cli_count", 0)), - "webui_session_count": int(payload.get("webui_session_count", 0)), - "cli_session_count": int(payload.get("cli_session_count", 0)), "include_archived": bool(payload.get("include_archived", False)), "all_profiles": bool(payload.get("all_profiles", False)), "active_profile": payload.get("active_profile"), @@ -2160,6 +2158,11 @@ def _session_list_payload_to_response(payload: dict) -> dict: "server_time": time.time(), "server_tz": time.strftime("%z"), } + if "webui_session_count" in payload: + response["webui_session_count"] = int(payload.get("webui_session_count", 0)) + if "cli_session_count" in payload: + response["cli_session_count"] = int(payload.get("cli_session_count", 0)) + return response def _get_cached_session_list_payload( diff --git a/static/sessions.js b/static/sessions.js index 5863de55..30ed46f2 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1569,7 +1569,7 @@ function _setSessionSourceFilter(filter) { _selectedSessions.clear(); _sessionSelectMode = false; try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} - renderSessionListFromCache(); + void renderSessionList({deferWhileInteracting:false}); } function _restoreSessionSourceFilter() { diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3331baaf..e81fb45a 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -3,6 +3,8 @@ import io import json from pathlib import Path +import shutil +import subprocess from urllib.parse import urlparse import api.profiles as profiles @@ -12,6 +14,7 @@ import pytest ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS = ROOT / "static" / "sessions.js" +NODE = shutil.which("node") class _FakeHandler: @@ -82,6 +85,22 @@ def _handle_sessions(url): return handler +def _extract_function(source_text, function_name): + marker = f"function {function_name}(" + start = source_text.index(marker) + brace_start = source_text.index("{", start) + depth = 0 + for index in range(brace_start, len(source_text)): + char = source_text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source_text[start : index + 1] + raise AssertionError(f"Could not extract {function_name}") + + @pytest.fixture(autouse=True) def _clear_cache(): routes._session_list_cache_clear() @@ -229,6 +248,71 @@ def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_source_switch_fetches_selected_bucket(): + src = SESSIONS_JS.read_text(encoding="utf-8") + fn_source = _extract_function(src, "_setSessionSourceFilter") + script = f""" +const renderCalls = []; +global._sessionSourceFilter = 'webui'; +global._activeProject = 'demo-project'; +global._selectedSessions = new Set(['first', 'second']); +global._sessionSelectMode = true; +global.localStorage = {{ + writes: [], + setItem(key, value) {{ + this.writes.push([key, value]); + }}, +}}; +global.renderSessionList = (opts) => {{ + renderCalls.push(opts); + return Promise.resolve(); +}}; +{fn_source} +_setSessionSourceFilter('cli'); +console.log(JSON.stringify({{ + sourceFilter: global._sessionSourceFilter, + activeProject: global._activeProject, + selectedSize: global._selectedSessions.size, + sessionSelectMode: global._sessionSelectMode, + storageWrites: global.localStorage.writes, + renderCalls, +}})); +""" + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + body = json.loads(proc.stdout) + + assert body["sourceFilter"] == "cli" + assert body["activeProject"] is None + assert body["selectedSize"] == 0 + assert body["sessionSelectMode"] is False + assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]] + assert body["renderCalls"] == [{"deferWhileInteracting": False}] + + +def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) + + body = routes._session_list_payload_to_response( + { + "sessions": [{"session_id": "webui-1", "title": "WebUI Session"}], + "cli_count": 0, + "archived_count": 0, + "archived_webui_count": 0, + "archived_cli_count": 0, + "include_archived": False, + "all_profiles": False, + "active_profile": "default", + "other_profile_count": 0, + } + ) + + assert "webui_session_count" not in body + assert "cli_session_count" not in body + assert body["sessions"][0]["session_id"] == "webui-1" + + def test_payload_row_count_regression(monkeypatch): rows = _session_rows(webui_count=30, cli_count=20) _install_common_monkeypatches(monkeypatch, rows) From 4ee5ef0d0196cc4546722686a79b8216316d23d6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:21:15 -0400 Subject: [PATCH 03/11] fix(#4766): harden source-tab refresh state --- static/sessions.js | 36 ++++-- .../test_issue4766_sidebar_source_pushdown.py | 107 ++++++++++++++++-- 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 30ed46f2..604e28a9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1561,6 +1561,26 @@ function _sessionSourceLabel(filter, count) { return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`; } +function _clearSessionSourceTabCounts() { + _serverWebuiSessionCount = null; + _serverCliSessionCount = null; +} + +function _sessionListQueryString() { + const qs = new URLSearchParams(); + const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; + qs.set('sidebar_source', requestSidebarSource); + if(_showAllProfiles) qs.set('all_profiles','1'); + if(_showArchived) qs.set('include_archived','1'); + return qs.toString() ? `?${qs.toString()}` : ''; +} + +function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) { + const serverCount = filter === 'cli' ? _serverCliSessionCount : _serverWebuiSessionCount; + if (Number.isFinite(serverCount)) return serverCount; + return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount; +} + function _setSessionSourceFilter(filter) { const next = filter === 'cli' ? 'cli' : 'webui'; if (_sessionSourceFilter === next) return; @@ -3836,12 +3856,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ if(!deferWhileInteracting) _pendingSessionListPayload=null; try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; - const qs = new URLSearchParams(); - const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; - qs.set('sidebar_source', requestSidebarSource); - if(_showAllProfiles) qs.set('all_profiles','1'); - if(_showArchived) qs.set('include_archived','1'); - const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; + const sessionListQS = _sessionListQueryString(); const sessionRequestOpts={ timeoutToast:false, timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS, @@ -3902,6 +3917,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ } else { _allSessions = []; _allSessionsScope = null; + _clearSessionSourceTabCounts(); renderSessionListFromCache(); } } @@ -5564,12 +5580,8 @@ function renderSessionListFromCache(){ const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw); const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; - const webuiSessionTabCount=Number.isFinite(_serverWebuiSessionCount) - ? _serverWebuiSessionCount - : renderedWebuiSessionCount; - const cliSessionTabCount=Number.isFinite(_serverCliSessionCount) - ? _serverCliSessionCount - : renderedCliSessionCount; + const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount); + const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount); _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index e81fb45a..3c0c922b 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -101,6 +101,11 @@ def _extract_function(source_text, function_name): raise AssertionError(f"Could not extract {function_name}") +def _run_node(script): + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + return json.loads(proc.stdout) + + @pytest.fixture(autouse=True) def _clear_cache(): routes._session_list_cache_clear() @@ -233,19 +238,33 @@ def test_sidebar_source_varies_cache_key(): def test_frontend_sends_sidebar_source_param(): src = SESSIONS_JS.read_text(encoding="utf-8") - assert "const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui';" in src + assert "function _sessionListQueryString()" in src assert "qs.set('sidebar_source', requestSidebarSource);" in src assert "_serverWebuiSessionCount" in src assert "_serverCliSessionCount" in src - assert "Number.isFinite(_serverWebuiSessionCount)" in src - assert "Number.isFinite(_serverCliSessionCount)" in src - assert "_sessionSourceLabel(filter,count)" in src + assert "function _sessionSourceTabCount(" in src -def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_list_query_string_respects_sidebar_source_and_flags(): src = SESSIONS_JS.read_text(encoding="utf-8") + query_fn = _extract_function(src, "_sessionListQueryString") + script = f""" +global.window = {{ _showCliSessions: true }}; +global._sessionSourceFilter = 'cli'; +global._showAllProfiles = true; +global._showArchived = false; +{query_fn} +const first = _sessionListQueryString(); +window._showCliSessions = false; +global._showArchived = true; +const second = _sessionListQueryString(); +console.log(JSON.stringify({{ first, second }})); +""" + body = _run_node(script) - assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src + assert body["first"] == "?sidebar_source=cli&all_profiles=1" + assert body["second"] == "?sidebar_source=webui&all_profiles=1&include_archived=1" @pytest.mark.skipif(NODE is None, reason="node not on PATH") @@ -279,8 +298,7 @@ console.log(JSON.stringify({{ renderCalls, }})); """ - proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) - body = json.loads(proc.stdout) + body = _run_node(script) assert body["sourceFilter"] == "cli" assert body["activeProject"] is None @@ -290,6 +308,73 @@ console.log(JSON.stringify({{ assert body["renderCalls"] == [{"deferWhileInteracting": False}] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): + src = SESSIONS_JS.read_text(encoding="utf-8") + apply_fn = _extract_function(src, "_applySessionListPayload") + count_fn = _extract_function(src, "_sessionSourceTabCount") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + script = f""" +global._otherProfileCount = 0; +global._archivedWebuiCount = 0; +global._archivedCliCount = 0; +global._serverWebuiSessionCount = null; +global._serverCliSessionCount = null; +global._serverTimeDelta = 0; +global._serverTz = null; +global._optimisticallyRemovedSessionIds = new Set(); +global._allSessions = []; +global._allSessionsScope = null; +global._allProjects = []; +global._sessionListLoadError = null; +global._sessionListHasLoadedOnce = false; +global._sessionListFirstRenderAnimated = true; +global._sessionListSkeletonActive = true; +global._showAllProfiles = false; +global.S = {{ activeProfile: 'default' }}; +global._reconcileActiveSessionIdleStateFromList = rows => rows; +global._mergeOptimisticFirstTurnSessions = rows => rows; +global._syncSessionAttentionSoundState = () => {{}}; +global._pruneLineageReportCacheToVisibleSessions = () => {{}}; +global._markPollingCompletionUnreadTransitions = () => {{}}; +global._isSessionEffectivelyStreaming = () => false; +global.startStreamingPoll = () => {{}}; +global.stopStreamingPoll = () => {{}}; +global.ensureSessionTimeRefreshPoll = () => {{}}; +global.ensureActiveSessionExternalRefreshPoll = () => {{}}; +global.ensureSessionEventsSSE = () => {{}}; +global.animateNextSessionListRefresh = () => {{}}; +global.renderSessionListFromCache = () => {{}}; +{clear_fn} +{count_fn} +{apply_fn} +const sessions = [{{ session_id: 'webui-1' }}]; +_applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); +const oldPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +_clearSessionSourceTabCounts(); +_applySessionListPayload({{ + sessions, + other_profile_count: 0, + archived_count: 0, + active_profile: 'default', + webui_session_count: 11, + cli_session_count: 5, +}}, {{ projects: [] }}); +const newPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +console.log(JSON.stringify({{ oldPayload, newPayload }})); +""" + body = _run_node(script) + + assert body["oldPayload"] == {"webui": 7, "cli": 3} + assert body["newPayload"] == {"webui": 11, "cli": 5} + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) @@ -313,6 +398,12 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): assert body["sessions"][0]["session_id"] == "webui-1" +def test_scope_mismatch_error_path_clears_server_tab_counts(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "_clearSessionSourceTabCounts();" in src + + def test_payload_row_count_regression(monkeypatch): rows = _session_rows(webui_count=30, cli_count=20) _install_common_monkeypatches(monkeypatch, rows) From 534008c3fce79e8c0350d4d2dd7d5dd551d8fdee Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:39:34 -0400 Subject: [PATCH 04/11] Preserve hidden source runtime state across tab refreshes --- static/sessions.js | 38 +++++++-- .../test_issue4766_sidebar_source_pushdown.py | 79 ++++++++++++++++++- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 604e28a9..d43a9e63 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -175,6 +175,7 @@ let _sessionCompletionUnread = null; let _sessionObservedStreaming = null; const _sessionStreamingById = new Map(); const _sessionListSnapshotById = new Map(); +const _sessionListSourceById = new Map(); let _sessionListPointerActive = false; let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; @@ -449,6 +450,9 @@ function _purgeStaleInflightEntries() { if (s && s.session_id) sessionsById.set(s.session_id, s); } } + const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const sid of Object.keys(INFLIGHT)) { // #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one // session actively mid-send (#2689 start-race) — during /api/chat/start the @@ -458,6 +462,10 @@ function _purgeStaleInflightEntries() { continue; } if (!sessionsById.has(sid)) { + const knownSource = _sessionListSourceById.get(sid); + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) { + continue; + } // Session is absent from _allSessions — it was deleted / archived / // filtered and can never stream again, so drop the entry. delete INFLIGHT[sid]; @@ -625,6 +633,7 @@ function _markSessionCompletedInList(session, previousSid = null) { _sessionStreamingById.delete(previousSid); _forgetObservedStreamingSession(previousSid); _sessionListSnapshotById.delete(previousSid); + _sessionListSourceById.delete(previousSid); } _sessionListSnapshotById.set(finalSid, { message_count: messageCount, @@ -636,10 +645,14 @@ function _markSessionCompletedInList(session, previousSid = null) { function _markPollingCompletionUnreadTransitions(sessions) { if (!Array.isArray(sessions)) return; const seen = new Set(); + const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const s of sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); + _sessionListSourceById.set(sid, _isCliSession(s) ? 'cli' : 'webui'); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -677,11 +690,18 @@ function _markPollingCompletionUnreadTransitions(sessions) { last_message_at: lastMessageAt, }); } - for (const sid of Array.from(_sessionStreamingById.keys())) { - if (!seen.has(sid)) _sessionStreamingById.delete(sid); - } - for (const sid of Array.from(_sessionListSnapshotById.keys())) { - if (!seen.has(sid)) _sessionListSnapshotById.delete(sid); + const staleRuntimeStateSids = new Set([ + ...Array.from(_sessionStreamingById.keys()), + ...Array.from(_sessionListSnapshotById.keys()), + ...Array.from(_sessionListSourceById.keys()), + ]); + for (const sid of staleRuntimeStateSids) { + if (seen.has(sid)) continue; + const knownSource = _sessionListSourceById.get(sid); + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue; + _sessionStreamingById.delete(sid); + _sessionListSnapshotById.delete(sid); + _sessionListSourceById.delete(sid); } } @@ -1566,10 +1586,13 @@ function _clearSessionSourceTabCounts() { _serverCliSessionCount = null; } +function _requestedSessionSidebarSource() { + return window._showCliSessions ? _sessionSourceFilter : 'webui'; +} + function _sessionListQueryString() { const qs = new URLSearchParams(); - const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; - qs.set('sidebar_source', requestSidebarSource); + qs.set('sidebar_source', _requestedSessionSidebarSource()); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); return qs.toString() ? `?${qs.toString()}` : ''; @@ -3800,6 +3823,7 @@ function _applySessionListPayload(sessData, projData){ ? sessData.active_profile : (S.activeProfile || 'default'), allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; _syncSessionAttentionSoundState(_allSessions); _pruneLineageReportCacheToVisibleSessions(_allSessions); diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3c0c922b..f900dbcf 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -238,8 +238,9 @@ def test_sidebar_source_varies_cache_key(): def test_frontend_sends_sidebar_source_param(): src = SESSIONS_JS.read_text(encoding="utf-8") + assert "function _requestedSessionSidebarSource()" in src assert "function _sessionListQueryString()" in src - assert "qs.set('sidebar_source', requestSidebarSource);" in src + assert "qs.set('sidebar_source', _requestedSessionSidebarSource());" in src assert "_serverWebuiSessionCount" in src assert "_serverCliSessionCount" in src assert "function _sessionSourceTabCount(" in src @@ -248,12 +249,14 @@ def test_frontend_sends_sidebar_source_param(): @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_session_list_query_string_respects_sidebar_source_and_flags(): src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: true }}; global._sessionSourceFilter = 'cli'; global._showAllProfiles = true; global._showArchived = false; +{requested_source_fn} {query_fn} const first = _sessionListQueryString(); window._showCliSessions = false; @@ -311,10 +314,12 @@ console.log(JSON.stringify({{ @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") apply_fn = _extract_function(src, "_applySessionListPayload") count_fn = _extract_function(src, "_sessionSourceTabCount") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") script = f""" +global.window = {{ _showCliSessions: true }}; global._otherProfileCount = 0; global._archivedWebuiCount = 0; global._archivedCliCount = 0; @@ -331,6 +336,7 @@ global._sessionListHasLoadedOnce = false; global._sessionListFirstRenderAnimated = true; global._sessionListSkeletonActive = true; global._showAllProfiles = false; +global._sessionSourceFilter = 'webui'; global.S = {{ activeProfile: 'default' }}; global._reconcileActiveSessionIdleStateFromList = rows => rows; global._mergeOptimisticFirstTurnSessions = rows => rows; @@ -347,6 +353,7 @@ global.animateNextSessionListRefresh = () => {{}}; global.renderSessionListFromCache = () => {{}}; {clear_fn} {count_fn} +{requested_source_fn} {apply_fn} const sessions = [{{ session_id: 'webui-1' }}]; _applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); @@ -375,6 +382,76 @@ console.log(JSON.stringify({{ oldPayload, newPayload }})); assert body["newPayload"] == {"webui": 11, "cli": 5} +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_source_filtered_cache_preserves_hidden_bucket_runtime_state(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") + mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions") + script = f""" +global._allSessions = [{{ + session_id: 'cli-1', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 1, + last_message_at: 10, +}}]; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map([ + ['webui-live', 'webui'], + ['cli-stale', 'cli'], +]); +global._sessionStreamingById = new Map([ + ['webui-live', true], + ['cli-stale', true], +]); +global._sessionListSnapshotById = new Map([ + ['webui-live', {{ message_count: 1, last_message_at: 1 }}], + ['cli-stale', {{ message_count: 2, last_message_at: 2 }}], +]); +global._sendInProgress = false; +global._sendInProgressSid = null; +global.INFLIGHT = {{ + 'webui-live': {{ lastAssistantText: 'working' }}, + 'cli-stale': {{ lastAssistantText: 'stale' }}, +}}; +const cleared = []; +global.clearInflightState = sid => cleared.push(sid); +global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming); +global._getSessionObservedStreaming = () => ({{}}); +global._hasPendingUserMessageSignal = () => false; +global._isSessionActivelyViewedForList = () => false; +global._markSessionCompletionUnread = () => {{}}; +global._setSessionViewedCount = () => {{}}; +global._rememberObservedStreamingSession = () => {{}}; +global._forgetObservedStreamingSession = () => {{}}; +{is_cli_fn} +{purge_fn} +{mark_fn} +_purgeStaleInflightEntries(); +_markPollingCompletionUnreadTransitions(global._allSessions); +console.log(JSON.stringify({{ + inflightKeys: Object.keys(INFLIGHT), + cleared, + streamingKeys: Array.from(_sessionStreamingById.keys()).sort(), + snapshotKeys: Array.from(_sessionListSnapshotById.keys()).sort(), + sourceKeys: Array.from(_sessionListSourceById.keys()).sort(), +}})); +""" + body = _run_node(script) + + assert body["inflightKeys"] == ["webui-live"] + assert body["cleared"] == ["cli-stale"] + assert "webui-live" in body["streamingKeys"] + assert "cli-stale" not in body["streamingKeys"] + assert "webui-live" in body["snapshotKeys"] + assert "cli-stale" not in body["snapshotKeys"] + assert "webui-live" in body["sourceKeys"] + assert "cli-stale" not in body["sourceKeys"] + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) From efb784730a4d9503c7173dba48399f5523434605 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:51:12 -0400 Subject: [PATCH 05/11] Keep sidebar source fallbacks honest under refresh failure --- static/sessions.js | 30 +++- .../test_issue4766_sidebar_source_pushdown.py | 132 +++++++++++++++--- 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index d43a9e63..7d1e298f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -482,8 +482,28 @@ function _purgeStaleInflightEntries() { } } +function _rememberSessionListSource(s, sid = null) { + const resolvedSid = sid || (s && s.session_id); + if (!resolvedSid) return; + let source = null; + if (s && typeof _isCliSession === 'function') { + source = _isCliSession(s) ? 'cli' : 'webui'; + } + if (!source && Array.isArray(_allSessions)) { + const cached = _allSessions.find(item => item && item.session_id === resolvedSid); + if (cached && typeof _isCliSession === 'function') { + source = _isCliSession(cached) ? 'cli' : 'webui'; + } + } + if (!source && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { + source = _allSessionsScope.sidebarSource; + } + if (source) _sessionListSourceById.set(resolvedSid, source); +} + function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; + _rememberSessionListSource(s); _sessionStreamingById.set(s.session_id, true); _rememberObservedStreamingSession(s); } @@ -588,6 +608,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){ function _rememberRenderedSessionSnapshot(s) { if (!s || !s.session_id) return; + _rememberSessionListSource(s); const previous = _sessionListSnapshotById.get(s.session_id); if (previous) return; _sessionListSnapshotById.set(s.session_id, { @@ -622,6 +643,7 @@ function _markSessionCompletedInList(session, previousSid = null) { pending_started_at: null, is_streaming: false, }; + _rememberSessionListSource(_allSessions[idx], finalSid); _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { @@ -652,7 +674,7 @@ function _markPollingCompletionUnreadTransitions(sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); - _sessionListSourceById.set(sid, _isCliSession(s) ? 'cli' : 'webui'); + _rememberSessionListSource(s, sid); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -3670,6 +3692,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; + _rememberSessionListSource(null, sid); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -3928,10 +3951,12 @@ async function _runRenderSessionListRefresh(opts, _gen){ const _curScope = { profile: S.activeProfile || 'default', allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; const _scopeMatches = _allSessionsScope && _allSessionsScope.profile === _curScope.profile - && _allSessionsScope.allProfiles === _curScope.allProfiles; + && _allSessionsScope.allProfiles === _curScope.allProfiles + && _allSessionsScope.sidebarSource === _curScope.sidebarSource; // #4671: the /api/sessions fetch failed — clear the skeleton flag so this error // render (matched cache, or empty rows for a mismatched scope) replaces the // up-front profile-switch skeleton instead of stranding it. @@ -5325,6 +5350,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; + _rememberSessionListSource(null, sid); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index f900dbcf..370abbce 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -386,6 +386,9 @@ console.log(JSON.stringify({{ oldPayload, newPayload }})); def test_source_filtered_cache_preserves_hidden_bucket_runtime_state(): src = SESSIONS_JS.read_text(encoding="utf-8") is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState") + remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot") purge_fn = _extract_function(src, "_purgeStaleInflightEntries") mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions") script = f""" @@ -399,24 +402,12 @@ global._allSessions = [{{ last_message_at: 10, }}]; global._allSessionsScope = {{ sidebarSource: 'cli' }}; -global._sessionListSourceById = new Map([ - ['webui-live', 'webui'], - ['cli-stale', 'cli'], -]); -global._sessionStreamingById = new Map([ - ['webui-live', true], - ['cli-stale', true], -]); -global._sessionListSnapshotById = new Map([ - ['webui-live', {{ message_count: 1, last_message_at: 1 }}], - ['cli-stale', {{ message_count: 2, last_message_at: 2 }}], -]); +global._sessionListSourceById = new Map([['webui-live', 'webui']]); +global._sessionStreamingById = new Map([['webui-live', true]]); +global._sessionListSnapshotById = new Map([['webui-live', {{ message_count: 1, last_message_at: 1 }}]]); global._sendInProgress = false; global._sendInProgressSid = null; -global.INFLIGHT = {{ - 'webui-live': {{ lastAssistantText: 'working' }}, - 'cli-stale': {{ lastAssistantText: 'stale' }}, -}}; +global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; const cleared = []; global.clearInflightState = sid => cleared.push(sid); global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming); @@ -428,8 +419,23 @@ global._setSessionViewedCount = () => {{}}; global._rememberObservedStreamingSession = () => {{}}; global._forgetObservedStreamingSession = () => {{}}; {is_cli_fn} +{remember_source_fn} +{remember_streaming_fn} +{remember_snapshot_fn} {purge_fn} {mark_fn} +const cliStale = {{ + session_id: 'cli-stale', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 2, + last_message_at: 2, +}}; +_rememberRenderedStreamingState(cliStale, true); +_rememberRenderedSessionSnapshot(cliStale); +INFLIGHT['cli-stale'] = {{ lastAssistantText: 'stale' }}; _purgeStaleInflightEntries(); _markPollingCompletionUnreadTransitions(global._allSessions); console.log(JSON.stringify({{ @@ -475,10 +481,100 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): assert body["sessions"][0]["session_id"] == "webui-1" -def test_scope_mismatch_error_path_clears_server_tab_counts(): +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_scope_mismatch_error_path_respects_sidebar_source(): src = SESSIONS_JS.read_text(encoding="utf-8") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + query_fn = _extract_function(src, "_sessionListQueryString") + refresh_fn = _extract_function(src, "_runRenderSessionListRefresh").replace( + "function _runRenderSessionListRefresh", + "async function _runRenderSessionListRefresh", + 1, + ) + script = f""" +global.window = {{ _showCliSessions: true }}; +global._showAllProfiles = false; +global._showArchived = false; +global._sessionListHasLoadedOnce = true; +global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000; +global._renderSessionListGen = 1; +global._profileSwitchListEmbargo = false; +global._pendingSessionListPayload = null; +global._allProjects = []; +global._contentSearchResults = ['stale']; +global.S = {{ activeProfile: 'default' }}; +global.$ = () => ({{ value: '' }}); +global._isSessionListUserInteracting = () => false; +global._schedulePendingSessionListApply = () => {{}}; +global._showSessionListLoadError = error => {{ + global._lastError = error.message; +}}; +const renders = []; +global.renderSessionListFromCache = () => {{ + renders.push({{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + }}); +}}; +global.api = () => Promise.reject(new Error('boom')); +{clear_fn} +{requested_source_fn} +{query_fn} +{refresh_fn} +async function runCase(requestedSource, cachedSource) {{ + global._sessionSourceFilter = requestedSource; + global._allSessions = [{{ session_id: cachedSource + '-1' }}]; + global._allSessionsScope = {{ + profile: 'default', + allProfiles: false, + sidebarSource: cachedSource, + }}; + global._serverWebuiSessionCount = 11; + global._serverCliSessionCount = 5; + global._sessionListSkeletonActive = true; + global._lastError = null; + renders.length = 0; + await _runRenderSessionListRefresh({{}}, 1); + return {{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + error: global._lastError, + render: renders[0] || null, + }}; +}} +(async () => {{ + const mismatch = await runCase('cli', 'webui'); + const match = await runCase('webui', 'webui'); + console.log(JSON.stringify({{ mismatch, match }})); +}})().catch(error => {{ + console.error(error); + process.exit(1); +}}); +""" + body = _run_node(script) - assert "_clearSessionSourceTabCounts();" in src + assert body["mismatch"]["sessions"] == [] + assert body["mismatch"]["scope"] is None + assert body["mismatch"]["webui"] is None + assert body["mismatch"]["cli"] is None + assert body["mismatch"]["skeleton"] is False + assert body["mismatch"]["render"]["sessions"] == [] + assert body["match"]["sessions"] == ["webui-1"] + assert body["match"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "webui", + } + assert body["match"]["webui"] == 11 + assert body["match"]["cli"] == 5 + assert body["match"]["render"]["sessions"] == ["webui-1"] def test_payload_row_count_regression(monkeypatch): From 742a19ab709489611702eff4f5e542ccb4ddc189 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:58:53 -0400 Subject: [PATCH 06/11] Keep sidebar partition proofs aligned with helper counts --- tests/test_sidebar_session_partition.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_sidebar_session_partition.py b/tests/test_sidebar_session_partition.py index a1afa4f1..35cbdac5 100644 --- a/tests/test_sidebar_session_partition.py +++ b/tests/test_sidebar_session_partition.py @@ -32,11 +32,12 @@ def test_render_uses_single_pass_partition_helper(): assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body assert "_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw)" in render_body - assert "? sessions.length" in render_body - assert ": _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body - assert ": _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body - assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" in render_body - assert "const count=filter==='cli'?cliSessionCount:webuiSessionCount;" not in render_body + assert "const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body + assert "const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body + assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;" in render_body + assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" not in render_body assert "withMessages.filter(" not in render_body From f5f8f4800610086045a98715d6b4ee05f133fbb5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:06:07 -0400 Subject: [PATCH 07/11] Keep hidden source state through failed source refreshes --- static/sessions.js | 2 +- .../test_issue4766_sidebar_source_pushdown.py | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 7d1e298f..7aaa51ba 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3965,7 +3965,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ renderSessionListFromCache(); } else { _allSessions = []; - _allSessionsScope = null; + _allSessionsScope = _curScope; _clearSessionSourceTabCounts(); renderSessionListFromCache(); } diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 370abbce..3f18a5a7 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -484,6 +484,7 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_scope_mismatch_error_path_respects_sidebar_source(): src = SESSIONS_JS.read_text(encoding="utf-8") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") query_fn = _extract_function(src, "_sessionListQueryString") @@ -511,16 +512,21 @@ global._showSessionListLoadError = error => {{ global._lastError = error.message; }}; const renders = []; +const cleared = []; global.renderSessionListFromCache = () => {{ + _purgeStaleInflightEntries(); renders.push({{ sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, webui: global._serverWebuiSessionCount, cli: global._serverCliSessionCount, skeleton: global._sessionListSkeletonActive, + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), }}); }}; global.api = () => Promise.reject(new Error('boom')); +global.clearInflightState = sid => cleared.push(sid); +{purge_fn} {clear_fn} {requested_source_fn} {query_fn} @@ -533,6 +539,9 @@ async function runCase(requestedSource, cachedSource) {{ allProfiles: false, sidebarSource: cachedSource, }}; + global._sessionListSourceById = new Map([['webui-live', 'webui']]); + global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; + cleared.length = 0; global._serverWebuiSessionCount = 11; global._serverCliSessionCount = 5; global._sessionListSkeletonActive = true; @@ -546,6 +555,8 @@ async function runCase(requestedSource, cachedSource) {{ cli: global._serverCliSessionCount, skeleton: global._sessionListSkeletonActive, error: global._lastError, + cleared: [...cleared], + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), render: renders[0] || null, }}; }} @@ -561,11 +572,18 @@ async function runCase(requestedSource, cachedSource) {{ body = _run_node(script) assert body["mismatch"]["sessions"] == [] - assert body["mismatch"]["scope"] is None + assert body["mismatch"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "cli", + } assert body["mismatch"]["webui"] is None assert body["mismatch"]["cli"] is None assert body["mismatch"]["skeleton"] is False assert body["mismatch"]["render"]["sessions"] == [] + assert body["mismatch"]["inflightKeys"] == ["webui-live"] + assert body["mismatch"]["cleared"] == [] + assert body["mismatch"]["render"]["inflightKeys"] == ["webui-live"] assert body["match"]["sessions"] == ["webui-1"] assert body["match"]["scope"] == { "profile": "default", From 0f5e0d71b7121a4e82641e45b63cf4d771a8776a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:37:42 -0400 Subject: [PATCH 08/11] Keep sidebar state helpers compatible with isolated JS harnesses --- static/sessions.js | 52 ++++++++++++++----- ...ssue4662_profile_switch_skeleton_static.py | 16 ++++-- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 7aaa51ba..ac172c10 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -450,7 +450,14 @@ function _purgeStaleInflightEntries() { if (s && s.session_id) sessionsById.set(s.session_id, s); } } - const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + ? _sessionListSourceById + : null; + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' ? _allSessionsScope.sidebarSource : null; for (const sid of Object.keys(INFLIGHT)) { @@ -462,7 +469,7 @@ function _purgeStaleInflightEntries() { continue; } if (!sessionsById.has(sid)) { - const knownSource = _sessionListSourceById.get(sid); + const knownSource = sourceById ? sourceById.get(sid) : null; if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) { continue; } @@ -495,15 +502,23 @@ function _rememberSessionListSource(s, sid = null) { source = _isCliSession(cached) ? 'cli' : 'webui'; } } - if (!source && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { + if (!source + && typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string') { source = _allSessionsScope.sidebarSource; } - if (source) _sessionListSourceById.set(resolvedSid, source); + if (source + && typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.set === 'function') { + _sessionListSourceById.set(resolvedSid, source); + } } function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; - _rememberSessionListSource(s); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); _sessionStreamingById.set(s.session_id, true); _rememberObservedStreamingSession(s); } @@ -608,7 +623,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){ function _rememberRenderedSessionSnapshot(s) { if (!s || !s.session_id) return; - _rememberSessionListSource(s); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); const previous = _sessionListSnapshotById.get(s.session_id); if (previous) return; _sessionListSnapshotById.set(s.session_id, { @@ -643,7 +658,7 @@ function _markSessionCompletedInList(session, previousSid = null) { pending_started_at: null, is_streaming: false, }; - _rememberSessionListSource(_allSessions[idx], finalSid); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(_allSessions[idx], finalSid); _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { @@ -667,14 +682,23 @@ function _markSessionCompletedInList(session, previousSid = null) { function _markPollingCompletionUnreadTransitions(sessions) { if (!Array.isArray(sessions)) return; const seen = new Set(); - const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + && typeof _sessionListSourceById.keys === 'function' + && typeof _sessionListSourceById.delete === 'function' + ? _sessionListSourceById + : new Map(); + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' ? _allSessionsScope.sidebarSource : null; for (const s of sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); - _rememberSessionListSource(s, sid); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s, sid); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -715,15 +739,15 @@ function _markPollingCompletionUnreadTransitions(sessions) { const staleRuntimeStateSids = new Set([ ...Array.from(_sessionStreamingById.keys()), ...Array.from(_sessionListSnapshotById.keys()), - ...Array.from(_sessionListSourceById.keys()), + ...Array.from(sourceById.keys()), ]); for (const sid of staleRuntimeStateSids) { if (seen.has(sid)) continue; - const knownSource = _sessionListSourceById.get(sid); + const knownSource = sourceById.get(sid); if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue; _sessionStreamingById.delete(sid); _sessionListSnapshotById.delete(sid); - _sessionListSourceById.delete(sid); + sourceById.delete(sid); } } @@ -3692,7 +3716,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; - _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -5350,7 +5374,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; - _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4662_profile_switch_skeleton_static.py b/tests/test_issue4662_profile_switch_skeleton_static.py index c7b6f97b..90eb0a96 100644 --- a/tests/test_issue4662_profile_switch_skeleton_static.py +++ b/tests/test_issue4662_profile_switch_skeleton_static.py @@ -133,11 +133,17 @@ class TestSwitchWiring: class TestSessionsWiring: def test_skeleton_flag_cleared_on_real_render(self): - # renderSessionListFromCache clears the skeleton-active flag when it - # writes real rows (so a strand can't persist). - idx = SESSIONS.index("function renderSessionListFromCache(") - body = SESSIONS[idx: idx + 4000] - assert "_sessionListSkeletonActive=false" in body.replace(" ", "") + # The authoritative clear now happens in _applySessionListPayload() + # immediately before the real render, while renderSessionListFromCache() + # keeps the guard that blocks stale cached rows during the skeleton phase. + render_idx = SESSIONS.index("function renderSessionListFromCache(") + render_body = SESSIONS[render_idx: render_idx + 1200] + apply_idx = SESSIONS.index("function _applySessionListPayload(") + apply_body = SESSIONS[apply_idx: apply_idx + 4000] + + assert "if(_sessionListSkeletonActive)return;" in render_body.replace(" ", "") + assert "_sessionListSkeletonActive = false;" in apply_body + assert "renderSessionListFromCache();" in apply_body def test_builder_defines_groups_and_function(self): assert "const _SESSION_SKELETON_GROUPS" in SESSIONS From 52557d113785f37257993ef82f05622832300495 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:44:03 -0400 Subject: [PATCH 09/11] Keep isolated sidebar dedupe harnesses aligned with source-map cleanup --- tests/test_sidebar_completion_dedupe.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_sidebar_completion_dedupe.py b/tests/test_sidebar_completion_dedupe.py index 82d26c44..436bb29e 100644 --- a/tests/test_sidebar_completion_dedupe.py +++ b/tests/test_sidebar_completion_dedupe.py @@ -31,6 +31,7 @@ let _allSessions = [ ]; let _sessionStreamingById = new Map([['old', true], ['new', true]]); let _sessionListSnapshotById = new Map([['old', {{message_count: 10}}]]); +let _sessionListSourceById = new Map([['old', 'webui'], ['new', 'webui']]); function _forgetObservedStreamingSession(_sid) {{}} function renderSessionListFromCache() {{}} {fn_src} @@ -40,7 +41,11 @@ _markSessionCompletedInList({{ message_count: 3, parent_session_id: 'old', }}, 'old'); -console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreamingById.has('old')}})); +console.log(JSON.stringify({{ + rows: _allSessions, + oldStreaming: _sessionStreamingById.has('old'), + oldSourceTracked: _sessionListSourceById.has('old'), +}})); """ result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) payload = json.loads(result.stdout) @@ -49,3 +54,4 @@ console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreaming assert payload["rows"][0]["title"] == "After done" assert payload["rows"][0]["message_count"] == 3 assert payload["oldStreaming"] is False + assert payload["oldSourceTracked"] is False From df4cdf7f9ba06e58d511e45d95b5dfb7f75feb84 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:49:46 -0400 Subject: [PATCH 10/11] Prevent stale cross-bucket rows during source-tab switches --- static/sessions.js | 1 + tests/test_issue4766_sidebar_source_pushdown.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index ac172c10..72a6aa46 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1658,6 +1658,7 @@ function _setSessionSourceFilter(filter) { _selectedSessions.clear(); _sessionSelectMode = false; try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} + renderSessionListFromCache(); void renderSessionList({deferWhileInteracting:false}); } diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3f18a5a7..869bec5c 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -286,6 +286,9 @@ global.localStorage = {{ this.writes.push([key, value]); }}, }}; +global.renderSessionListFromCache = () => {{ + renderCalls.push('cache'); +}}; global.renderSessionList = (opts) => {{ renderCalls.push(opts); return Promise.resolve(); @@ -308,7 +311,7 @@ console.log(JSON.stringify({{ assert body["selectedSize"] == 0 assert body["sessionSelectMode"] is False assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]] - assert body["renderCalls"] == [{"deferWhileInteracting": False}] + assert body["renderCalls"] == ["cache", {"deferWhileInteracting": False}] @pytest.mark.skipif(NODE is None, reason="node not on PATH") From 148a1bfab2b0ce612bc5442db82fb7f8548d7476 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:58:55 -0400 Subject: [PATCH 11/11] Keep source-bucket cleanup tied to real session origin --- static/sessions.js | 9 ++++---- .../test_issue4766_sidebar_source_pushdown.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 72a6aa46..f01741bc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -489,7 +489,7 @@ function _purgeStaleInflightEntries() { } } -function _rememberSessionListSource(s, sid = null) { +function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) { const resolvedSid = sid || (s && s.session_id); if (!resolvedSid) return; let source = null; @@ -503,6 +503,7 @@ function _rememberSessionListSource(s, sid = null) { } } if (!source + && allowScopeFallback && typeof _allSessionsScope !== 'undefined' && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { @@ -1641,7 +1642,7 @@ function _sessionListQueryString() { qs.set('sidebar_source', _requestedSessionSidebarSource()); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); - return qs.toString() ? `?${qs.toString()}` : ''; + return `?${qs.toString()}`; } function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) { @@ -3717,7 +3718,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; - if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -5375,7 +5376,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; - if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 869bec5c..46b36c20 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -461,6 +461,29 @@ console.log(JSON.stringify({{ assert "cli-stale" not in body["sourceKeys"] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_sid_only_source_remembering_skips_scope_fallback(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + script = f""" +global._allSessions = []; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map(); +{is_cli_fn} +{remember_source_fn} +_rememberSessionListSource(null, 'detached-sid', false); +console.log(JSON.stringify({{ + hasDetached: _sessionListSourceById.has('detached-sid'), + remembered: Array.from(_sessionListSourceById.entries()), +}})); +""" + body = _run_node(script) + + assert body["hasDetached"] is False + assert body["remembered"] == [] + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row)