Merge pull request #5241 — page archived sidebar rows + archived-search refetch (#5200, @santastabber)
Release v0.51.757 — page archived sidebar rows + archived-search refetch (#5200, @santastabber)
This commit is contained in:
7 files changed
+309
-27
No files matched your search
@@ -35,6 +35,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The session sidebar stays fast with a very large archive, and archived search still finds everything.** Archived sidebar rows are now paged (capped per request) instead of all loaded at once, so an instance with thousands of archived sessions no longer pays to render them all. When you turn on "Show archived" and start a search, the sidebar transparently refetches without the archive cap so a title/id match beyond the first archived page is still reachable, then restores normal paging when the search clears. Default (non-search) paging keeps the existing 2000-row safety cap, and a render-generation guard prevents a slower capped/uncapped response from overwriting newer sidebar state. Thanks @santastabber. (#5200)
|
||||
- **Post-restart config reloads no longer stampede under concurrent traffic.** Right after a restart (or profile switch), several in-flight requests could all see the config cache as stale and each trigger a full `config.yaml` re-parse at once, briefly stalling the single-process server. Stale reads now route through a shared gate that re-checks freshness inside the config lock (double-checked locking), so concurrent stale readers collapse to a single refresh instead of a thundering herd. Forced reloads (writes, profile switches) and the in-memory-override guard keep their existing semantics. Thanks @rodboev. (#5235, fixes #5220)
|
||||
- **Fewer false "Connection Lost" banners on instances with large session state.** Under heavy session/sidebar state, transient slow responses and benign hidden-tab SSE churn could flap the offline banner even though the server was fine. The probe now waits for consecutive failures before declaring offline (while genuine signals — `navigator.onLine` going offline, startup failures — still surface immediately and bypass that delay), sidebar refreshes coalesce while one is in flight by draining the latest queued request instead of dropping it, the per-session SSE suspends/resumes cleanly across tab-visibility changes (reopening from a dedicated holder so it can't get stuck closed), and large-transcript message loads get a longer (120s) timeout so they don't trip the failure counter. A true outage still shows the banner and clears on recovery. Thanks @franksong2702. (#5201)
|
||||
- **Polish UI: the "Virtualize long transcripts" setting is now translated** (label + description), and a stray UTF-8 byte-order mark was stripped from the top of `static/i18n.js`. Locale-only change; English and other locales are unaffected. Thanks @leszek3737. (#5216)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+57
-12
@@ -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.
|
||||
|
||||
@@ -1695,6 +1710,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
|
||||
@@ -1901,15 +1918,6 @@ def _build_session_list_cache_payload(
|
||||
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)]
|
||||
@@ -1917,12 +1925,38 @@ def _build_session_list_cache_payload(
|
||||
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")
|
||||
@@ -1944,6 +1978,8 @@ def _build_session_list_cache_payload(
|
||||
"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,
|
||||
@@ -1997,6 +2033,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
|
||||
|
||||
|
||||
@@ -10739,6 +10778,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
|
||||
@@ -10755,6 +10796,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
|
||||
@@ -10773,6 +10816,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,
|
||||
|
||||
+71
-4
@@ -1841,12 +1841,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()}`;
|
||||
}
|
||||
|
||||
@@ -3066,6 +3084,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)
|
||||
@@ -3088,6 +3108,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
|
||||
@@ -4962,6 +4983,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")
|
||||
|
||||
@@ -5126,11 +5148,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;
|
||||
@@ -6120,8 +6154,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);
|
||||
@@ -6318,7 +6360,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
|
||||
@@ -6467,6 +6513,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';
|
||||
|
||||
@@ -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 @@ global._activeProject = null;
|
||||
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")
|
||||
|
||||
@@ -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 test_sessions_api_internal_typeerror_is_not_hidden_by_legacy_fallback(monkey
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user