* feat(toolsets): per-toolset config/env/provider/post-setup management (#782) * fix(toolsets): per-entry toolset name for shared detail ViewModel (#782) NavDisplay uses the activity-level ViewModelStoreOwner, so class-keyed viewModel{} returns the same ToolsetDetailViewModel for every detail entry; the constructor-captured name froze the first-opened toolset into all entries (on-device: every toolset showed web's providers, no further /config calls after the first). The screen now drives setToolset(name) + loadConfig() per entry, and setToolset resets per-toolset state (config, revealed secrets, running post-setup) so nothing bleeds across toolsets. * style(toolsets): single-line scrollable tool chip strip (#782) A wrapping FlowRow stacked chips vertically for toolsets with many tools (web etc.), ballooning card height — especially on large font scales. Chips now sit on one horizontally scrollable row. * chore(toolsets): drop stray terminal-noise line from chip strip change --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
d9f437939a
commit
fe79ff42b4
9 files changed
+1558
-16
No files matched your search
@@ -56,6 +56,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
|||||||
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||||
|
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||||
@@ -129,6 +130,15 @@ private fun appEntryProvider(
|
|||||||
viewModel = viewModel { SettingsViewModel() },
|
viewModel = viewModel { SettingsViewModel() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Toolset detail drill-down (issue #782) ────────────────────────
|
||||||
|
entry<ToolsetDetailKey> { key ->
|
||||||
|
ToolsetDetailScreen(
|
||||||
|
name = key.name,
|
||||||
|
label = key.label,
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object ToolsetsScreen : NavKey
|
@Serializable data object ToolsetsScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class ToolsetDetailKey(
|
||||||
|
val name: String,
|
||||||
|
val label: String? = null,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object AchievementsScreen : NavKey
|
@Serializable data object AchievementsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ConfigScreen : NavKey
|
@Serializable data object ConfigScreen : NavKey
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-toolset management payloads — issue #782.
|
||||||
|
*
|
||||||
|
* Contracts verified against hermes-agent hermes_cli/web_routers/tools.py:
|
||||||
|
* - GET /api/tools/toolsets/{name}/config → provider matrix + key status
|
||||||
|
* (web toolset additionally resolves active_search_backend /
|
||||||
|
* active_extract_backend the way the runtime dispatchers do).
|
||||||
|
* - PUT /api/tools/toolsets/{name}/provider {provider} → {ok, name, provider,
|
||||||
|
* needs_nous_auth?, feature?} (managed Nous rows without Portal entitlement
|
||||||
|
* write the keys but won't activate until sign-in).
|
||||||
|
* - PUT /api/tools/toolsets/{name}/env {env: {KEY: value}} → {ok, name,
|
||||||
|
* saved[], skipped[], is_set{}}; keys validated against the toolset
|
||||||
|
* category's env-var allowlist, blank values skipped ("leave unchanged").
|
||||||
|
* - POST /api/tools/toolsets/{name}/post-setup {key} → {ok, pid, name,
|
||||||
|
* key}; spawns `hermes tools post-setup <key>` and the client tails
|
||||||
|
* GET /api/actions/{name}/status (see ActionStatusResponse).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetConfigResponse(
|
||||||
|
val name: String,
|
||||||
|
val hasCategory: Boolean = false,
|
||||||
|
val providers: List<ToolsetProvider> = emptyList(),
|
||||||
|
val activeProvider: String? = null,
|
||||||
|
val activeSearchBackend: String? = null,
|
||||||
|
val activeExtractBackend: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetProvider(
|
||||||
|
val name: String,
|
||||||
|
val badge: String? = null,
|
||||||
|
val tag: String? = null,
|
||||||
|
val envVars: List<ToolsetEnvVar> = emptyList(),
|
||||||
|
val postSetup: String? = null,
|
||||||
|
val requiresNousAuth: Boolean = false,
|
||||||
|
val isActive: Boolean = false,
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetEnvVar(
|
||||||
|
val key: String,
|
||||||
|
val prompt: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val default: String? = null,
|
||||||
|
val isSet: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetProviderSelectRequest(
|
||||||
|
val provider: String,
|
||||||
|
val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetProviderSelectResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val name: String? = null,
|
||||||
|
val provider: String? = null,
|
||||||
|
val needsNousAuth: Boolean = false,
|
||||||
|
val feature: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetEnvUpdateRequest(
|
||||||
|
val env: Map<String, String>,
|
||||||
|
val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetEnvUpdateResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val name: String? = null,
|
||||||
|
val saved: List<String> = emptyList(),
|
||||||
|
val skipped: List<String> = emptyList(),
|
||||||
|
val isSet: Map<String, Boolean> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetPostSetupRequest(
|
||||||
|
val key: String,
|
||||||
|
val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolsetPostSetupResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val pid: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val key: String? = null,
|
||||||
|
)
|
||||||
@@ -107,6 +107,13 @@ import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse
|
|||||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStatusResponse
|
import com.m57.hermescontrol.data.model.TelegramOnboardingStatusResponse
|
||||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||||
import com.m57.hermescontrol.data.model.Toolset
|
import com.m57.hermescontrol.data.model.Toolset
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetPostSetupRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateCheckResponse
|
import com.m57.hermescontrol.data.model.UpdateCheckResponse
|
||||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
@@ -407,6 +414,30 @@ interface HermesApiService {
|
|||||||
@Body body: ToolsetToggleRequest,
|
@Body body: ToolsetToggleRequest,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
// ── Per-toolset management (issue #782) ────────────────────────────
|
||||||
|
@GET("api/tools/toolsets/{name}/config")
|
||||||
|
suspend fun getToolsetConfig(
|
||||||
|
@Path("name") name: String,
|
||||||
|
): Response<ToolsetConfigResponse>
|
||||||
|
|
||||||
|
@PUT("api/tools/toolsets/{name}/provider")
|
||||||
|
suspend fun selectToolsetProvider(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: ToolsetProviderSelectRequest,
|
||||||
|
): Response<ToolsetProviderSelectResponse>
|
||||||
|
|
||||||
|
@PUT("api/tools/toolsets/{name}/env")
|
||||||
|
suspend fun saveToolsetEnv(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: ToolsetEnvUpdateRequest,
|
||||||
|
): Response<ToolsetEnvUpdateResponse>
|
||||||
|
|
||||||
|
@POST("api/tools/toolsets/{name}/post-setup")
|
||||||
|
suspend fun runToolsetPostSetup(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: ToolsetPostSetupRequest,
|
||||||
|
): Response<ToolsetPostSetupResponse>
|
||||||
|
|
||||||
@GET("api/plugins/hermes-achievements/achievements")
|
@GET("api/plugins/hermes-achievements/achievements")
|
||||||
suspend fun getAchievements(): Response<AchievementsResponse>
|
suspend fun getAchievements(): Response<AchievementsResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,708 @@
|
|||||||
|
@file:OptIn(
|
||||||
|
androidx.compose.material3.ExperimentalMaterial3Api::class,
|
||||||
|
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
|
||||||
|
)
|
||||||
|
|
||||||
|
package com.m57.hermescontrol.ui.toolsets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
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.model.ToolsetConfigResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvVar
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProvider
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-toolset management page (issue #782) — the mobile counterpart of the
|
||||||
|
* desktop toolset-config-panel: provider pick with honest readiness pills,
|
||||||
|
* per-provider API-key entry (save/clear/reveal), and the post-setup install
|
||||||
|
* hook with an inlined live log.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ToolsetDetailScreen(
|
||||||
|
name: String,
|
||||||
|
label: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: ToolsetDetailViewModel = viewModel { ToolsetDetailViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val config = state.config
|
||||||
|
|
||||||
|
var deleteTarget by remember { mutableStateOf<ToolsetEnvVar?>(null) }
|
||||||
|
|
||||||
|
// The VM is shared across all toolset detail entries (activity-level
|
||||||
|
// ViewModelStoreOwner) — drive it with the CURRENT entry's toolset.
|
||||||
|
LaunchedEffect(name) {
|
||||||
|
viewModel.setToolset(name)
|
||||||
|
viewModel.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||||
|
|
||||||
|
deleteTarget?.let { envVar ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleteTarget = null },
|
||||||
|
title = { Text(stringResource(R.string.toolset_delete_env_title)) },
|
||||||
|
text = { Text(stringResource(R.string.toolset_delete_env_message, envVar.key)) },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
deleteTarget = null
|
||||||
|
viewModel.clearEnvVar(envVar.key)
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.toolset_action_delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteTarget = null }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HermesScaffold(
|
||||||
|
title = { Text(label ?: name) },
|
||||||
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
|
drawerGesturesEnabled = false,
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = { viewModel.loadConfig() },
|
||||||
|
) { paddingValues ->
|
||||||
|
when {
|
||||||
|
state.isLoading && config == null -> {
|
||||||
|
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage != null && config == null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.errorMessage ?: "",
|
||||||
|
onRetry = { viewModel.loadConfig() },
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
config == null -> {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.toolsets_empty_title),
|
||||||
|
subtitle = stringResource(R.string.toolsets_empty_desc),
|
||||||
|
onAction = { viewModel.loadConfig() },
|
||||||
|
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ToolsetConfigContent(
|
||||||
|
config = config,
|
||||||
|
state = state,
|
||||||
|
onToggleExpanded = viewModel::toggleProviderExpanded,
|
||||||
|
onSelectProvider = viewModel::selectProvider,
|
||||||
|
onSaveEnv = viewModel::saveEnvVar,
|
||||||
|
onRequestDeleteEnv = { deleteTarget = it },
|
||||||
|
onRevealEnv = viewModel::revealEnvVar,
|
||||||
|
onHideEnv = viewModel::hideEnvVar,
|
||||||
|
onRunPostSetup = viewModel::runPostSetup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolsetConfigContent(
|
||||||
|
config: ToolsetConfigResponse,
|
||||||
|
state: ToolsetDetailUiState,
|
||||||
|
onToggleExpanded: (String) -> Unit,
|
||||||
|
onSelectProvider: (ToolsetProvider) -> Unit,
|
||||||
|
onSaveEnv: (String, String) -> Unit,
|
||||||
|
onRequestDeleteEnv: (ToolsetEnvVar) -> Unit,
|
||||||
|
onRevealEnv: (String) -> Unit,
|
||||||
|
onHideEnv: (String) -> Unit,
|
||||||
|
onRunPostSetup: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val noBackendsText = stringResource(R.string.toolset_detail_no_backends)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = listContentPadding,
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
if (config.activeSearchBackend != null || config.activeExtractBackend != null) {
|
||||||
|
item(key = "web-capabilities") {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
config.activeSearchBackend?.let {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(stringResource(R.string.toolset_web_search_active, it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
config.activeExtractBackend?.let {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(stringResource(R.string.toolset_web_extract_active, it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.providers.isEmpty()) {
|
||||||
|
item(key = "no-backends") {
|
||||||
|
Text(
|
||||||
|
text = noBackendsText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(config.providers, key = { "provider-${it.name}" }) { provider ->
|
||||||
|
ToolsetProviderCard(
|
||||||
|
provider = provider,
|
||||||
|
expanded = state.expandedProvider == provider.name,
|
||||||
|
selecting = state.selectingProvider == provider.name,
|
||||||
|
savingEnvKey = state.savingEnvKey,
|
||||||
|
deletingEnvKey = state.deletingEnvKey,
|
||||||
|
revealedValues = state.revealedValues,
|
||||||
|
postSetup = state.postSetup,
|
||||||
|
onToggleExpanded = { onToggleExpanded(provider.name) },
|
||||||
|
onSelectProvider = { onSelectProvider(provider) },
|
||||||
|
onSaveEnv = onSaveEnv,
|
||||||
|
onRequestDeleteEnv = onRequestDeleteEnv,
|
||||||
|
onRevealEnv = onRevealEnv,
|
||||||
|
onHideEnv = onHideEnv,
|
||||||
|
onRunPostSetup = onRunPostSetup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolsetProviderCard(
|
||||||
|
provider: ToolsetProvider,
|
||||||
|
expanded: Boolean,
|
||||||
|
selecting: Boolean,
|
||||||
|
savingEnvKey: String?,
|
||||||
|
deletingEnvKey: String?,
|
||||||
|
revealedValues: Map<String, String>,
|
||||||
|
postSetup: PostSetupState?,
|
||||||
|
onToggleExpanded: () -> Unit,
|
||||||
|
onSelectProvider: () -> Unit,
|
||||||
|
onSaveEnv: (String, String) -> Unit,
|
||||||
|
onRequestDeleteEnv: (ToolsetEnvVar) -> Unit,
|
||||||
|
onRevealEnv: (String) -> Unit,
|
||||||
|
onHideEnv: (String) -> Unit,
|
||||||
|
onRunPostSetup: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val statusText = stringResource(provider.statusTextRes())
|
||||||
|
val statusType = provider.statusType()
|
||||||
|
val activeText = stringResource(R.string.toolset_active_badge)
|
||||||
|
val noKeysText = stringResource(R.string.toolset_no_api_key_required)
|
||||||
|
val useBackendText = stringResource(R.string.toolset_action_use_backend)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (expanded) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.10f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// ── Header (tap to expand/collapse) ────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggleExpanded)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = provider.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
provider.badge?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
StatusBadge(text = it, status = StatusBadgeType.NEUTRAL)
|
||||||
|
}
|
||||||
|
if (provider.isActive) {
|
||||||
|
StatusBadge(text = activeText, status = StatusBadgeType.SUCCESS)
|
||||||
|
}
|
||||||
|
if (statusText.isNotEmpty()) {
|
||||||
|
StatusBadge(text = statusText, status = statusType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selecting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expanded body ──────────────────────────────────────────
|
||||||
|
if (expanded) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
provider.tag?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.isActive) {
|
||||||
|
Button(onClick = onSelectProvider, enabled = !selecting) {
|
||||||
|
Text(useBackendText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.requiresNousAuth) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.toolset_nous_signin_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.envVars.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = noKeysText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
provider.envVars.forEach { envVar ->
|
||||||
|
ToolsetEnvVarRow(
|
||||||
|
envVar = envVar,
|
||||||
|
saving = savingEnvKey == envVar.key,
|
||||||
|
deleting = deletingEnvKey == envVar.key,
|
||||||
|
revealedValue = revealedValues[envVar.key],
|
||||||
|
onSave = { value -> onSaveEnv(envVar.key, value) },
|
||||||
|
onRequestDelete = { onRequestDeleteEnv(envVar) },
|
||||||
|
onReveal = { onRevealEnv(envVar.key) },
|
||||||
|
onHide = { onHideEnv(envVar.key) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.postSetup?.let { postSetupKey ->
|
||||||
|
PostSetupRunner(
|
||||||
|
postSetupKey = postSetupKey,
|
||||||
|
installed = provider.status == "ready",
|
||||||
|
postSetup = postSetup,
|
||||||
|
onRun = { onRunPostSetup(postSetupKey) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolsetEnvVarRow(
|
||||||
|
envVar: ToolsetEnvVar,
|
||||||
|
saving: Boolean,
|
||||||
|
deleting: Boolean,
|
||||||
|
revealedValue: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onRequestDelete: () -> Unit,
|
||||||
|
onReveal: () -> Unit,
|
||||||
|
onHide: () -> Unit,
|
||||||
|
) {
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
var value by remember { mutableStateOf("") }
|
||||||
|
val setText = stringResource(R.string.toolset_env_set)
|
||||||
|
val notSetText = stringResource(R.string.toolset_env_not_set)
|
||||||
|
val saveText = stringResource(R.string.toolset_env_save)
|
||||||
|
val cancelText = stringResource(R.string.action_cancel)
|
||||||
|
val editDesc = stringResource(R.string.toolset_edit_env_desc, envVar.key)
|
||||||
|
val revealDesc = stringResource(R.string.toolset_reveal_env_desc, envVar.key)
|
||||||
|
val hideDesc = stringResource(R.string.toolset_hide_env_desc, envVar.key)
|
||||||
|
val clearDesc = stringResource(R.string.toolset_clear_env_desc, envVar.key)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = envVar.key,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
envVar.prompt?.takeIf { it.isNotBlank() && it != envVar.key }?.let {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StatusBadge(
|
||||||
|
text = if (envVar.isSet) setText else notSetText,
|
||||||
|
status = if (envVar.isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { editing = true },
|
||||||
|
enabled = !saving && !deleting,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = editDesc,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (revealedValue == null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onReveal,
|
||||||
|
enabled = envVar.isSet && !saving && !deleting,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Visibility,
|
||||||
|
contentDescription = revealDesc,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = onHide,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = hideDesc,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (envVar.isSet) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onRequestDelete,
|
||||||
|
enabled = !saving && !deleting,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
if (deleting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Delete,
|
||||||
|
contentDescription = clearDesc,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revealedValue?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { value = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(envVar.key) },
|
||||||
|
placeholder = { Text(envVar.prompt ?: envVar.key) },
|
||||||
|
visualTransformation =
|
||||||
|
if (revealedValue != null) {
|
||||||
|
VisualTransformation.None
|
||||||
|
} else {
|
||||||
|
PasswordVisualTransformation()
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
),
|
||||||
|
enabled = !saving,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(value)
|
||||||
|
value = ""
|
||||||
|
editing = false
|
||||||
|
},
|
||||||
|
enabled = !saving && value.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (saving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(saveText)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
editing = false
|
||||||
|
value = ""
|
||||||
|
},
|
||||||
|
enabled = !saving,
|
||||||
|
) {
|
||||||
|
Text(cancelText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostSetupRunner(
|
||||||
|
postSetupKey: String,
|
||||||
|
installed: Boolean,
|
||||||
|
postSetup: PostSetupState?,
|
||||||
|
onRun: () -> Unit,
|
||||||
|
) {
|
||||||
|
val hintText =
|
||||||
|
if (installed) {
|
||||||
|
stringResource(R.string.toolset_post_setup_installed_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.toolset_post_setup_hint)
|
||||||
|
}
|
||||||
|
val installedText = stringResource(R.string.toolset_post_setup_installed)
|
||||||
|
val runText = stringResource(R.string.toolset_post_setup_run)
|
||||||
|
val rerunText = stringResource(R.string.toolset_post_setup_rerun)
|
||||||
|
val runningText = stringResource(R.string.toolset_post_setup_running)
|
||||||
|
|
||||||
|
val activeState = postSetup?.takeIf { it.key == postSetupKey }
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = hintText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
activeState?.exitCode?.let { code ->
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (code == 0) {
|
||||||
|
stringResource(R.string.toolset_post_setup_done_ok, code)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.toolset_post_setup_done_fail, code)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color =
|
||||||
|
if (code == 0) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (installed && activeState == null) {
|
||||||
|
StatusBadge(text = installedText, status = StatusBadgeType.SUCCESS)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
if (activeState?.running == true) {
|
||||||
|
Button(onClick = {}, enabled = false) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(runningText)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = onRun) {
|
||||||
|
Text(if (installed) rerunText else runText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lines = activeState?.lines.orEmpty()
|
||||||
|
if (activeState?.running == true || lines.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 180.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (lines.isEmpty()) runningText else lines.joinToString("\n"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ToolsetProvider.statusTextRes(): Int =
|
||||||
|
when (status) {
|
||||||
|
"ready" -> R.string.toolset_status_ready
|
||||||
|
"needs_keys" -> R.string.toolset_status_needs_keys
|
||||||
|
"needs_auth" -> R.string.toolset_status_needs_auth
|
||||||
|
"needs_setup" -> R.string.toolset_status_needs_setup
|
||||||
|
else ->
|
||||||
|
// Older backends may omit `status` — fall back to the legacy
|
||||||
|
// env-var heuristic the desktop used before server-computed
|
||||||
|
// readiness landed.
|
||||||
|
if (envVars.isEmpty() || envVars.all { it.isSet }) {
|
||||||
|
R.string.toolset_status_ready
|
||||||
|
} else {
|
||||||
|
R.string.toolset_status_needs_keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ToolsetProvider.statusType(): StatusBadgeType =
|
||||||
|
when (status) {
|
||||||
|
"ready" -> StatusBadgeType.SUCCESS
|
||||||
|
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING
|
||||||
|
else ->
|
||||||
|
if (envVars.isEmpty() || envVars.all { it.isSet }) {
|
||||||
|
StatusBadgeType.SUCCESS
|
||||||
|
} else {
|
||||||
|
StatusBadgeType.WARNING
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package com.m57.hermescontrol.ui.toolsets
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||||
|
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetPostSetupRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProvider
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
|
||||||
|
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 kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Live state of a spawned post-setup install (mirrors the desktop log tail). */
|
||||||
|
data class PostSetupState(
|
||||||
|
val key: String,
|
||||||
|
val running: Boolean,
|
||||||
|
val lines: List<String> = emptyList(),
|
||||||
|
val exitCode: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ToolsetDetailUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val config: ToolsetConfigResponse? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
val expandedProvider: String? = null,
|
||||||
|
val selectingProvider: String? = null,
|
||||||
|
val savingEnvKey: String? = null,
|
||||||
|
val deletingEnvKey: String? = null,
|
||||||
|
val revealedValues: Map<String, String> = emptyMap(),
|
||||||
|
val postSetup: PostSetupState? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-toolset management — issue #782. Contracts verified against
|
||||||
|
* hermes-agent web_routers/tools.py; mirrors the desktop
|
||||||
|
* toolset-config-panel (provider pick, env-var save/clear/reveal, post-setup
|
||||||
|
* spawn + action-log tail). Deliberately avoids an explicit IO hop: Retrofit
|
||||||
|
* suspend calls already run off the caller thread, and skipping
|
||||||
|
* `withContext(Dispatchers.IO)` keeps unit tests free of the static
|
||||||
|
* Dispatchers mock that bleeds across test classes.
|
||||||
|
*
|
||||||
|
* NOTE: this ViewModel is shared across all toolset detail nav entries — the
|
||||||
|
* app's NavDisplay uses the activity-level ViewModelStoreOwner, so
|
||||||
|
* class-keyed `viewModel {}` returns the same instance for every entry.
|
||||||
|
* The toolset name is therefore NOT a constructor arg (it would freeze the
|
||||||
|
* first-opened toolset into every entry — bug found on-device 2026-08-06).
|
||||||
|
* The screen drives [setToolset] + [loadConfig] per entry instead.
|
||||||
|
*/
|
||||||
|
class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
||||||
|
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
|
||||||
|
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var toolsetName: String = ""
|
||||||
|
private var postSetupJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point the shared instance at [name] and drop all state from the
|
||||||
|
* previously viewed toolset (config, revealed secrets, running
|
||||||
|
* post-setup) so one toolset's data never bleeds into another.
|
||||||
|
*/
|
||||||
|
fun setToolset(name: String) {
|
||||||
|
if (name == toolsetName) return
|
||||||
|
toolsetName = name
|
||||||
|
postSetupJob?.cancel()
|
||||||
|
_uiState.value = ToolsetDetailUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.getToolsetConfig(toolsetName) }
|
||||||
|
_uiState.update { state ->
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val config = result.data
|
||||||
|
// Default the expanded provider to the one active in
|
||||||
|
// config (is_active / active_provider), else the first
|
||||||
|
// provider — claimed once so refreshes never yank the
|
||||||
|
// user's open/collapsed choice.
|
||||||
|
val defaultExpanded =
|
||||||
|
config.providers.firstOrNull { it.isActive || it.name == config.activeProvider }?.name
|
||||||
|
?: config.providers.firstOrNull()?.name
|
||||||
|
state.copy(
|
||||||
|
isLoading = false,
|
||||||
|
config = config,
|
||||||
|
errorMessage = null,
|
||||||
|
expandedProvider = if (state.config == null) defaultExpanded else state.expandedProvider,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
state.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load toolset config: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleProviderExpanded(name: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(expandedProvider = if (it.expandedProvider == name) null else name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectProvider(provider: ToolsetProvider) {
|
||||||
|
if (_uiState.value.selectingProvider != null) return
|
||||||
|
_uiState.update { it.copy(selectingProvider = provider.name) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.selectToolsetProvider(
|
||||||
|
toolsetName,
|
||||||
|
ToolsetProviderSelectRequest(provider = provider.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update { state ->
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val message =
|
||||||
|
if (result.data.needsNousAuth) {
|
||||||
|
"Backend set to ${provider.name} — needs Nous Portal sign-in to activate"
|
||||||
|
} else {
|
||||||
|
"Backend set to ${provider.name}"
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
selectingProvider = null,
|
||||||
|
config =
|
||||||
|
state.config?.let { cfg ->
|
||||||
|
cfg.copy(
|
||||||
|
activeProvider = provider.name,
|
||||||
|
providers = cfg.providers.map { it.copy(isActive = it.name == provider.name) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
toastMessage = message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
state.copy(
|
||||||
|
selectingProvider = null,
|
||||||
|
toastMessage = "Failed to select backend: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEnvVar(
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
if (value.isBlank()) return
|
||||||
|
_uiState.update { it.copy(savingEnvKey = key) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.saveToolsetEnv(
|
||||||
|
toolsetName,
|
||||||
|
ToolsetEnvUpdateRequest(env = mapOf(key to value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update { state ->
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success ->
|
||||||
|
state.copy(
|
||||||
|
savingEnvKey = null,
|
||||||
|
config = state.config?.let { patchEnvIsSet(it, key, true) },
|
||||||
|
toastMessage = "Saved $key",
|
||||||
|
)
|
||||||
|
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
state.copy(
|
||||||
|
savingEnvKey = null,
|
||||||
|
toastMessage = "Failed to save $key: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearEnvVar(key: String) {
|
||||||
|
_uiState.update { it.copy(deletingEnvKey = key) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
|
||||||
|
_uiState.update { state ->
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success ->
|
||||||
|
state.copy(
|
||||||
|
deletingEnvKey = null,
|
||||||
|
config = state.config?.let { patchEnvIsSet(it, key, false) },
|
||||||
|
revealedValues = state.revealedValues - key,
|
||||||
|
toastMessage = "Cleared $key",
|
||||||
|
)
|
||||||
|
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
state.copy(
|
||||||
|
deletingEnvKey = null,
|
||||||
|
toastMessage = "Failed to clear $key: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revealEnvVar(key: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
|
||||||
|
_uiState.update { state ->
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success ->
|
||||||
|
state.copy(revealedValues = state.revealedValues + (key to result.data.value))
|
||||||
|
|
||||||
|
is NetworkResult.Failure ->
|
||||||
|
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideEnvVar(key: String) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(revealedValues = state.revealedValues - key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the provider's post-setup install hook and tail its action log
|
||||||
|
* (the desktop PostSetupRunner pattern): poll
|
||||||
|
* GET /api/actions/{name}/status every ~1.2s until it exits, then refresh
|
||||||
|
* the config so readiness pills reflect the finished install.
|
||||||
|
*/
|
||||||
|
fun runPostSetup(key: String) {
|
||||||
|
if (_uiState.value.postSetup?.running == true) return
|
||||||
|
postSetupJob?.cancel()
|
||||||
|
_uiState.update { it.copy(postSetup = PostSetupState(key = key, running = true)) }
|
||||||
|
postSetupJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
val spawn =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.runToolsetPostSetup(
|
||||||
|
toolsetName,
|
||||||
|
ToolsetPostSetupRequest(key = key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (spawn) {
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
postSetup = null,
|
||||||
|
toastMessage = "Failed to start setup: ${spawn.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
if (spawn.data.ok != true) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
postSetup = null,
|
||||||
|
toastMessage = "Failed to start setup: spawn rejected",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val actionName = spawn.data.name ?: "tools-post-setup"
|
||||||
|
while (isActive) {
|
||||||
|
delay(1200)
|
||||||
|
val status = safeApiCall { ApiClient.hermesApi.getActionStatus(actionName) }
|
||||||
|
if (status is NetworkResult.Success) {
|
||||||
|
val data = status.data
|
||||||
|
val done = data.running != true
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
postSetup =
|
||||||
|
state.postSetup?.copy(
|
||||||
|
lines = data.lines ?: state.postSetup?.lines ?: emptyList(),
|
||||||
|
running = data.running == true,
|
||||||
|
exitCode = data.exit_code,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
loadConfig()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closePostSetup() {
|
||||||
|
postSetupJob?.cancel()
|
||||||
|
_uiState.update { it.copy(postSetup = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun patchEnvIsSet(
|
||||||
|
config: ToolsetConfigResponse,
|
||||||
|
key: String,
|
||||||
|
isSet: Boolean,
|
||||||
|
): ToolsetConfigResponse =
|
||||||
|
config.copy(
|
||||||
|
providers =
|
||||||
|
config.providers.map { provider ->
|
||||||
|
provider.copy(
|
||||||
|
envVars = provider.envVars.map { if (it.key == key) it.copy(isSet = isSet) else it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.m57.hermescontrol.ui.toolsets
|
package com.m57.hermescontrol.ui.toolsets
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -38,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Toolset
|
import com.m57.hermescontrol.ToolsetDetailKey
|
||||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -50,7 +51,6 @@ import com.m57.hermescontrol.ui.common.SkeletonListState
|
|||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,7 +62,6 @@ fun ToolsetsScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var showDetail by remember { mutableStateOf<Toolset?>(null) }
|
|
||||||
|
|
||||||
val filteredToolsets =
|
val filteredToolsets =
|
||||||
remember(query, state.toolsets) {
|
remember(query, state.toolsets) {
|
||||||
@@ -141,7 +140,19 @@ fun ToolsetsScreen(
|
|||||||
}
|
}
|
||||||
items(filteredToolsets, key = { it.name }) { toolset ->
|
items(filteredToolsets, key = { it.name }) { toolset ->
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
NavigationController.navigateTo(
|
||||||
|
ToolsetDetailKey(
|
||||||
|
name = toolset.name,
|
||||||
|
label = toolset.label,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
@@ -180,9 +191,15 @@ fun ToolsetsScreen(
|
|||||||
toolset.tools?.let { tools ->
|
toolset.tools?.let { tools ->
|
||||||
if (tools.isNotEmpty()) {
|
if (tools.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
FlowRow(
|
// Single-line chip strip — toolsets can carry a dozen+
|
||||||
|
// tools, and a wrapping FlowRow stacks chips vertically
|
||||||
|
// (huge cards on large font scales). Scroll instead.
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
tools.forEach { tool ->
|
tools.forEach { tool ->
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
@@ -191,6 +208,7 @@ fun ToolsetsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = tool,
|
text = tool,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -213,13 +231,5 @@ fun ToolsetsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showDetail?.let { toolset ->
|
|
||||||
DetailDialog(
|
|
||||||
title = toolset.label ?: toolset.name,
|
|
||||||
rows = toolset.toDetailRows(),
|
|
||||||
onDismiss = { showDetail = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,6 +524,37 @@
|
|||||||
<string name="toolsets_empty_title">No toolsets reported</string>
|
<string name="toolsets_empty_title">No toolsets reported</string>
|
||||||
<string name="toolsets_empty_desc">Verify configuration or retry</string>
|
<string name="toolsets_empty_desc">Verify configuration or retry</string>
|
||||||
|
|
||||||
|
<!-- Toolset Detail Screen (issue #782) -->
|
||||||
|
<string name="toolset_detail_no_backends">No configurable backends for this toolset</string>
|
||||||
|
<string name="toolset_status_ready">Ready</string>
|
||||||
|
<string name="toolset_status_needs_keys">Needs keys</string>
|
||||||
|
<string name="toolset_status_needs_auth">Needs sign-in</string>
|
||||||
|
<string name="toolset_status_needs_setup">Needs setup</string>
|
||||||
|
<string name="toolset_active_badge">Active</string>
|
||||||
|
<string name="toolset_action_use_backend">Use this backend</string>
|
||||||
|
<string name="toolset_nous_signin_hint">Requires Nous Portal sign-in to activate</string>
|
||||||
|
<string name="toolset_no_api_key_required">No API key required</string>
|
||||||
|
<string name="toolset_env_set">Set</string>
|
||||||
|
<string name="toolset_env_not_set">Not set</string>
|
||||||
|
<string name="toolset_env_save">Save</string>
|
||||||
|
<string name="toolset_action_delete">Delete</string>
|
||||||
|
<string name="toolset_delete_env_title">Remove key?</string>
|
||||||
|
<string name="toolset_delete_env_message">This clears %1$s from the environment.</string>
|
||||||
|
<string name="toolset_edit_env_desc">Edit %1$s</string>
|
||||||
|
<string name="toolset_reveal_env_desc">Reveal %1$s</string>
|
||||||
|
<string name="toolset_hide_env_desc">Hide %1$s</string>
|
||||||
|
<string name="toolset_clear_env_desc">Clear %1$s</string>
|
||||||
|
<string name="toolset_post_setup_run">Run setup</string>
|
||||||
|
<string name="toolset_post_setup_rerun">Re-run setup</string>
|
||||||
|
<string name="toolset_post_setup_running">Running…</string>
|
||||||
|
<string name="toolset_post_setup_installed">Installed</string>
|
||||||
|
<string name="toolset_post_setup_hint">Installs missing dependencies for this backend</string>
|
||||||
|
<string name="toolset_post_setup_installed_hint">Dependencies are installed</string>
|
||||||
|
<string name="toolset_post_setup_done_ok">Setup finished (exit %1$d)</string>
|
||||||
|
<string name="toolset_post_setup_done_fail">Setup failed (exit %1$d)</string>
|
||||||
|
<string name="toolset_web_search_active">Search: %1$s</string>
|
||||||
|
<string name="toolset_web_extract_active">Extract: %1$s</string>
|
||||||
|
|
||||||
<!-- Webhooks Screen -->
|
<!-- Webhooks Screen -->
|
||||||
<string name="webhooks_sec_status">Global Webhooks Status</string>
|
<string name="webhooks_sec_status">Global Webhooks Status</string>
|
||||||
<string name="webhooks_sec_subscriptions">Subscriptions</string>
|
<string name="webhooks_sec_subscriptions">Subscriptions</string>
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package com.m57.hermescontrol.ui.toolsets
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.ActionStatusResponse
|
||||||
|
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetEnvVar
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProvider
|
||||||
|
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #782 — per-toolset management. Contracts verified against
|
||||||
|
* hermes-agent web_routers/tools.py:
|
||||||
|
* - GET /api/tools/toolsets/{name}/config → {name, has_category, providers,
|
||||||
|
* active_provider, ...} where each provider row carries env_vars with
|
||||||
|
* is_set, post_setup key and a server-computed readiness status.
|
||||||
|
* - PUT /api/tools/toolsets/{name}/provider {provider} → {ok, name, provider}
|
||||||
|
* - PUT /api/tools/toolsets/{name}/env {env} → {ok, saved, skipped, is_set}
|
||||||
|
* - POST /api/tools/toolsets/{name}/post-setup {key} → {ok, pid, name, key},
|
||||||
|
* then tail GET /api/actions/{name}/status until running flips false.
|
||||||
|
*
|
||||||
|
* NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock bleeds
|
||||||
|
* JVM-wide and breaks later test classes. The ViewModel under test does no
|
||||||
|
* explicit IO hop (Retrofit suspend calls run off the caller thread), so
|
||||||
|
* setMain(StandardTestDispatcher) alone makes it fully deterministic.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ToolsetDetailViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var mockApi: HermesApiService
|
||||||
|
|
||||||
|
private val providerA =
|
||||||
|
ToolsetProvider(
|
||||||
|
name = "prov-a",
|
||||||
|
envVars = listOf(ToolsetEnvVar(key = "KEY_A", prompt = "Prompt A", isSet = true)),
|
||||||
|
status = "ready",
|
||||||
|
)
|
||||||
|
private val providerB =
|
||||||
|
ToolsetProvider(
|
||||||
|
name = "prov-b",
|
||||||
|
envVars = listOf(ToolsetEnvVar(key = "KEY_B", isSet = false)),
|
||||||
|
postSetup = "camofox",
|
||||||
|
isActive = true,
|
||||||
|
status = "needs_keys",
|
||||||
|
)
|
||||||
|
private val config =
|
||||||
|
ToolsetConfigResponse(
|
||||||
|
name = "web",
|
||||||
|
hasCategory = true,
|
||||||
|
providers = listOf(providerA, providerB),
|
||||||
|
activeProvider = "prov-b",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(ApiClient)
|
||||||
|
mockApi = mockk(relaxed = true)
|
||||||
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
|
coEvery { mockApi.getToolsetConfig("web") } returns Response.success(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
|
||||||
|
|
||||||
|
private fun createViewModel(): ToolsetDetailViewModel {
|
||||||
|
val vm = ToolsetDetailViewModel()
|
||||||
|
vm.setToolset("web")
|
||||||
|
vm.loadConfig()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadConfig success populates config and defaults expanded provider to active`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertFalse(state.isLoading)
|
||||||
|
assertNull(state.errorMessage)
|
||||||
|
assertEquals(config, state.config)
|
||||||
|
// The default-expanded provider mirrors the desktop panel: the
|
||||||
|
// provider actually active in config (is_active / active_provider).
|
||||||
|
assertEquals("prov-b", state.expandedProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadConfig failure surfaces error and keeps config null`() {
|
||||||
|
coEvery { mockApi.getToolsetConfig("web") } returns errorResponse(400)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertNull(state.config)
|
||||||
|
assertTrue(state.errorMessage?.contains("Failed to load toolset config") == true)
|
||||||
|
assertFalse(state.isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectProvider success patches active provider and toasts`() {
|
||||||
|
coEvery { mockApi.selectToolsetProvider("web", any()) } returns
|
||||||
|
Response.success(ToolsetProviderSelectResponse(ok = true, name = "web", provider = "prov-a"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectProvider(providerA)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals("prov-a", state.config?.activeProvider)
|
||||||
|
assertEquals(listOf(true, false), state.config?.providers?.map { it.isActive })
|
||||||
|
assertEquals("Backend set to prov-a", state.toastMessage)
|
||||||
|
assertNull(state.selectingProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectProvider failure toasts and keeps config untouched`() {
|
||||||
|
coEvery { mockApi.selectToolsetProvider("web", any()) } returns errorResponse(400)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectProvider(providerA)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals("prov-b", state.config?.activeProvider)
|
||||||
|
assertTrue(state.toastMessage?.contains("Failed to select backend") == true)
|
||||||
|
assertNull(state.selectingProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveEnvVar success marks the key set locally`() {
|
||||||
|
coEvery { mockApi.saveToolsetEnv("web", any()) } returns
|
||||||
|
Response.success(ToolsetEnvUpdateResponse(ok = true, saved = listOf("KEY_B")))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.saveEnvVar("KEY_B", "secret-value")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals(true, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
|
||||||
|
assertEquals("Saved KEY_B", state.toastMessage)
|
||||||
|
assertNull(state.savingEnvKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveEnvVar with blank value does not call the backend`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.saveEnvVar("KEY_B", " ")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { mockApi.saveToolsetEnv(any(), any()) }
|
||||||
|
assertNull(vm.uiState.value.savingEnvKey)
|
||||||
|
assertNull(vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearEnvVar success marks key unset and drops revealed value`() {
|
||||||
|
coEvery { mockApi.revealEnvVar(any()) } returns
|
||||||
|
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
|
||||||
|
coEvery { mockApi.deleteEnvVar(any()) } returns Response.success(Unit)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.revealEnvVar("KEY_B")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
|
||||||
|
|
||||||
|
vm.clearEnvVar("KEY_B")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals(false, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
|
||||||
|
assertFalse(state.revealedValues.containsKey("KEY_B"))
|
||||||
|
assertEquals("Cleared KEY_B", state.toastMessage)
|
||||||
|
assertNull(state.deletingEnvKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reveal then hide toggles revealed value`() {
|
||||||
|
coEvery { mockApi.revealEnvVar(any()) } returns
|
||||||
|
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.revealEnvVar("KEY_B")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
|
||||||
|
|
||||||
|
vm.hideEnvVar("KEY_B")
|
||||||
|
assertFalse(vm.uiState.value.revealedValues.containsKey("KEY_B"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setToolset switches config to the selected toolset and drops prior state`() {
|
||||||
|
// Regression for the on-device bug (2026-08-06): the VM is shared
|
||||||
|
// across all toolset detail nav entries, so the name must be driven
|
||||||
|
// per entry — every toolset must load ITS OWN config, and state from
|
||||||
|
// the previously viewed toolset (revealed secrets included) must not
|
||||||
|
// bleed into the next one.
|
||||||
|
val ttsConfig =
|
||||||
|
ToolsetConfigResponse(
|
||||||
|
name = "tts",
|
||||||
|
hasCategory = true,
|
||||||
|
providers = listOf(providerA.copy(isActive = false)),
|
||||||
|
)
|
||||||
|
coEvery { mockApi.getToolsetConfig("tts") } returns Response.success(ttsConfig)
|
||||||
|
coEvery { mockApi.revealEnvVar(any()) } returns
|
||||||
|
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.revealEnvVar("KEY_B")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
|
||||||
|
|
||||||
|
vm.setToolset("tts")
|
||||||
|
vm.loadConfig()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals("tts", state.config?.name)
|
||||||
|
assertTrue(state.revealedValues.isEmpty())
|
||||||
|
// Default-expand re-claims for the NEW toolset (its first provider),
|
||||||
|
// not the previous toolset's active one.
|
||||||
|
assertEquals("prov-a", state.expandedProvider)
|
||||||
|
coVerify { mockApi.getToolsetConfig("tts") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runPostSetup polls action log to completion then refreshes config`() {
|
||||||
|
coEvery { mockApi.runToolsetPostSetup("web", any()) } returns
|
||||||
|
Response.success(
|
||||||
|
ToolsetPostSetupResponse(
|
||||||
|
ok = true,
|
||||||
|
pid = 42,
|
||||||
|
name = "tools-post-setup",
|
||||||
|
key = "camofox",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
var polls = 0
|
||||||
|
coEvery { mockApi.getActionStatus("tools-post-setup") } answers {
|
||||||
|
polls++
|
||||||
|
if (polls == 1) {
|
||||||
|
Response.success(
|
||||||
|
ActionStatusResponse(
|
||||||
|
name = "tools-post-setup",
|
||||||
|
running = true,
|
||||||
|
lines = listOf("installing…"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Response.success(
|
||||||
|
ActionStatusResponse(
|
||||||
|
name = "tools-post-setup",
|
||||||
|
running = false,
|
||||||
|
exit_code = 0,
|
||||||
|
lines = listOf("installing…", "done"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coEvery { mockApi.getToolsetConfig("web") } returns Response.success(config)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.runPostSetup("camofox")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals(0, state.postSetup?.exitCode)
|
||||||
|
assertFalse(state.postSetup?.running == true)
|
||||||
|
assertEquals(listOf("installing…", "done"), state.postSetup?.lines)
|
||||||
|
// The config is refreshed once the install finishes so readiness
|
||||||
|
// pills reflect the new install state (initial load + refresh).
|
||||||
|
coVerify(exactly = 2) { mockApi.getToolsetConfig("web") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runPostSetup with rejected spawn does not poll`() {
|
||||||
|
coEvery { mockApi.runToolsetPostSetup("web", any()) } returns
|
||||||
|
Response.success(
|
||||||
|
ToolsetPostSetupResponse(
|
||||||
|
ok = false,
|
||||||
|
pid = null,
|
||||||
|
name = "tools-post-setup",
|
||||||
|
key = "camofox",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.runPostSetup("camofox")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.postSetup)
|
||||||
|
assertTrue(vm.uiState.value.toastMessage?.contains("spawn rejected") == true)
|
||||||
|
coVerify(exactly = 0) { mockApi.getActionStatus(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user