diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index 2acb8cdaf90..ec7fdfb5f63 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -127,7 +127,19 @@ def _session_list_cache_key( visible_only: bool = False, source_filter: str | None = None, sidebar_source: str | None = None, + archived_limit: int | None = None, + archived_offset: int = 0, ) -> tuple: + normalized_archived_limit = None + if archived_limit is not None: + try: + normalized_archived_limit = max(0, int(archived_limit)) + except (TypeError, ValueError): + normalized_archived_limit = None + try: + normalized_archived_offset = max(0, int(archived_offset or 0)) + except (TypeError, ValueError): + normalized_archived_offset = 0 return ( _session_list_cache_profile_scope(active_profile), bool(all_profiles), @@ -139,6 +151,8 @@ def _session_list_cache_key( bool(visible_only), source_filter, sidebar_source, + normalized_archived_limit, + normalized_archived_offset, ) diff --git a/api/routes.py b/api/routes.py index 4642ac359b0..f76452a3eb3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -481,6 +481,21 @@ def _query_flag(parsed_url, name: str) -> bool: return raw in ('1', 'true', 'yes', 'on') +def _query_positive_int(parsed_url, name: str, *, default=None, maximum: int | None = None): + """Return a non-negative integer query parameter, or default when absent/invalid.""" + qs = parse_qs(parsed_url.query) + raw = qs.get(name, [''])[0] + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + return default + if value < 0: + return default + if maximum is not None: + value = min(value, int(maximum)) + return value + + def _session_visible_to_active_profile(session_profile, handler=None) -> bool: """Return whether a detail-load session belongs to the active profile. @@ -1694,6 +1709,8 @@ def _build_session_list_cache_payload( visible_only: bool = False, source_filter: str | None = None, sidebar_source: str | None = None, + archived_limit: int | None = None, + archived_offset: int = 0, diag=None, ) -> dict: diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None @@ -1900,15 +1917,6 @@ def _all_sessions_for_sidebar(): if s.get("archived") and _is_cli_session_for_settings(s) ) 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) - ) def _filter_sidebar_source(rows: list[dict]) -> list[dict]: if sidebar_source == "webui": return [s for s in rows if not _is_cli_session_for_settings(s)] @@ -1916,12 +1924,38 @@ def _filter_sidebar_source(rows: list[dict]) -> list[dict]: return [s for s in rows if _is_cli_session_for_settings(s)] return list(rows) - scoped = _filter_sidebar_source(scoped) + full_scoped_all_sources = archived_scoped if include_archived else visible_scoped + webui_session_count = sum( + 1 for s in full_scoped_all_sources + if not _is_cli_session_for_settings(s) + ) + cli_session_count = sum( + 1 for s in full_scoped_all_sources + if _is_cli_session_for_settings(s) + ) + visible_scoped_filtered = _filter_sidebar_source(visible_scoped) + archived_scoped_filtered = _filter_sidebar_source(archived_scoped) + scoped = _filter_sidebar_source(full_scoped_all_sources) + if include_archived and archived_limit is not None: + try: + normalized_archived_limit = max(0, int(archived_limit)) + except (TypeError, ValueError): + normalized_archived_limit = None + try: + normalized_archived_offset = max(0, int(archived_offset or 0)) + except (TypeError, ValueError): + normalized_archived_offset = 0 + if normalized_archived_limit is not None: + visible_rows_for_page = [s for s in visible_scoped_filtered if not s.get("archived")] + archived_rows_for_page = [s for s in archived_scoped_filtered if s.get("archived")] + scoped = visible_rows_for_page + archived_rows_for_page[ + normalized_archived_offset: normalized_archived_offset + normalized_archived_limit + ] sidebar_reference_sessions: list[dict] = [] if not include_archived: sidebar_reference_sessions = _hidden_archived_sidebar_reference_sessions( - _filter_sidebar_source(visible_scoped), - _filter_sidebar_source(archived_scoped), + visible_scoped_filtered, + archived_scoped_filtered, ) if not include_archived: diag_stage("filter_archived_sessions") @@ -1943,6 +1977,8 @@ def _filter_sidebar_source(rows: list[dict]) -> list[dict]: "webui_session_count": webui_session_count, "cli_session_count": cli_session_count, "include_archived": include_archived, + "archived_limit": archived_limit, + "archived_offset": archived_offset, "all_profiles": all_profiles, "active_profile": active_profile, "other_profile_count": other_profile_count, @@ -1996,6 +2032,9 @@ def _session_list_payload_to_response(payload: dict) -> dict: 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)) + if payload.get("archived_limit") is not None: + response["archived_limit"] = int(payload.get("archived_limit") or 0) + response["archived_offset"] = int(payload.get("archived_offset") or 0) return response @@ -10691,6 +10730,8 @@ def handle_get(handler, parsed) -> bool: all_profiles = _all_profiles_enabled(parsed) include_archived = _query_flag(parsed, "include_archived") exclude_hidden = _query_flag(parsed, "exclude_hidden") + archived_limit = _query_positive_int(parsed, "archived_limit", default=None, maximum=2000) + archived_offset = _query_positive_int(parsed, "archived_offset", default=0, maximum=200000) sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None if sidebar_source not in ("webui", "cli"): sidebar_source = None @@ -10707,6 +10748,8 @@ def handle_get(handler, parsed) -> bool: visible_only=True, source_filter=agent_session_source_filter, sidebar_source=sidebar_source, + archived_limit=archived_limit, + archived_offset=archived_offset, ) # Keep the visible /api/sessions contract unchanged even though the # heavy lifting now lives in the cache builder: profile scoping via @@ -10725,6 +10768,8 @@ def handle_get(handler, parsed) -> bool: visible_only=True, source_filter=agent_session_source_filter, sidebar_source=sidebar_source, + archived_limit=archived_limit, + archived_offset=archived_offset, diag=diag, ), diag=diag, diff --git a/static/sessions.js b/static/sessions.js index b8a49e80447..5ceda1a1f39 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1819,12 +1819,30 @@ function _sessionListExcludeHiddenEnabled() { return _activeProject===null || _activeProject===NO_PROJECT_FILTER; } +function _sessionArchivePagingFilterActive() { + let searchActive=false; + try{ + const searchEl=typeof $==='function' ? $('sessionSearch') : null; + searchActive=Boolean(searchEl&&String(searchEl.value||'').trim()); + }catch(_e){ searchActive=false; } + return Boolean(searchActive||_activeProject); +} + function _sessionListQueryString() { const qs = new URLSearchParams(); qs.set('sidebar_source', _requestedSessionSidebarSource()); if(_sessionListExcludeHiddenEnabled()) qs.set('exclude_hidden','1'); if(_showAllProfiles) qs.set('all_profiles','1'); - if(_showArchived) qs.set('include_archived','1'); + if(_showArchived){ + qs.set('include_archived','1'); + if(!_sessionArchivePagingFilterActive()){ + const archiveLimit=Math.min( + SESSION_ARCHIVED_MAX_LOADED_LIMIT, + Math.max(SESSION_ARCHIVED_PAGE_SIZE, Number(_archivedRowsLoadedLimit)||SESSION_ARCHIVED_PAGE_SIZE) + ); + qs.set('archived_limit', String(archiveLimit)); + } + } return `?${qs.toString()}`; } @@ -3023,6 +3041,8 @@ async function _ensureAllMessagesLoaded() { } } +const SESSION_ARCHIVED_PAGE_SIZE = 100; +const SESSION_ARCHIVED_MAX_LOADED_LIMIT = 2000; let _allSessions = []; // cached for search filter let _sidebarReferenceSessions = []; // hidden archived ancestor rows used only for nesting/suppression let _allSessionsScope = null; // {profile, allProfiles} the cache was loaded under (#4167) @@ -3045,6 +3065,7 @@ 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 _archivedRowsLoadedLimit = SESSION_ARCHIVED_PAGE_SIZE; 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 @@ -4905,6 +4926,7 @@ let _searchDebounceTimer = null; let _contentSearchResults = []; // results from /api/sessions/search content scan let _lastSessionSearchQuery = ''; let _hideSearchPreviewsAfterSelect = false; +let _archivedSearchPagingQueryActive = false; let _serverTimeDelta = 0; // ms offset: client clock - server clock (for clock-skew compensation) let _serverTz = ''; // server timezone offset string (e.g. "+0800", "+0000", "-0500") @@ -5069,11 +5091,23 @@ function clearSessionSearch(focusInput=true){ if(focusInput) input.focus(); } +function _syncArchivedSearchPagingRefresh(query){ + const queryActive=Boolean(String(query||'').trim()); + const previous=_archivedSearchPagingQueryActive; + _archivedSearchPagingQueryActive=queryActive; + if(!_showArchived||queryActive===previous) return; + // Archived title/id filtering is client-side. When search becomes active, + // refetch without archived_limit so matches beyond the first archived page are + // reachable; when search clears, refetch again to restore normal archive paging. + if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false}); +} + function filterSessions(){ // Immediate client-side title filter (no flicker) // Debounced content search via API for message text syncSessionSearchClear(); const q = ($('sessionSearch').value || '').trim(); + _syncArchivedSearchPagingRefresh(q); if(q!==_lastSessionSearchQuery){ _lastSessionSearchQuery=q; _hideSearchPreviewsAfterSelect=false; @@ -6063,8 +6097,16 @@ function renderSessionListFromCache(){ const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw; const isCliView=_sessionSourceFilter==='cli'; const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)]); - const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length; - const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length; + // Server-provided source bucket counts are authoritative for the current + // payload. When present, skip the expensive cross-bucket render/count pass; + // null is a deliberate "not computed" sentinel consumed only by + // _sessionSourceTabCount's fallback path below. + const renderedWebuiSessionCount=_serverWebuiSessionCount===null + ? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length + : null; + const renderedCliSessionCount=_serverCliSessionCount===null + ? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length + : null; const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount); const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount); _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); @@ -6261,7 +6303,11 @@ function renderSessionListFromCache(){ const toggle=document.createElement('div'); toggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; toggle.textContent=_showArchived?'Hide archived':'Show '+archivedCount+' archived'; - toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionList();}; + toggle.onclick=()=>{ + _showArchived=!_showArchived; + if(_showArchived) _archivedRowsLoadedLimit=SESSION_ARCHIVED_PAGE_SIZE; + renderSessionList(); + }; list.appendChild(toggle); } // Empty state for active project filter @@ -6410,6 +6456,27 @@ function renderSessionListFromCache(){ list.scrollTop=listScrollTopBeforeRender; _resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow); } + const archivePagingFilterActive=_sessionArchivePagingFilterActive(); + if(_showArchived&&!archivePagingFilterActive){ + const activeArchivedTotal=_sessionSourceFilter==='cli'?_archivedCliCount:_archivedWebuiCount; + const loadedArchivedCount=sidebarRows.filter(s=>s&&s.archived&&(_sessionSourceFilter==='cli'?_isCliSession(s):!_isCliSession(s))).length; + const archiveLoadCapReached=Number(_archivedRowsLoadedLimit||0)>=SESSION_ARCHIVED_MAX_LOADED_LIMIT; + const remainingArchived=archiveLoadCapReached?0:Math.max(0, Number(activeArchivedTotal||0)-loadedArchivedCount); + if(remainingArchived>0){ + const more=document.createElement('div'); + more.className='session-archive-more'; + more.style.cssText='font-size:10px;padding:6px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.8;'; + more.textContent='Load '+Math.min(SESSION_ARCHIVED_PAGE_SIZE, remainingArchived)+' more archived ('+remainingArchived+' remaining)'; + more.onclick=()=>{ + _archivedRowsLoadedLimit=Math.min( + SESSION_ARCHIVED_MAX_LOADED_LIMIT, + Math.max(SESSION_ARCHIVED_PAGE_SIZE, Number(_archivedRowsLoadedLimit)||SESSION_ARCHIVED_PAGE_SIZE)+SESSION_ARCHIVED_PAGE_SIZE + ); + renderSessionList(); + }; + list.appendChild(more); + } + } // Select mode toggle button (only when NOT in select mode) if(!_sessionSelectMode){ const toggleBtn=document.createElement('div');toggleBtn.className='session-select-toggle'; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 6918a213243..abbfb9720ac 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -304,6 +304,7 @@ 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") exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") + archive_filter_fn = _extract_function(src, "_sessionArchivePagingFilterActive") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: true }}; @@ -311,27 +312,97 @@ def test_session_list_query_string_respects_sidebar_source_and_flags(): global._sessionSourceFilter = 'cli'; global._showAllProfiles = true; global._showArchived = false; +global.SESSION_ARCHIVED_PAGE_SIZE = 100; +global.SESSION_ARCHIVED_MAX_LOADED_LIMIT = 2000; +global._archivedRowsLoadedLimit = 100; +global.NO_PROJECT_FILTER = '__none__'; +let searchValue = ''; +global.$ = (id) => id === 'sessionSearch' ? {{ value: searchValue }} : null; {requested_source_fn} {exclude_hidden_fn} +{archive_filter_fn} {query_fn} const first = _sessionListQueryString(); window._showCliSessions = false; global._showArchived = true; const second = _sessionListQueryString(); +searchValue = 'old archived title'; +const searchFiltered = _sessionListQueryString(); +searchValue = ''; +global._activeProject = 'project-1'; +const projectFiltered = _sessionListQueryString(); +global._activeProject = null; +global._archivedRowsLoadedLimit = 2500; +const capped = _sessionListQueryString(); global._activeProject = '__none__'; -global.NO_PROJECT_FILTER = '__none__'; global._showAllProfiles = false; global._showArchived = false; const third = _sessionListQueryString(); -console.log(JSON.stringify({{ first, second, third }})); +console.log(JSON.stringify({{ first, second, searchFiltered, projectFiltered, capped, third }})); """ body = _run_node(script) assert body["first"] == "?sidebar_source=cli&exclude_hidden=1&all_profiles=1" - assert body["second"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1" + assert body["second"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1&archived_limit=100" + assert body["searchFiltered"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1" + assert body["projectFiltered"] == "?sidebar_source=webui&all_profiles=1&include_archived=1" + assert body["capped"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1&archived_limit=2000" assert body["third"] == "?sidebar_source=webui&exclude_hidden=1" +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_archived_search_input_refetches_uncapped_then_restores_paging(): + src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") + archive_filter_fn = _extract_function(src, "_sessionArchivePagingFilterActive") + query_fn = _extract_function(src, "_sessionListQueryString") + sync_archive_fn = _extract_function(src, "_syncArchivedSearchPagingRefresh") + filter_fn = _extract_function(src, "filterSessions") + script = f""" +global.window = {{ _showCliSessions: false }}; +global._activeProject = null; +global.NO_PROJECT_FILTER = '__none__'; +global._sessionSourceFilter = 'webui'; +global._showAllProfiles = false; +global._showArchived = true; +global.SESSION_ARCHIVED_PAGE_SIZE = 100; +global.SESSION_ARCHIVED_MAX_LOADED_LIMIT = 2000; +global._archivedRowsLoadedLimit = 100; +global._archivedSearchPagingQueryActive = false; +global._lastSessionSearchQuery = ''; +global._hideSearchPreviewsAfterSelect = false; +global._contentSearchResults = []; +global._searchDebounceTimer = null; +const calls = []; +let searchValue = ''; +global.$ = (id) => id === 'sessionSearch' ? {{ value: searchValue }} : null; +global.syncSessionSearchClear = () => {{}}; +global.renderSessionList = () => {{ calls.push(_sessionListQueryString()); return Promise.resolve(); }}; +global.renderSessionListFromCache = () => {{}}; +global.clearTimeout = () => {{}}; +global.setTimeout = () => 1; +global.api = () => Promise.resolve({{ sessions: [] }}); +{requested_source_fn} +{exclude_hidden_fn} +{archive_filter_fn} +{query_fn} +{sync_archive_fn} +{filter_fn} +searchValue = 'page two title'; +filterSessions(); +searchValue = ''; +filterSessions(); +console.log(JSON.stringify({{ calls }})); +""" + body = _run_node(script) + + assert body["calls"] == [ + "?sidebar_source=webui&exclude_hidden=1&include_archived=1", + "?sidebar_source=webui&exclude_hidden=1&include_archived=1&archived_limit=100", + ] + + @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") diff --git a/tests/test_session_list_long_history_perf.py b/tests/test_session_list_long_history_perf.py index d9e94a16ebe..b22d014c296 100644 --- a/tests/test_session_list_long_history_perf.py +++ b/tests/test_session_list_long_history_perf.py @@ -128,6 +128,67 @@ def test_sessions_api_fetches_archived_rows_only_when_requested(monkeypatch): assert enriched_batches == [["visible-active", "archived-history"]] +def test_sessions_api_can_limit_archived_rows_without_hiding_visible_rows(monkeypatch): + rows = [ + {"session_id": "visible-a", "title": "Visible A", "profile": "default", "archived": False, "message_count": 1, "updated_at": 50, "last_message_at": 50}, + {"session_id": "visible-b", "title": "Visible B", "profile": "default", "archived": False, "message_count": 1, "updated_at": 40, "last_message_at": 40}, + {"session_id": "archived-new", "title": "Archived New", "profile": "default", "archived": True, "message_count": 1, "updated_at": 30, "last_message_at": 30}, + {"session_id": "archived-mid", "title": "Archived Mid", "profile": "default", "archived": True, "message_count": 1, "updated_at": 20, "last_message_at": 20}, + {"session_id": "archived-old", "title": "Archived Old", "profile": "default", "archived": True, "message_count": 1, "updated_at": 10, "last_message_at": 10}, + ] + enriched_batches = [] + + monkeypatch.setattr(routes, "all_sessions", lambda **_kwargs: rows) + monkeypatch.setattr( + routes, + "_enrich_sidebar_lineage_metadata", + lambda batch: enriched_batches.append([row["session_id"] for row in batch]), + ) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda rows: False) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": False}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + routes._session_list_cache_clear() + + handler = _FakeHandler() + routes.handle_get(handler, urlparse("http://example.com/api/sessions?include_archived=1&archived_limit=2")) + + assert handler.status == 200 + body = handler.json_body() + assert [row["session_id"] for row in body["sessions"]] == [ + "visible-a", + "visible-b", + "archived-new", + "archived-mid", + ] + assert body["archived_count"] == 3 + assert body["webui_session_count"] == 5 + assert body["archived_limit"] == 2 + assert enriched_batches == [["visible-a", "visible-b", "archived-new", "archived-mid"]] + + +def test_archived_limit_varies_session_list_cache_key(): + base = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=False, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=True, + archived_limit=100, + ) + larger = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=False, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=True, + archived_limit=200, + ) + + assert base != larger + + def test_sessions_api_legacy_all_sessions_monkeypatch_fallback_is_narrow(monkeypatch): calls = [] @@ -170,9 +231,15 @@ def broken_all_sessions(**_kwargs): def test_session_list_fetch_adds_include_archived_only_when_toggle_is_on(): src = (pathlib.Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") - assert "if(_showArchived) qs.set('include_archived','1');" in src + assert "qs.set('include_archived','1');" in src + assert "const archiveLimit=Math.min(" in src + assert "SESSION_ARCHIVED_MAX_LOADED_LIMIT" in src + assert "qs.set('archived_limit', String(archiveLimit));" in src assert "api('/api/sessions' + sessionListQS" in src - assert "toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionList();};" in src + assert "if(_showArchived) _archivedRowsLoadedLimit=SESSION_ARCHIVED_PAGE_SIZE;" in src + assert "className='session-archive-more'" in src + assert "_archivedRowsLoadedLimit=Math.min(" in src + assert "Math.max(SESSION_ARCHIVED_PAGE_SIZE, Number(_archivedRowsLoadedLimit)||SESSION_ARCHIVED_PAGE_SIZE)+SESSION_ARCHIVED_PAGE_SIZE" in src assert "_archivedWebuiCount" in src assert "sessData.archived_webui_count ?? sessData.archived_count ?? 0" in src assert "archived_webui_count" in src diff --git a/tests/test_sidebar_session_partition.py b/tests/test_sidebar_session_partition.py index 13308880b30..32293bc6bcc 100644 --- a/tests/test_sidebar_session_partition.py +++ b/tests/test_sidebar_session_partition.py @@ -32,8 +32,12 @@ def test_render_uses_single_pass_partition_helper(): assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)])" in render_body - assert "const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length;" in render_body - assert "const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length;" in render_body + assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body + assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body + assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body + assert ": null;" in render_body + assert "null is a deliberate \"not computed\" sentinel" 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 @@ -66,10 +70,23 @@ def test_partition_helper_keeps_raw_source_counts_while_render_owns_visible_coun assert "cliReferenceRaw," in _partition_block() assert "webuiSessionsRaw," in _partition_block() assert "cliSessionsRaw," in _partition_block() - assert "const renderedWebuiSessionCount=" in render_body - assert "const renderedCliSessionCount=" in render_body - assert "_renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body - assert "_renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body + assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body + assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body + assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS assert "_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows)" in SESSIONS_JS + + +def test_archive_load_more_uses_source_wide_loaded_count_and_hides_under_filters(): + render_body = _function_block("renderSessionListFromCache") + + assert "function _sessionArchivePagingFilterActive()" in SESSIONS_JS + assert "const archivePagingFilterActive=_sessionArchivePagingFilterActive();" in render_body + assert "if(_showArchived&&!archivePagingFilterActive){" in render_body + assert "const loadedArchivedCount=sidebarRows.filter" in render_body + assert "const archiveLoadCapReached=Number(_archivedRowsLoadedLimit||0)>=SESSION_ARCHIVED_MAX_LOADED_LIMIT;" in render_body + assert "const remainingArchived=archiveLoadCapReached?0:Math.max(0, Number(activeArchivedTotal||0)-loadedArchivedCount);" in render_body + assert "const remainingArchived=Math.max(0, Number(activeArchivedTotal||0)-loadedArchivedCount);" not in render_body + assert "orderedSessions.filter(s=>s&&s.archived).length" not in render_body