perf: optimize screen load times with progressive un-gating and SWR caching (#1192)
This commit is contained in:
55 files changed
+2494
-401
No files matched your search
@@ -468,7 +468,7 @@ fun SessionsScreen(
|
||||
title = { Text(stringResource(R.string.screen_history)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadSessions() },
|
||||
onRefresh = { viewModel.loadSessions(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { NavigationController.openNewChat() },
|
||||
|
||||
@@ -3,16 +3,21 @@ package com.m57.hermescontrol.ui.sessions
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.ProjectInfo
|
||||
import com.m57.hermescontrol.data.model.PruneRequest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
@@ -140,15 +145,31 @@ class SessionsViewModel(
|
||||
private var liveStatusRefreshPending = false
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
private val sessionsPageCache = SwrCache<String, SessionListResponse>()
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and reset/refetch History twice.
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
||||
// silently (no spinner, no selection reset) instead of blind polling.
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.SESSIONS,
|
||||
apiCall = {
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
val capturedSection = _uiState.value.section
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
when (
|
||||
val result =
|
||||
safeApiCall {
|
||||
@@ -156,17 +177,31 @@ class SessionsViewModel(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
source = capturedSection.source,
|
||||
excludeSources = capturedSection.excludeSources,
|
||||
)
|
||||
}
|
||||
) {
|
||||
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
|
||||
is NetworkResult.Failure -> result
|
||||
is NetworkResult.Success -> {
|
||||
NetworkResult.Success(
|
||||
Triple(requestGeneration, Pair(requestScope, capturedSection), result.data),
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
result
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess = { (requestGeneration, data) ->
|
||||
if (requestGeneration == generation) {
|
||||
onSuccess = { (requestGeneration, scopeAndSection, data) ->
|
||||
val (requestScope, capturedSection) = scopeAndSection
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration == generation && currentScope == requestScope) {
|
||||
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty()
|
||||
@@ -189,6 +224,41 @@ class SessionsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun onDataScopeChanged(newScope: DataScope? = AuthManager.currentDataScope()) {
|
||||
generation++
|
||||
searchGeneration++
|
||||
trackingGeneration++
|
||||
loadJob?.cancel()
|
||||
pageJob?.cancel()
|
||||
statsJob?.cancel()
|
||||
searchJob?.cancel()
|
||||
searchPageJob?.cancel()
|
||||
projectsJob?.cancel()
|
||||
rawPaginationOffset = 0
|
||||
resolvedParentCache.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sessions = emptyList(),
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
selectedIds = emptySet(),
|
||||
isSelecting = false,
|
||||
errorMessage = null,
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
searchLoadMoreError = null,
|
||||
searchHasMore = false,
|
||||
searchNextOffset = null,
|
||||
isSearching = false,
|
||||
isLoadingMoreSearch = false,
|
||||
stats = SessionStats(),
|
||||
emptyCount = 0,
|
||||
projects = emptyList(),
|
||||
)
|
||||
}
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size sent to the server. Matches the desktop sidebar's
|
||||
* SIDEBAR_SESSIONS_PAGE_SIZE (50); the backend caps `limit` at 100.
|
||||
@@ -283,9 +353,43 @@ class SessionsViewModel(
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions() {
|
||||
fun loadSessions(forceRefresh: Boolean = false) {
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val section = _uiState.value.section
|
||||
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (forceRefresh) {
|
||||
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
|
||||
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
|
||||
}
|
||||
val cached =
|
||||
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
||||
sessionsPageCache.get(inMemoryKey)
|
||||
?: SessionListCacheStore.get(persistentKey)?.also { sessionsPageCache.put(inMemoryKey, it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (cached != null) {
|
||||
val sessionsList = cached.sessions.orEmpty()
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = minOf(cached.sessions.size, PAGE_SIZE),
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = cached.total,
|
||||
accumulatedCount = sessionsList.size,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
sessions = sessionsList,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
if (trackingJob?.isActive == true) {
|
||||
@@ -306,9 +410,16 @@ class SessionsViewModel(
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onStart = {
|
||||
if (cached == null) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty()
|
||||
val paging =
|
||||
@@ -331,13 +442,15 @@ class SessionsViewModel(
|
||||
stitchMissingParents(requestGeneration)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (cached == null || forceRefresh) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -361,6 +474,7 @@ class SessionsViewModel(
|
||||
val state = _uiState.value
|
||||
if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val offset = rawPaginationOffset
|
||||
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
@@ -376,7 +490,8 @@ class SessionsViewModel(
|
||||
excludeSources = state.section.excludeSources,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != generation) return@launch
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
@@ -450,10 +565,11 @@ class SessionsViewModel(
|
||||
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||
return
|
||||
}
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
searchJob =
|
||||
viewModelScope.launch {
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
requestSearchPage(query, section, requestGeneration, 0)
|
||||
requestSearchPage(query, section, requestGeneration, requestScope, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +578,11 @@ class SessionsViewModel(
|
||||
val offset = state.searchNextOffset ?: return
|
||||
if (!state.isSearchMode || state.isSearching || state.isLoadingMoreSearch || !state.searchHasMore) return
|
||||
val requestGeneration = searchGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(isLoadingMoreSearch = true, searchLoadMoreError = null) }
|
||||
searchPageJob =
|
||||
viewModelScope.launch {
|
||||
requestSearchPage(state.searchQuery, state.section, requestGeneration, offset)
|
||||
requestSearchPage(state.searchQuery, state.section, requestGeneration, requestScope, offset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +590,7 @@ class SessionsViewModel(
|
||||
query: String,
|
||||
section: HistorySection,
|
||||
requestGeneration: Long,
|
||||
requestScope: DataScope?,
|
||||
offset: Int,
|
||||
) {
|
||||
val result =
|
||||
@@ -486,7 +604,12 @@ class SessionsViewModel(
|
||||
offset = offset,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != searchGeneration || _uiState.value.section != section) return
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != searchGeneration || _uiState.value.section != section ||
|
||||
currentScope != requestScope
|
||||
) {
|
||||
return
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
@@ -906,11 +1029,15 @@ class SessionsViewModel(
|
||||
|
||||
/** Refresh the empty-session count (REST; auxiliary — failures are silent). */
|
||||
fun loadEmptyCount() {
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
runCatching {
|
||||
safeApiCall { ApiClient.hermesApi.getEmptySessionCount() }
|
||||
}.getOrNull()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@launch
|
||||
val count = (result as? NetworkResult.Success)?.data?.count
|
||||
if (count != null) {
|
||||
_uiState.update { it.copy(emptyCount = count) }
|
||||
|
||||
Reference in new issue
Block a user