* 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.SettingsConnectionPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||
@@ -129,6 +130,15 @@ private fun appEntryProvider(
|
||||
viewModel = viewModel { SettingsViewModel() },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toolset detail drill-down (issue #782) ────────────────────────
|
||||
entry<ToolsetDetailKey> { key ->
|
||||
ToolsetDetailScreen(
|
||||
name = key.name,
|
||||
label = key.label,
|
||||
onBack = { NavigationController.goBack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -23,6 +23,11 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@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 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.ToggleSkillRequest
|
||||
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.UpdateCheckResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||
@@ -407,6 +414,30 @@ interface HermesApiService {
|
||||
@Body body: ToolsetToggleRequest,
|
||||
): 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")
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
@@ -38,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ToolsetDetailKey
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
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.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -62,7 +62,6 @@ fun ToolsetsScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showDetail by remember { mutableStateOf<Toolset?>(null) }
|
||||
|
||||
val filteredToolsets =
|
||||
remember(query, state.toolsets) {
|
||||
@@ -141,7 +140,19 @@ fun ToolsetsScreen(
|
||||
}
|
||||
items(filteredToolsets, key = { it.name }) { toolset ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = {
|
||||
NavigationController.navigateTo(
|
||||
ToolsetDetailKey(
|
||||
name = toolset.name,
|
||||
label = toolset.label,
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
@@ -180,9 +191,15 @@ fun ToolsetsScreen(
|
||||
toolset.tools?.let { tools ->
|
||||
if (tools.isNotEmpty()) {
|
||||
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),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
tools.forEach { tool ->
|
||||
SuggestionChip(
|
||||
@@ -191,6 +208,7 @@ fun ToolsetsScreen(
|
||||
Text(
|
||||
text = tool,
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user