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

@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.ui.common.EmptyState
@@ -72,6 +73,7 @@ fun AchievementsScreen(
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showResetDialog by remember { mutableStateOf(false) }
@@ -92,7 +94,8 @@ fun AchievementsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadAchievements()
viewModel.loadRecentUnlocks()
}
@@ -129,7 +132,10 @@ fun AchievementsScreen(
title = { Text(stringResource(R.string.screen_achievements)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() },
onRefresh = {
viewModel.loadAchievements(forceRefresh = true)
viewModel.loadRecentUnlocks()
},
modifier = modifier,
actions = {
Box {
@@ -1,12 +1,16 @@
package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -46,9 +50,53 @@ class AchievementsViewModel :
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
private val api get() = ApiClient.hermesApi
private val achievementsCache = SwrCache<String, AchievementsResponse>()
fun loadAchievements() {
safeLaunchLoad(
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
achievements = emptyList(),
unlockedCount = 0,
discoveredCount = 0,
secretCount = 0,
totalCount = 0,
categories = emptyList(),
recentUnlocks = emptyList(),
errorMessage = null,
)
}
}
fun loadAchievements(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = achievementsCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
val categories =
data.achievements
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
_uiState.update {
it.copy(
isLoading = false,
achievements = data.achievements.orEmpty(),
unlockedCount = data.unlockedCount,
discoveredCount = data.discoveredCount,
secretCount = data.secretCount,
totalCount = data.totalCount,
isStale = data.isStale,
generatedAt = data.generatedAt,
scanState = data.scanMeta?.status?.state ?: "idle",
scanLastError = data.scanMeta?.status?.lastError,
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
categories = categories,
errorMessage = null,
)
}
},
apiCall = { safeApiCall { api.getAchievements() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -100,6 +148,7 @@ class AchievementsViewModel :
}
fun rescan() {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
safeLaunchLoad(
apiCall = { safeApiCall { api.rescanAchievements() } },
onStart = {
@@ -111,6 +160,12 @@ class AchievementsViewModel :
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
// A successful rescan is a mutation: publish the fresh payload into the
// scoped cache, otherwise the next SWR revisit replays the pre-rescan list.
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope) {
achievementsCache.put(requestScope.scopedKey("default"), data)
}
_uiState.update {
it.copy(
isRescanning = false,
@@ -155,7 +210,7 @@ class AchievementsViewModel :
toastMessage = "Achievement state reset",
)
}
loadAchievements()
loadAchievements(forceRefresh = true)
},
onError = { errorMsg ->
_uiState.update {
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -235,68 +237,72 @@ class BotsViewModel(
}
}
viewModelScope.launch(ioDispatcher) {
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
coroutineScope {
val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
}
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val activeResult = activeDeferred.await()
val profilesResult =
if (profilesWithMeta != null) {
null
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesWithMeta != null) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
}
}
}
}
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.common
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.SwrCache
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -34,6 +38,69 @@ inline fun <T> ViewModel.safeLaunchLoad(
}
}
/**
* Executes a read-only API call with Stale-While-Revalidate caching.
* If cached data exists in [cache] for [cacheKey] and [forceRefresh] is false, [onCacheHit] is invoked immediately
* on the caller thread, avoiding full-screen loading spinners on screen revisits.
* The network call runs in the background to update the cache and invoke [onSuccess].
* If a previous request is active:
* - when [forceRefresh] is false, the active job is returned to avoid duplicate in-flight work
* - when [forceRefresh] is true, the active job is cancelled and superseded by the new forced refresh
* Results are checked against [requestScope] and coroutine cancellation so late-arriving responses
* cannot overwrite newer state.
*/
inline fun <T : Any> ViewModel.safeLaunchSwrLoad(
cache: SwrCache<String, T>,
cacheKey: String = "default",
forceRefresh: Boolean = false,
currentJob: Job? = null,
requestScope: DataScope? = runCatching { AuthManager.currentDataScope() }.getOrNull(),
crossinline scopeIsCurrent: () -> Boolean = {
requestScope == null || AuthManager.currentDataScope() == requestScope
},
crossinline onCacheHit: (T) -> Unit,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
): Job {
if (currentJob?.isActive == true) {
if (!forceRefresh) return currentJob
currentJob.cancel()
}
val scopedKey = requestScope?.scopedKey(cacheKey)
if (forceRefresh && scopedKey != null) {
cache.remove(scopedKey)
}
val cached = if (!forceRefresh && scopedKey != null) cache.get(scopedKey) else null
if (cached != null) {
onCacheHit(cached)
} else {
onStart()
}
return viewModelScope.launch {
val result = apiCall()
coroutineContext.ensureActive()
if (!scopeIsCurrent()) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
if (scopedKey != null) {
cache.put(scopedKey, result.data)
}
onSuccess(result.data)
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
onError(result.error.message)
}
}
}
}
}
/**
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
.collect { _ ->
if (refreshInFlight) return@collect
refreshInFlight = true
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
try {
// No Dispatchers.IO hop — see safeLaunchLoad.
val result = apiCall()
coroutineContext.ensureActive()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@collect
if (result is NetworkResult.Success) {
onSuccess(result.data)
}
@@ -99,7 +99,7 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAll() },
onRefresh = { viewModel.loadAll(forceRefresh = true) },
actions = {
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
IconButton(onClick = { viewModel.saveConfig() }) {
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
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.SwrCache
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
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.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -50,35 +54,114 @@ class ConfigViewModel :
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
private val pendingChanges = mutableMapOf<String, JsonElement>()
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
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 issue a duplicate loadAll().
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
loadAll()
}
fun loadAll() {
viewModelScope.launch {
private fun onDataScopeChanged(newScope: DataScope?) {
pendingChanges.clear()
_uiState.update {
it.copy(
values = null,
schema = null,
defaults = null,
uncoveredPaths = emptyList(),
path = null,
yamlText = null,
modifiedKeys = emptySet(),
isSaving = false,
yamlIsSaving = false,
yamlIsLoading = false,
errorMessage = null,
)
}
// A scope switch must load the new context. The scoped caches mean this paints the
// new scope's own entry instantly when one exists, then revalidates in the background.
loadAll()
}
fun loadAll(forceRefresh: Boolean = false) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
if (forceRefresh && scopeKey.isNotBlank()) {
schemaCache.remove(scopeKey)
defaultsCache.remove(scopeKey)
}
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
val hasCachedData = _uiState.value.values != null
if (!hasCachedData) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try {
coroutineScope {
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
val schemaDeferred =
if (cachedSchema == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
} else {
null
}
val defaultsDeferred =
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
if (cachedDefaults == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
} else {
null
}
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await()
val defaultsResult = defaultsDeferred.await()
val schema: ConfigSchemaResponse? =
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
} ?: cachedSchema
val defaults: Map<String, JsonElement>? =
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
} ?: cachedDefaults
val rawResult = rawDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@coroutineScope
if (configResult is NetworkResult.Success) {
val values = flattenConfig(configResult.data)
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update {
it.copy(
_uiState.update { state ->
val categories = schema?.category_order ?: emptyList()
val validCategory =
when {
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
state.activeCategory
}
categories.isNotEmpty() -> {
categories.first()
}
else -> {
state.activeCategory
}
}
state.copy(
isLoading = false,
values = values,
schema = schema,
@@ -89,15 +172,11 @@ class ConfigViewModel :
schema?.fields?.keys ?: emptySet(),
),
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
activeCategory = validCategory,
errorMessage = null,
)
}
} else {
} else if (!hasCachedData) {
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
@@ -105,8 +184,11 @@ class ConfigViewModel :
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
}
}
}
}
@@ -198,6 +280,7 @@ class ConfigViewModel :
fun saveConfig() {
if (pendingChanges.isEmpty()) return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
val changeset = buildChangeset(pendingChanges)
@@ -209,6 +292,8 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
pendingChanges.clear()
@@ -216,6 +301,8 @@ class ConfigViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
isSaving = false,
@@ -242,6 +329,7 @@ class ConfigViewModel :
fun saveYamlConfig() {
val yamlText = _uiState.value.yamlText ?: return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(yamlIsSaving = true) }
viewModelScope.launch {
val result =
@@ -252,12 +340,16 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
yamlIsSaving = false,
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob
@@ -82,10 +83,12 @@ fun CronJobsScreen(
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var selectedJob by remember { mutableStateOf<CronJob?>(null) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadCronJobs()
}
@@ -95,7 +98,7 @@ fun CronJobsScreen(
title = { Text(stringResource(R.string.screen_cron)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() },
onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon(
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
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.SwrCache
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob
@@ -16,6 +19,7 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -95,6 +99,19 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
jobs = emptyList(),
errorMessage = null,
deleteTarget = null,
editorState = CronJobEditorState(),
)
}
}
init {
// Issue #784: gateway broadcasts cron.changed — refresh the list
@@ -102,14 +119,24 @@ class CronJobsViewModel :
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
onSuccess = { data ->
val jobs = data.orEmpty()
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null) jobsCache.put(requestScope.inMemoryKey("default"), jobs)
_uiState.update { it.copy(jobs = jobs) }
},
)
}
fun loadCronJobs() {
fun loadCronJobs(forceRefresh: Boolean = false) {
loadJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = jobsCache,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -128,6 +155,7 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -141,14 +169,24 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -162,8 +200,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -188,6 +235,7 @@ class CronJobsViewModel :
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
@@ -196,8 +244,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -447,7 +504,7 @@ class CronJobsViewModel :
when (result) {
is NetworkResult.Success -> {
closeEditor()
loadCronJobs()
loadCronJobs(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -79,6 +81,7 @@ fun FilesScreen(
viewModel: FilesViewModel = viewModel { FilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val uploadLauncher =
@@ -104,6 +107,13 @@ fun FilesScreen(
onDispose { viewModel.clearTransientState() }
}
// Scope-aware load: a new server/profile context must never keep the old
// server's directory path or entries on screen (PR #1192 cache scoping).
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.load()
}
val hasEntries = state.entries.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
import android.util.Base64
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
import com.m57.hermescontrol.data.model.ManagedFileDelete
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
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.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -76,8 +79,52 @@ class FilesViewModel :
private val _uiState = MutableStateFlow(FilesUiState())
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
fun load(path: String? = null) {
safeLaunchLoad(
private val filesCache = SwrCache<String, ManagedFilesListResponse>()
/**
* Reset state owned by the previous server/profile context (issue: PR #1192 cache scoping).
* The current directory path belongs to the old server and must not leak into the new one.
*/
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
currentPath = "",
parentPath = null,
crumbs = emptyList(),
entries = emptyList(),
isLoading = false,
errorMessage = null,
busyPaths = emptySet(),
isCreatingDir = false,
newDirName = "",
isUploading = false,
deleteTarget = null,
openingPath = null,
)
}
}
fun load(
path: String? = null,
forceRefresh: Boolean = false,
) {
val cacheKey = path ?: ""
safeLaunchSwrLoad(
cache = filesCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { state ->
state.copy(
isLoading = false,
currentPath = data.path,
parentPath = data.parent,
crumbs = buildCrumbs(data.path),
entries = data.entries,
errorMessage = null,
)
}
},
apiCall = {
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
},
@@ -104,7 +151,7 @@ class FilesViewModel :
)
}
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
fun refresh() = load(_uiState.value.currentPath.ifBlank { null }, forceRefresh = true)
fun navigateTo(entry: ManagedFileEntry) {
if (!entry.isDirectory) return
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -51,9 +52,11 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadStatus()
}
@@ -63,7 +66,7 @@ fun GatewayScreen(
title = { Text(stringResource(R.string.screen_gateway)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadStatus() },
onRefresh = { viewModel.loadStatus(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.status == null -> {
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.StatusResponse
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.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,8 +30,19 @@ class GatewayViewModel :
private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() {
safeLaunchLoad(
private val statusCache = SwrCache<String, StatusResponse>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, status = null, errorMessage = null) }
}
fun loadStatus(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = statusCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { it.copy(isLoading = false, status = data, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -73,7 +86,7 @@ class GatewayViewModel :
toastMessage = "Gateway ${actionName}ed successfully",
)
}
loadStatus()
loadStatus(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject
@@ -32,7 +33,10 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -168,34 +172,71 @@ class KanbanViewModel(
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val hasExistingContent = _uiState.value.columns.isNotEmpty() || _uiState.value.tasks.isNotEmpty()
if (!hasExistingContent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch {
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
coroutineScope {
val prefetchedBoardDeferred =
if (previouslySelectedId != null) {
async(Dispatchers.IO) {
repository.getBoard(
board = previouslySelectedId,
includeArchived = _uiState.value.includeArchived,
)
}
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
null
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
_uiState.update { it.copy(boards = boards) }
if (targetBoard != null) {
if (prefetchedBoardDeferred != null && targetBoard.id == previouslySelectedId) {
preferences.setSelectedBoard(endpoint, targetBoard.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = targetBoard) }
val prefetchedResult = prefetchedBoardDeferred.await()
applyBoardResult(targetBoard, prefetchedResult, gen)
connectEvents(targetBoard)
} else {
selectBoard(targetBoard)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
selectedBoard = null,
columns = emptyList(),
tasks = emptyList(),
)
}
}
}
is NetworkResult.Failure -> {
if (!hasExistingContent) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
}
}
}
@@ -906,15 +947,11 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
private fun applyBoardResult(
board: KanbanBoard,
gen: Int = currentLoadGen,
result: NetworkResult<KanbanBoardResponse>,
gen: Int,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
@@ -928,6 +965,7 @@ class KanbanViewModel(
isLoading = false,
columns = body.columns,
tasks = allTasks,
errorMessage = null,
)
}
}
@@ -948,6 +986,18 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
applyBoardResult(board, result, gen)
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
@@ -114,6 +115,7 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
@@ -150,7 +152,8 @@ fun KeysScreen(
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadKeys()
}
@@ -211,7 +214,7 @@ fun KeysScreen(
title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() },
onRefresh = { viewModel.loadKeys(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openAddDialog() }) {
Icon(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,6 +57,19 @@ class KeysViewModel :
private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
private val keysCache = SwrCache<String, Map<String, EnvVarConfig>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
categories = emptyList(),
revealedValues = emptyMap(),
errorMessage = null,
)
}
}
private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> =
@@ -89,8 +104,21 @@ class KeysViewModel :
}
}
fun loadKeys() {
safeLaunchLoad(
fun loadKeys(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = keysCache,
forceRefresh = forceRefresh,
onCacheHit = { envVars ->
val currentCategories = _uiState.value.categories
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
_uiState.update {
it.copy(
isLoading = false,
categories = buildCategoryList(envVars, expandedCategories.keys),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -178,7 +206,7 @@ class KeysViewModel :
toastMessage = "Key added successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -223,7 +251,7 @@ class KeysViewModel :
toastMessage = "Key deleted successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -281,7 +309,7 @@ class KeysViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -70,6 +71,7 @@ fun LogsScreen(
viewModel: LogsViewModel = viewModel { LogsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val listState = rememberLazyListState()
@@ -115,7 +117,8 @@ fun LogsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadLogs()
}
@@ -146,7 +149,7 @@ fun LogsScreen(
title = { Text(stringResource(R.string.screen_logs)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadLogs() },
onRefresh = { viewModel.loadLogs(forceRefresh = true) },
actions = {
// Pause / Resume toggle
IconButton(onClick = { pauseScroll = !pauseScroll }) {
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,43 +42,51 @@ class LogsViewModel :
private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
@Volatile
private var loadInProgress = false
private var loadJob: Job? = null
private var autoRefreshJob: Job? = null
private val logsCache = SwrCache<String, LogResponse>()
fun loadLogs() {
if (loadInProgress) return
loadInProgress = true
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
}
fun loadLogs(forceRefresh: Boolean = false) {
val filters = _uiState.value.filters
safeLaunchLoad(
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val body = data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
loadInProgress = false
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
loadInProgress = false
},
)
val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
loadJob =
safeLaunchSwrLoad(
cache = logsCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
},
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
},
)
}
/** Update the server-side filters and immediately reload. */
@@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing
@@ -99,6 +100,7 @@ fun McpServersScreen(
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
@@ -127,14 +129,17 @@ fun McpServersScreen(
}
}
LaunchedEffect(Unit) { viewModel.loadServers() }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadServers()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers() },
onRefresh = { viewModel.loadServers(forceRefresh = true) },
actions = {
IconButton(
onClick = { viewModel.testAllServers() },
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
@@ -9,11 +10,13 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse
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.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -69,10 +72,39 @@ class McpServersViewModel :
private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
private val mcpCache = SwrCache<String, McpServersResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
servers = emptyList(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
isTestingAll = false,
errorMessage = null,
activeOAuthFlow = null,
)
}
}
// ── Data loading ──────────────────────────────────────────
fun loadServers() {
safeLaunchLoad(
fun loadServers(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = mcpCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
servers = data.servers.orEmpty(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -247,7 +279,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -305,7 +337,9 @@ class McpServersViewModel :
toastMessage = msg,
)
}
loadServers()
if (successCount > 0) {
loadServers(forceRefresh = true)
}
}
}
@@ -371,7 +405,7 @@ class McpServersViewModel :
toastMessage = "Server '${request.name}' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -428,7 +462,7 @@ class McpServersViewModel :
toastMessage = "Env var '$key' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -453,7 +487,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -520,7 +554,7 @@ class McpServersViewModel :
toastMessage = "Catalog entry '${entry.name}' installed",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -605,7 +639,7 @@ class McpServersViewModel :
toastMessage = "OAuth authorization successful!",
)
}
loadServers()
loadServers(forceRefresh = true)
}
"error" -> {
@@ -112,7 +112,7 @@ fun MemoryScreen(
title = { Text(stringResource(R.string.screen_memory)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
onRefresh = { viewModel.load(forceRefresh = true) },
modifier = modifier,
) { paddingValues ->
when {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
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.SwrCache
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResponse
@@ -10,6 +13,9 @@ 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.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -39,50 +45,89 @@ class MemoryViewModel :
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
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 issue a duplicate request, so the
// coordinator flows remain the single trigger.
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
}
fun load(silent: Boolean = false) {
private var loadJob: Job? = null
private val memoryCache = SwrCache<String, MemoryResponse>()
private fun onDataScopeChanged(newScope: DataScope?) {
loadJob?.cancel()
_uiState.update {
it.copy(
isLoading = false,
memory = null,
learningGraph = null,
resetting = null,
errorMessage = null,
)
}
// A scope switch must silently reload the new context's data. Clearing alone
// would leave the screen empty when it is not the composition performing the load.
load(silent = true)
}
fun load(
silent: Boolean = false,
forceRefresh: Boolean = false,
) {
if (_uiState.value.isLoading) return
if (!silent) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopedKey = requestScope?.inMemoryKey("default")
if (forceRefresh && scopedKey != null) memoryCache.remove(scopedKey)
val cached = if (!forceRefresh && scopedKey != null) memoryCache.get(scopedKey) else null
if (cached != null) {
_uiState.update { it.copy(isLoading = false, memory = cached, errorMessage = null) }
} else if (!silent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) }
}
loadJob =
viewModelScope.launch {
coroutineScope {
val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
val memoryResult = memoryDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope != requestScope) return@coroutineScope
when (memoryResult) {
is NetworkResult.Success -> {
if (scopedKey != null) memoryCache.put(scopedKey, memoryResult.data)
_uiState.update { it.copy(isLoading = false, memory = memoryResult.data) }
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = memoryResult.error.message,
)
}
}
}
}
val graphResult = graphDeferred.await()
if (graphResult is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = graphResult.data) }
}
}
}
loadLearningGraph()
}
}
private fun loadLearningGraph() {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = result.data) }
}
}
}
fun resetMemory(target: String) {
@@ -109,7 +154,7 @@ class MemoryViewModel :
}
}
}
load()
load(forceRefresh = true)
}
}
@@ -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
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PairingItem
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -50,8 +51,10 @@ fun PairingScreen(
viewModel: PairingViewModel = viewModel { PairingViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadPairing()
}
@@ -61,7 +64,7 @@ fun PairingScreen(
title = { Text(stringResource(R.string.screen_pairing)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPairing() },
onRefresh = { viewModel.loadPairing(forceRefresh = true) },
modifier = modifier,
) { paddingValues ->
val pairing = state.pairing
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,6 +40,11 @@ class PairingViewModel :
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null
private val pairingCache = SwrCache<String, PairingResponse>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, pairing = null, errorMessage = null, actionKey = null) }
}
init {
// Issue #784: gateway broadcasts pairing.changed — refresh silently
@@ -44,14 +52,23 @@ class PairingViewModel :
refreshOnChange(
eventType = ChangeEvents.PAIRING,
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onSuccess = { data -> _uiState.update { it.copy(pairing = data) } },
onSuccess = { data ->
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null) pairingCache.put(requestScope.inMemoryKey("default"), data)
_uiState.update { it.copy(pairing = data) }
},
)
}
fun loadPairing() {
fun loadPairing(forceRefresh: Boolean = false) {
launchJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = pairingCache,
forceRefresh = forceRefresh,
currentJob = launchJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, pairing = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -127,7 +144,7 @@ class PairingViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { onSuccess(it.copy(actionKey = null)) }
loadPairing()
loadPairing(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -50,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.MemoryProviderDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -75,11 +76,17 @@ fun PluginsScreen(
viewModel: PluginsViewModel = viewModel { PluginsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadPlugins()
}
val filteredPlugins =
remember(query, state.plugins) {
state.plugins.filter { plugin ->
@@ -111,10 +118,6 @@ fun PluginsScreen(
}
}
LaunchedEffect(Unit) {
viewModel.loadPlugins()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Remove confirmation dialog
@@ -152,7 +155,7 @@ fun PluginsScreen(
isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
onRefresh = {
if (state.selectedTab == PluginsTab.INSTALLED) {
viewModel.loadPlugins()
viewModel.loadPlugins(forceRefresh = true)
} else {
viewModel.loadCatalog(isRefresh = true)
}
@@ -2,10 +2,14 @@ package com.m57.hermescontrol.ui.plugins
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.SwrCache
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -13,6 +17,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -73,8 +78,56 @@ class PluginsViewModel :
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
fun loadPlugins() {
safeLaunchLoad(
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
plugins = emptyList(),
orphanPlugins = emptyList(),
errorMessage = null,
memoryProvider = "",
memoryOptions = emptyList(),
contextEngine = "compressor",
contextOptions = emptyList(),
providerBusy = false,
rowBusy = null,
removeConfirmPlugin = null,
rescanBusy = false,
)
}
}
fun loadPlugins(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = pluginsCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
val plugins = data.plugins.orEmpty()
val providers = data.providers
_uiState.update {
it.copy(
isLoading = false,
plugins = plugins,
orphanPlugins =
data.orphanDashboardPlugins.orEmpty().map { orphan ->
PluginInfo(
name = orphan.name ?: "unknown",
description = orphan.description ?: orphan.label,
version = null,
source = null,
runtimeStatus = null,
)
},
memoryProvider = providers?.memoryProvider ?: "",
memoryOptions = providers?.memoryOptions.orEmpty(),
contextEngine = providers?.contextEngine ?: "compressor",
contextOptions = providers?.contextOptions.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getPlugins() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -85,10 +138,10 @@ class PluginsViewModel :
isLoading = false,
plugins = plugins,
orphanPlugins =
data.orphanDashboardPlugins.orEmpty().map {
data.orphanDashboardPlugins.orEmpty().map { orphan ->
PluginInfo(
name = it.name ?: "unknown",
description = it.description ?: it.label,
name = orphan.name ?: "unknown",
description = orphan.description ?: orphan.label,
version = null,
source = null,
runtimeStatus = null,
@@ -153,7 +206,7 @@ class PluginsViewModel :
toastMessage = "Plugin installed successfully",
)
}
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -194,7 +247,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -219,7 +272,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -237,6 +290,7 @@ class PluginsViewModel :
fun togglePlugin(plugin: PluginInfo) {
val originalEnabled = plugin.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistically update
_uiState.update { state ->
@@ -261,8 +315,19 @@ class PluginsViewModel :
safeApiCall { ApiClient.hermesApi.disablePlugin(plugin.name) }
}
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.plugins
if (requestScope != null) {
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = updated))
}
} else if (result is NetworkResult.Failure) {
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${result.error.message}")
val reverted = _uiState.value.plugins
if (requestScope != null) {
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = reverted))
}
}
}
}
@@ -276,7 +341,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -309,7 +374,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
clearRowBusy(name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -331,7 +396,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
clearRowBusy(name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -359,7 +424,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin visibility updated") }
clearRowBusy(plugin.name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -469,7 +534,7 @@ class PluginsViewModel :
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
)
}
loadPlugins()
loadPlugins(forceRefresh = true)
loadCatalog(isRefresh = true)
},
onError = { errorMsg ->
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -83,6 +84,7 @@ fun ProfilesScreen(
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
@@ -106,7 +108,8 @@ fun ProfilesScreen(
var isBuildingProfile by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadProfiles()
}
@@ -133,7 +136,7 @@ fun ProfilesScreen(
if (isBuildingProfile) {
null
} else {
{ viewModel.loadProfiles() }
{ viewModel.loadProfiles(forceRefresh = true) }
},
actions = {
if (!isBuildingProfile) {
@@ -3,12 +3,15 @@ package com.m57.hermescontrol.ui.profiles
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.SwrCache
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -112,8 +115,53 @@ class ProfilesViewModel(
}
}
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
private val profilesCache = SwrCache<String, ProfilesResponse>()
/**
* Reset state owned by the previous server/profile context (PR #1192 cache scoping).
* Profiles, their soul content, setup commands, and builder data all belong to the
* server that served them and must not be shown for a new context.
*/
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
profiles = emptyList(),
activeProfileName = null,
sharedGatewayProfiles = emptyList(),
selectedSoulContent = null,
isLoadingSoul = false,
errorMessage = null,
isAutoDescribing = false,
setupCommand = null,
isLoadingSetupCommand = false,
modelPickerPinned = emptyList(),
modelProviders = emptyList(),
isLoadingBuilderData = false,
availableSkills = emptyList(),
hubSearchResults = emptyList(),
isSearchingHub = false,
)
}
}
fun loadProfiles(forceRefresh: Boolean = false) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopedKey = requestScope?.inMemoryKey("default")
if (forceRefresh && scopedKey != null) profilesCache.remove(scopedKey)
val cached = if (!forceRefresh && scopedKey != null) profilesCache.get(scopedKey) else null
if (cached != null) {
_uiState.update {
it.copy(
isLoading = false,
profiles = cached.profiles.orEmpty(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try {
coroutineScope {
@@ -125,7 +173,11 @@ class ProfilesViewModel(
val profilesResult = profilesDeferred.await()
val activeResult = activeDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope != requestScope) return@coroutineScope
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
if (scopedKey != null) profilesCache.put(scopedKey, profilesResult.data)
_uiState.update {
it.copy(
isLoading = false,
@@ -133,6 +185,7 @@ class ProfilesViewModel(
activeProfileName = activeResult.data.active,
sharedGatewayProfiles = emptyList(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -145,7 +198,7 @@ class ProfilesViewModel(
)
}
}
} else {
} else if (cached == null || forceRefresh) {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update {
@@ -159,8 +212,11 @@ class ProfilesViewModel(
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope == requestScope && (cached == null || forceRefresh)) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
}
}
}
}
@@ -178,7 +234,7 @@ class ProfilesViewModel(
when (val result = ProfileSwitchCoordinator.switchProfile(name)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -244,7 +300,7 @@ class ProfilesViewModel(
toastMessage = "Soul updated successfully",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -276,7 +332,7 @@ class ProfilesViewModel(
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -322,7 +378,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$sourceProfileName' cloned successfully to '$newProfileName'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -359,7 +415,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$profileName' description updated",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -397,7 +453,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$oldName' renamed to '$newName'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -426,7 +482,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$name' deleted",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -463,7 +519,7 @@ class ProfilesViewModel(
toastMessage = "Auto-described profile '$name'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
} else {
// Backend returns generation failures as ok:false + reason,
// NOT an HTTP error — surface the reason to the user.
@@ -656,7 +712,7 @@ class ProfilesViewModel(
toastMessage = "Profile ${request.name} created successfully",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
onSuccess()
}
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -65,9 +66,13 @@ fun ProvidersScreen(
viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.load()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -98,7 +103,7 @@ fun ProvidersScreen(
title = { Text(stringResource(R.string.screen_providers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
onRefresh = { viewModel.load(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.providers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthProvidersResponse
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -66,9 +69,35 @@ class ProvidersViewModel :
val uiState: StateFlow<ProvidersUiState> = _uiState.asStateFlow()
private var pollJob: Job? = null
private val providersCache = SwrCache<String, OAuthProvidersResponse>()
fun load() {
safeLaunchLoad(
fun clearScopeOwnedState() {
pollJob?.cancel()
_uiState.update {
it.copy(
isLoading = false,
providers = emptyList(),
errorMessage = null,
flowPhase = OAuthFlowPhase.IDLE,
flowProvider = null,
flowErrorMessage = null,
)
}
}
fun load(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = providersCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
providers = data.providers.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -105,7 +134,7 @@ class ProvidersViewModel :
toastMessage = "Disconnected",
)
}
load()
load(forceRefresh = true)
},
onError = { error ->
_uiState.update {
@@ -301,7 +330,7 @@ class ProvidersViewModel :
private fun finishFlow(status: String) {
pollJob?.cancel()
load()
load(forceRefresh = true)
// Stay on DONE so the dialog can show a success confirmation; the Screen's
// Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would
// conflate with DONE via StateFlow and unmount the dialog before the user sees it.)
@@ -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) }
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -365,6 +366,7 @@ class SettingsViewModel(
AuthManager.setSessionCookie(null)
AuthManager.setWsAuthParam("token")
AuthManager.clearLastOpenedSessionId()
SessionListCacheStore.clear()
HermesWsClient.disconnect(clearPendingMessages = true)
// Don't rebuild ApiClient here — let the navigation complete first
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
@@ -43,11 +44,13 @@ fun SkillsScreen(
viewModel: SkillsViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) }
var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadSkills()
}
@@ -58,7 +61,7 @@ fun SkillsScreen(
isRefreshing = state.isLoading,
onRefresh = {
if (state.viewMode == SkillsViewMode.INSTALLED) {
viewModel.loadSkills()
viewModel.loadSkills(forceRefresh = true)
} else {
viewModel.loadHubSources()
}
@@ -4,6 +4,9 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
@@ -17,6 +20,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -93,11 +97,35 @@ class SkillsViewModel(
private var loadJob: Job? = null
private var searchJob: Job? = null
private val skillsCache = SwrCache<String, List<Skill>>()
fun loadSkills() {
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
skills = emptyList(),
errorMessage = null,
editingSkillName = null,
skillContent = null,
isLoadingContent = false,
isSavingContent = false,
saveContentSuccess = false,
previewSkillName = null,
previewSkillContent = null,
isLoadingPreview = false,
)
}
}
fun loadSkills(forceRefresh: Boolean = false) {
loadJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = skillsCache,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, skills = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -328,7 +356,7 @@ class SkillsViewModel(
)
}
// Refresh installed skills
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -365,7 +393,7 @@ class SkillsViewModel(
toastMessage = "Uninstalled: $name",
)
}
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -396,7 +424,9 @@ class SkillsViewModel(
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
@@ -411,12 +441,23 @@ class SkillsViewModel(
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.skills
if (requestScope != null) {
skillsCache.put(requestScope.scopedKey("default"), updated)
}
} else if (result is NetworkResult.Failure) {
revertSkillToggle(
skill.name,
originalEnabled,
"Failed to toggle skill: ${result.error.message}",
)
val reverted = _uiState.value.skills
if (requestScope != null) {
skillsCache.put(requestScope.scopedKey("default"), reverted)
}
}
}
}
@@ -525,7 +566,7 @@ class SkillsViewModel(
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Skills updated") }
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -154,48 +154,86 @@ class SystemViewModel(
val statsResult = statsDeferred.await()
val statusResult = statusDeferred.await()
val portalResult = portalDeferred.await()
val curatorResult = curatorDeferred.await()
val credResult = credDeferred.await()
val checkpointsResult = checkpointsDeferred.await()
val hooksResult = hooksDeferred.await()
val updateResult = updateDeferred.await()
val doctorResult = doctorDeferred.await()
// Phase 1: Un-gate UI immediately with fast host stats & gateway status (~100-200ms)
_uiState.update { state ->
state.copy(
isLoading = false,
stats = (statsResult as? NetworkResult.Success)?.data,
status = (statusResult as? NetworkResult.Success)?.data,
portal = (portalResult as? NetworkResult.Success)?.data,
curator = (curatorResult as? NetworkResult.Success)?.data,
credentials =
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
hooks = (hooksResult as? NetworkResult.Success)?.data,
updateInfo = (updateResult as? NetworkResult.Success)?.data,
doctorReport = (doctorResult as? NetworkResult.Success)?.data,
stats = (statsResult as? NetworkResult.Success)?.data ?: state.stats,
status = (statusResult as? NetworkResult.Success)?.data ?: state.status,
errorMessage = null,
)
}
// Log failures in debug builds
if (BuildConfig.DEBUG) {
listOf(
"stats" to statsResult,
"status" to statusResult,
"portal" to portalResult,
"curator" to curatorResult,
"credentials" to credResult,
"checkpoints" to checkpointsResult,
"hooks" to hooksResult,
"update" to updateResult,
"doctor" to doctorResult,
).forEach { (name, result) ->
if (result is NetworkResult.Failure) {
Log.w(TAG, "$name endpoint: ${result.error.message}")
}
if (statsResult is NetworkResult.Failure) {
Log.w(TAG, "stats endpoint: ${statsResult.error.message}")
}
if (statusResult is NetworkResult.Failure) {
Log.w(TAG, "status endpoint: ${statusResult.error.message}")
}
}
// Phase 2: Progressively hydrate remaining sections as each endpoint resolves
launch {
val portalResult = portalDeferred.await()
if (portalResult is NetworkResult.Success) {
_uiState.update { it.copy(portal = portalResult.data) }
} else if (BuildConfig.DEBUG && portalResult is NetworkResult.Failure) {
Log.w(TAG, "portal endpoint: ${portalResult.error.message}")
}
}
launch {
val curatorResult = curatorDeferred.await()
if (curatorResult is NetworkResult.Success) {
_uiState.update { it.copy(curator = curatorResult.data) }
} else if (BuildConfig.DEBUG && curatorResult is NetworkResult.Failure) {
Log.w(TAG, "curator endpoint: ${curatorResult.error.message}")
}
}
launch {
val credResult = credDeferred.await()
if (credResult is NetworkResult.Success) {
_uiState.update { it.copy(credentials = credResult.data.providers.orEmpty()) }
} else if (BuildConfig.DEBUG && credResult is NetworkResult.Failure) {
Log.w(TAG, "credentials endpoint: ${credResult.error.message}")
}
}
launch {
val checkpointsResult = checkpointsDeferred.await()
if (checkpointsResult is NetworkResult.Success) {
_uiState.update { it.copy(checkpoints = checkpointsResult.data) }
} else if (BuildConfig.DEBUG && checkpointsResult is NetworkResult.Failure) {
Log.w(TAG, "checkpoints endpoint: ${checkpointsResult.error.message}")
}
}
launch {
val hooksResult = hooksDeferred.await()
if (hooksResult is NetworkResult.Success) {
_uiState.update { it.copy(hooks = hooksResult.data) }
} else if (BuildConfig.DEBUG && hooksResult is NetworkResult.Failure) {
Log.w(TAG, "hooks endpoint: ${hooksResult.error.message}")
}
}
launch {
val updateResult = updateDeferred.await()
if (updateResult is NetworkResult.Success) {
_uiState.update { it.copy(updateInfo = updateResult.data) }
} else if (BuildConfig.DEBUG && updateResult is NetworkResult.Failure) {
Log.w(TAG, "update endpoint: ${updateResult.error.message}")
}
}
launch {
val doctorResult = doctorDeferred.await()
if (doctorResult is NetworkResult.Success) {
_uiState.update { it.copy(doctorReport = doctorResult.data) }
} else if (BuildConfig.DEBUG && doctorResult is NetworkResult.Failure) {
Log.w(TAG, "doctor endpoint: ${doctorResult.error.message}")
}
}
}
@@ -42,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ToolsetDetailKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -60,9 +61,15 @@ fun ToolsetsScreen(
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadToolsets()
}
val filteredToolsets =
remember(query, state.toolsets) {
state.toolsets.filter { toolset ->
@@ -72,17 +79,13 @@ fun ToolsetsScreen(
}
}
LaunchedEffect(Unit) {
viewModel.loadToolsets()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_toolsets)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadToolsets() },
onRefresh = { viewModel.loadToolsets(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.toolsets.isEmpty() -> {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.toolsets
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.SwrCache
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -9,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,8 +34,19 @@ class ToolsetsViewModel :
private val _uiState = MutableStateFlow(ToolsetsUiState())
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
fun loadToolsets() {
safeLaunchLoad(
private val toolsetsCache = SwrCache<String, List<Toolset>>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, toolsets = emptyList(), errorMessage = null) }
}
fun loadToolsets(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = toolsetsCache,
forceRefresh = forceRefresh,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, toolsets = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getToolsets() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -51,6 +66,7 @@ class ToolsetsViewModel :
fun toggleToolset(toolset: Toolset) {
val originalEnabled = toolset.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistic UI update
_uiState.update { state ->
@@ -67,8 +83,19 @@ class ToolsetsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled)) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.toolsets
if (requestScope != null) {
toolsetsCache.put(requestScope.scopedKey("default"), updated)
}
} else if (result is NetworkResult.Failure) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${result.error.message}")
val reverted = _uiState.value.toolsets
if (requestScope != null) {
toolsetsCache.put(requestScope.scopedKey("default"), reverted)
}
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
@@ -71,6 +72,7 @@ fun WebhooksScreen(
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<WebhookSubscription?>(null) }
@@ -84,7 +86,8 @@ fun WebhooksScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadWebhooks()
}
@@ -232,7 +235,7 @@ fun WebhooksScreen(
title = { Text(stringResource(R.string.screen_webhooks)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadWebhooks() },
onRefresh = { viewModel.loadWebhooks(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.showCreateDialog() }) {
Icon(
@@ -2,15 +2,18 @@ package com.m57.hermescontrol.ui.webhooks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
import com.m57.hermescontrol.data.model.WebhooksResponse
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
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.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -48,8 +51,37 @@ class WebhooksViewModel :
private val _uiState = MutableStateFlow(WebhooksUiState())
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
fun loadWebhooks() {
safeLaunchLoad(
private val webhooksCache = SwrCache<String, WebhooksResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
enabled = false,
baseUrl = null,
subscriptions = emptyList(),
errorMessage = null,
deleteTarget = null,
togglingName = null,
)
}
}
fun loadWebhooks(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = webhooksCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
enabled = data.enabled,
baseUrl = data.base_url,
subscriptions = data.subscriptions.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getWebhooks() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -90,7 +122,7 @@ class WebhooksViewModel :
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -185,7 +217,7 @@ class WebhooksViewModel :
toastMessage = "Subscription \"$name\" created",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -226,7 +258,7 @@ class WebhooksViewModel :
toastMessage = "Subscription \"$name\" ${if (enabled) "enabled" else "disabled"}",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -263,7 +295,7 @@ class WebhooksViewModel :
_uiState.update {
it.copy(toastMessage = "Subscription \"${target.name}\" deleted")
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {