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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/route_session_list_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -139,6 +151,8 @@ def _session_list_cache_key(
bool(visible_only),
source_filter,
sidebar_source,
normalized_archived_limit,
normalized_archived_offset,
)


Expand Down
69 changes: 57 additions & 12 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1900,28 +1917,45 @@ 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)]
if sidebar_source == "cli":
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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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")
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
75 changes: 71 additions & 4 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading