diff --git a/AGENTS.md b/AGENTS.md index 0135e5a7..072afd78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,8 +253,6 @@ gh pr create --title "fix(#N): description" --body "Closes #N" - Don't run `./gradlew` tasks without an Android SDK — CI handles compilation, lint, and tests. - Don't remove `@OptIn(ExperimentalMaterial3Api::class)` from screens that use `SegmentedButton`, `PullToRefreshBox`, or other experimental M3 APIs. -- Don't use `remember { AuthManager.getBottomNavItems() }` without a key — - SharedPreferences is not Compose state; the value goes stale. Read it directly. - Don't access `MaterialTheme.colorScheme.*` inside non-`@Composable` lambdas (`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local `val` at the composable scope first. diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 8b3f1475..3ea94ac8 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -6,9 +6,9 @@ import androidx.navigation3.runtime.NavKey /** * Central navigation controller with deduplication guard. * - * Primary screens (bottom nav tabs) clear the back stack and become the new - * root — this matches Material's bottom-nav pattern where each tab has its own - * back stack history (simplified: we just clear). + * Top-level primary screens (drawer items) clear the back stack and become the new + * root — this matches navigation drawer patterns where switching top-level sections + * resets the stack. * * B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks. * Always route through [navigateTo] to prevent stacking duplicate screen @@ -18,9 +18,7 @@ object NavigationController { var backStack: NavBackStack? = null var pendingSessionId: String? = null - // Bottom-nav primary screens — dynamic, updated by Navigation.kt via - // updatePrimaryScreens() when the user customises the bottom nav bar. - // Default matches the default 5 bottom-nav items. + // Top-level primary screens (Chat, Skills, Cron, System, Settings) private val primaryScreens: MutableSet = mutableSetOf( ChatScreen, @@ -30,16 +28,9 @@ object NavigationController { SettingsScreen, ) - /** Returns whether the given key is a primary (bottom-nav) screen. */ + /** Returns whether the given key is a primary top-level screen. */ fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens - /** Replace the primary screen set. Called by Navigation.kt when the - * bottom-nav item config changes. */ - fun updatePrimaryScreens(keys: Set) { - primaryScreens.clear() - primaryScreens.addAll(keys) - } - fun navigateTo(key: NavKey) { val stack = backStack ?: return if (stack.lastOrNull() == key) return diff --git a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt index 1a701070..ee1bf14a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/billing/BillingViewModel.kt @@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.model.UsageBarsResponse import com.m57.hermescontrol.data.ws.BillingRepository import com.m57.hermescontrol.data.ws.HermesWsClient 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 @@ -47,54 +49,61 @@ class BillingViewModel : ViewModel() { _uiState.update { it.copy(isLoading = true, errorMessage = null, featureUnavailable = false) } loadJob = viewModelScope.launch { - var sub: SubscriptionStateResponse? = null - var bars: UsageBarsResponse? = null - var unavailable = false - var error: String? = null - try { - sub = BillingRepository.getSubscriptionState() - // The live gateway returns an `ok:false` *result* (with - // `error`/`message`) rather than an RPC-level error when the - // backend feature is missing or the user isn't portal-authed. - // Catch that here so the screen degrades gracefully. - if (sub?.ok == false) { - unavailable = true - error = sub.error ?: "Billing unavailable" - } else if (sub == null) { - // Backend returned no result payload (null result / unmapped - // response). Treat as a non-fatal load miss, not a crash. - if (error == null) error = "No billing data returned" + coroutineScope { + val subDeferred = + async { + var sub: SubscriptionStateResponse? = null + var unavailable = false + var error: String? = null + try { + sub = BillingRepository.getSubscriptionState() + if (sub?.ok == false) { + unavailable = true + error = sub.error ?: "Billing unavailable" + } else if (sub == null) { + if (error == null) error = "No billing data returned" + } + } catch (e: HermesWsClient.HermesRpcException) { + unavailable = true + error = e.message + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + error = e.message ?: "Failed to load subscription" + } + Triple(sub, unavailable, error) + } + + val barsDeferred = + async { + var bars: UsageBarsResponse? = null + var error: String? = null + try { + bars = BillingRepository.getUsageBars() + if (bars?.ok == false) { + error = "Usage data unavailable" + } + } catch (e: HermesWsClient.HermesRpcException) { + // usage unavailable is non-fatal + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + error = e.message ?: "Failed to load usage" + } + Pair(bars, error) + } + + val (sub, unavailable, subError) = subDeferred.await() + val (bars, barsError) = barsDeferred.await() + val error = subError ?: barsError + + _uiState.update { + it.copy( + isLoading = false, + subscription = sub, + usage = bars, + featureUnavailable = unavailable, + errorMessage = if (sub == null) error else null, + ) } - } catch (e: HermesWsClient.HermesRpcException) { - unavailable = true - error = e.message - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - error = e.message ?: "Failed to load subscription" - } - try { - bars = BillingRepository.getUsageBars() - if (bars?.ok == false) { - // usage unavailable is non-fatal — the plan card still renders - if (error == null) error = "Usage data unavailable" - } - } catch (e: HermesWsClient.HermesRpcException) { - // usage unavailable is non-fatal — do NOT mark the whole - // feature unavailable; the plan card still renders. - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - if (error == null) error = e.message ?: "Failed to load usage" - } - _uiState.update { - it.copy( - isLoading = false, - subscription = sub, - usage = bars, - featureUnavailable = unavailable, - // A failed primary subscription load is fatal — surface it - // even if usage bars happened to load. - errorMessage = if (sub == null) error else null, - ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index 4a6e8193..f67f8918 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -9,8 +9,9 @@ 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 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 @@ -51,53 +52,55 @@ class ConfigViewModel : } fun loadAll() { - safeLaunchLoad( - apiCall = { - safeApiCall { ApiClient.hermesApi.getConfig() } - }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { configData -> - viewModelScope.launch { - val schemaResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getConfigSchema() } - } - val defaultsResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getConfigDefaults() } - } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + try { + coroutineScope { + val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } } + val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } } + val defaultsDeferred = + async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } } + val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } } - val schema = (schemaResult as? NetworkResult.Success)?.data - val defaults = (defaultsResult as? NetworkResult.Success)?.data - val rawResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getRawConfig() } - } - val path = (rawResult as? NetworkResult.Success)?.data?.path + val configResult = configDeferred.await() + val schemaResult = schemaDeferred.await() + val defaultsResult = defaultsDeferred.await() + val rawResult = rawDeferred.await() - _uiState.update { - it.copy( - isLoading = false, - config = configData, - schema = schema, - defaults = defaults, - path = path, - activeCategory = - if (schema?.category_order?.isNotEmpty() == true) { - schema.category_order.first() - } else { - "" - }, - ) + if (configResult is NetworkResult.Success) { + val configData = 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( + isLoading = false, + config = configData, + schema = schema, + defaults = defaults, + path = path, + activeCategory = + if (schema?.category_order?.isNotEmpty() == true) { + schema.category_order.first() + } else { + "" + }, + ) + } + } else { + val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error" + _uiState.update { + it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg") + } } } - }, - onError = { errorMsg -> + } catch (e: Exception) { _uiState.update { - it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg") + it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}") } - }, - ) + } + } } fun updateField( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 3722f459..64a560b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -17,6 +17,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost 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 @@ -49,31 +51,39 @@ class ProfilesViewModel : fun loadProfiles() { _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { - val profilesResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getProfiles() } - } - val activeResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getActiveProfile() } - } + try { + coroutineScope { + val profilesDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() } } + val activeDeferred = + async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } - if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) { - _uiState.update { - it.copy( - isLoading = false, - profiles = profilesResult.data.profiles.orEmpty(), - activeProfileName = activeResult.data.active, - ) + val profilesResult = profilesDeferred.await() + val activeResult = activeDeferred.await() + + if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) { + _uiState.update { + it.copy( + isLoading = false, + profiles = profilesResult.data.profiles.orEmpty(), + activeProfileName = activeResult.data.active, + ) + } + } else { + val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success" + val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success" + _uiState.update { + it.copy( + isLoading = false, + errorMessage = + "Failed to load profiles/active: " + + "Profiles: $profilesError, Active: $activeError", + ) + } + } } - } else { - val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success" - val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success" + } catch (e: Exception) { _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Failed to load profiles/active: Profiles: $profilesError, Active: $activeError", - ) + it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}") } } } @@ -289,30 +299,40 @@ class ProfilesViewModel : fun loadBuilderData() { _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } viewModelScope.launch { - val modelsResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getModelOptions() } - } - val skillsResult = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getSkills() } - } + try { + coroutineScope { + val modelsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } + val skillsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSkills() } } - if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) { - _uiState.update { - it.copy( - isLoadingBuilderData = false, - modelProviders = modelsResult.data.providers, - availableSkills = skillsResult.data, - ) + val modelsResult = modelsDeferred.await() + val skillsResult = skillsDeferred.await() + + if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) { + _uiState.update { + it.copy( + isLoadingBuilderData = false, + modelProviders = modelsResult.data.providers, + availableSkills = skillsResult.data, + ) + } + } else { + val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success" + val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success" + _uiState.update { + it.copy( + isLoadingBuilderData = false, + errorMessage = + "Failed to load builder data: " + + "Models: $modelsError, Skills: $skillsError", + ) + } + } } - } else { - val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success" - val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success" + } catch (e: Exception) { _uiState.update { it.copy( isLoadingBuilderData = false, - errorMessage = "Failed to load builder data: Models: $modelsError, Skills: $skillsError", + errorMessage = "Failed to load builder data: ${e.message}", ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index ae0523e3..a538e4a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -159,6 +159,53 @@ class SystemViewModel : } } + // ── Targeted Data Reloads ────────────────────────────────────────── + + fun loadCredentials() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(credentials = result.data.providers ?: emptyList()) } + } + } + } + + fun loadHooks() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(hooks = result.data) } + } + } + } + + fun loadCurator() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(curator = result.data) } + } + } + } + + fun loadMemory() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(memory = result.data) } + } + } + } + + fun loadStatus() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(status = result.data) } + } + } + } + // ── Gateway actions ──────────────────────────────────────────────── fun startGateway() { @@ -182,7 +229,7 @@ class SystemViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") } - loadAll() + loadStatus() } is NetworkResult.Failure -> { @@ -249,7 +296,7 @@ class SystemViewModel : toastMessage = "Curator ${if (!currentlyPaused) "paused" else "resumed"}", ) } - loadAll() + loadCurator() } is NetworkResult.Failure -> { @@ -277,7 +324,7 @@ class SystemViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") } - loadAll() + loadMemory() } is NetworkResult.Failure -> { @@ -329,7 +376,7 @@ class SystemViewModel : toastMessage = "Credential added", ) } - loadAll() + loadCredentials() } is NetworkResult.Failure -> { @@ -353,7 +400,7 @@ class SystemViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Credential removed") } - loadAll() + loadCredentials() } is NetworkResult.Failure -> { @@ -561,7 +608,7 @@ class SystemViewModel : toastMessage = "Hook created", ) } - loadAll() + loadHooks() } is NetworkResult.Failure -> { @@ -588,7 +635,7 @@ class SystemViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Hook deleted") } - loadAll() + loadHooks() } is NetworkResult.Failure -> { diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 52dc6c2a..0cbdcc73 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -191,7 +191,7 @@ class NavigationControllerTest { assertNull(NavigationController.backStack) } - // ── primaryScreens and updatePrimaryScreens ─────────────────────────── + // ── primaryScreens ──────────────────────────────────────────────────── @Test fun `isPrimaryScreen returns true for default screens`() { @@ -208,22 +208,4 @@ class NavigationControllerTest { assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) } - - @Test - fun `updatePrimaryScreens replaces the full screen set`() { - NavigationController.updatePrimaryScreens(setOf(ProfilesScreen, SystemScreen)) - - assertTrue("ProfilesScreen should now be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) - assertTrue("SystemScreen should still be primary", NavigationController.isPrimaryScreen(SystemScreen)) - - // Old defaults should no longer be primary - assertFalse("ChatScreen should no longer be primary", NavigationController.isPrimaryScreen(ChatScreen)) - assertFalse("SkillsScreen should no longer be primary", NavigationController.isPrimaryScreen(SkillsScreen)) - assertFalse("SettingsScreen should no longer be primary", NavigationController.isPrimaryScreen(SettingsScreen)) - - // Reset to defaults to avoid test pollution - NavigationController.updatePrimaryScreens( - setOf(ChatScreen, SkillsScreen, CronJobsScreen, SystemScreen, SettingsScreen), - ) - } }