perf: optimize screen load times with progressive un-gating and SWR caching (#1192)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 23:25:30 +03:00
1 parent abce60ad7a
commit f5eaecbbef
55 files changed
+2494 -401

No files matched your search

@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -98,15 +100,15 @@ fun ModelScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_models)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
isRefreshing = state.isLoading || state.catalogLoading,
onRefresh = { viewModel.loadAll(refresh = true) },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() },
@@ -114,7 +116,10 @@ fun ModelScreen(
)
}
state.providers.isEmpty() -> {
!state.isLoading &&
!state.catalogLoading &&
state.providers.isEmpty() &&
state.mainModelProvider.isEmpty() -> {
EmptyState(
title = stringResource(R.string.model_empty_title),
subtitle = stringResource(R.string.model_empty_desc),
@@ -191,24 +196,38 @@ fun ModelScreen(
}
// ── Provider List ──
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
if (state.catalogLoading && state.providers.isEmpty()) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
} else {
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
}
}
}
}
@@ -241,7 +260,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_main),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
@@ -267,7 +286,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_aux, state.auxPickerTask),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
@@ -42,6 +42,7 @@ val AUX_TASKS =
data class ModelUiState(
val isLoading: Boolean = false,
val catalogLoading: Boolean = false,
val providers: List<ModelProvider> = emptyList(),
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null,
@@ -79,7 +80,7 @@ class ModelViewModel(
}
fun loadAll(refresh: Boolean = false) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
viewModelScope.launch {
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
val activeProfileDeferred =
@@ -145,6 +146,7 @@ class ModelViewModel(
// Render Phase 1 results right away so UI controls update without waiting for model options
_uiState.update {
it.copy(
isLoading = false,
activeProfile = activeProfile ?: it.activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
mainModelProvider = mainModel.first,
@@ -160,7 +162,7 @@ class ModelViewModel(
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
catalogLoading = false,
providers = optionsResult.data.providers.orEmpty(),
)
}
@@ -169,9 +171,9 @@ class ModelViewModel(
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
catalogLoading = false,
errorMessage =
if (it.providers.isEmpty()) {
if (it.providers.isEmpty() && it.mainModelProvider.isEmpty()) {
"Failed to load model options: ${optionsResult.error.message}"
} else {
null