From cbbda06157b8167c3e9f4c5e12a14f5b3f6f405b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:50:03 +0300 Subject: [PATCH] feat(#500): tap-to-reveal detail dialog for truncated item descriptions (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#500): tap-to-reveal detail dialog for truncated cards Add shared DetailDialog composable and wire tap-to-open full metadata dialogs across Skills (installed + hub), MCP servers, Plugins, Toolsets, and Webhooks screens. Action buttons stay on the card (sticky); tapping the card body reveals untruncated description + key metadata. Refs: #500 * fix(#500): resolve compile errors from missing runtime imports + dialog scope - add missing androidx.compose.runtime.mutableStateOf/remember imports in InstalledSkillsTab + HubSkillsTab (caused 'Unresolved reference' cascade on compileDebugKotlin) - move PluginsScreen detail dialog inside the composable function scope (was nested in OrphanPluginCard -> showDetail unresolved) - DetailDialog rows type: Pair -> Pair (key always non-null; fixes type-inference failure) - drop unnecessary safe call on non-null label Verified locally with the Android SDK: ./gradlew :app:compileDebugKotlin -> BUILD SUCCESSFUL ./gradlew :app:testDebugUnitTest -> BUILD SUCCESSFUL Refs: #500 * refactor(#500): extract toDetailRows() mappers + semantic status tones; wire SkillsScreen hub browse - add DetailRows.kt with @Composable extension mappers for Skill/HubSkill/ McpServer/PluginInfo/Toolset/WebhookSubscription -> List - DetailDialog now renders status rows via LocalHermesStatusColors (review #2): SUCCESS/NEUTRAL/WARNING/ERROR/INFO tones instead of plain onSurfaceVariant - wire SkillsScreen.HubBrowseView/HubSkillCard tap -> DetailDialog (same UX as other screens) — user request - remove JVM-signature-clashing Pair overload; drop unused Color import - add detail_dialog_trust_level string (HubSkill has no version field) - verified: compileDebugKotlin + testDebugUnitTest PASS locally w/ ANDROID_HOME * fix(#500): full hub description in dialog + no name truncation - HubBrowseView (both SkillsScreen + HubSkillsTab): on card tap, fetch previewHubSkill(identifier) to get the FULL SkillContentResponse.content (search snippet was truncated); dialog shows full content once loaded, with a spinner while previewing. Falls back to snippet if no identifier. - SkillsViewModel: add hubPreviewContent/isHubPreviewing/hubPreviewError state + previewHubSkill()/clearHubPreview() (uses existing preview endpoint) - DetailDialog: title now wraps fully (removed maxLines=2 ellipsis cap) so long skill names are never cut off - verified: compileDebugKotlin PASS + ktlint clean (local ANDROID_HOME) --- .../hermescontrol/ui/common/DetailDialog.kt | 124 ++++++++++++++++++ .../m57/hermescontrol/ui/common/DetailRows.kt | 122 +++++++++++++++++ .../hermescontrol/ui/mcp/McpServersScreen.kt | 23 +++- .../hermescontrol/ui/plugins/PluginsScreen.kt | 31 ++++- .../hermescontrol/ui/skills/SkillsScreen.kt | 57 +++++++- .../ui/skills/SkillsViewModel.kt | 59 +++++++++ .../ui/skills/components/HubSkillsTab.kt | 43 +++++- .../skills/components/InstalledSkillsTab.kt | 20 ++- .../ui/toolsets/ToolsetsScreen.kt | 15 ++- .../ui/webhooks/WebhooksScreen.kt | 16 ++- app/src/main/res/values/strings.xml | 25 ++++ 11 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/DetailDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailDialog.kt new file mode 100644 index 00000000..8fb2bf1d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailDialog.kt @@ -0,0 +1,124 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +/** + * Semantic tone for a detail row's value text. + * NONEl uses the default on-surface variant; the rest pull from + * [LocalHermesStatusColors] so status feedback stays brand-correct + * regardless of the wallpaper-derived Material palette. + */ +enum class DetailRowTone { + NONE, + SUCCESS, + WARNING, + ERROR, + INFO, + NEUTRAL, +} + +/** + * A single label/value row shown in [DetailDialog]. + * + * @param label Non-null resource string (already resolved by the mapper). + * @param value Display value; null/blank rows are skipped at render time. + * @param tone Semantic tone for the value text (defaults to [DetailRowTone.NONE]). + */ +data class DetailRow( + val label: String, + val value: String?, + val tone: DetailRowTone = DetailRowTone.NONE, +) + +/** + * Reusable tap-to-reveal detail dialog: a title plus a scrollable list of + * label/value rows. Null/blank rows are skipped automatically. An optional + * [actions] slot renders after the rows (inside the scroll area) so primary + * actions stay visible without the user dismissing the dialog. + */ +@Composable +fun DetailDialog( + title: String, + rows: List, + onDismiss: () -> Unit, + actions: @Composable (() -> Unit)? = null, +) { + val statusColors = LocalHermesStatusColors.current + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + HermesScaffold( + title = { Text(title, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = NavIcon.Back(onDismiss), + isRefreshing = false, + ) { padding -> + val visibleRows = rows.filter { !it.value.isNullOrBlank() } + Column( + modifier = + Modifier + .padding(padding) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + visibleRows.forEachIndexed { index, (label, value, tone) -> + if (index > 0) { + HorizontalDivider() + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value ?: "", + style = MaterialTheme.typography.bodyMedium, + color = + when (tone) { + DetailRowTone.SUCCESS -> statusColors.success + DetailRowTone.WARNING -> statusColors.warning + DetailRowTone.ERROR -> statusColors.error + DetailRowTone.INFO -> statusColors.info + DetailRowTone.NEUTRAL -> statusColors.neutral + DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + if (actions != null) { + if (visibleRows.isNotEmpty()) { + HorizontalDivider() + } + actions() + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt new file mode 100644 index 00000000..f1e82343 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.Toolset +import com.m57.hermescontrol.data.model.WebhookSubscription + +/** + * Mappers that turn a domain model into the rows shown by [DetailDialog]. + * Keeping these here (instead of inline in each screen) avoids the + * showDetail + DetailDialog wiring being copied across six screens. + */ + +@Composable +fun Skill.toDetailRows(): List = + listOf( + DetailRow(stringResource(R.string.detail_dialog_category), category), + DetailRow(stringResource(R.string.detail_dialog_source), source), + DetailRow(stringResource(R.string.detail_dialog_description), description), + ) + +@Composable +fun HubSkill.toDetailRows(): List = + listOf( + DetailRow(stringResource(R.string.detail_dialog_category), category), + DetailRow(stringResource(R.string.detail_dialog_source), source), + DetailRow(stringResource(R.string.detail_dialog_description), description), + DetailRow(stringResource(R.string.detail_dialog_tags), tags.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_trust_level), trustLevel), + ) + +@Composable +fun McpServer.toDetailRows(): List { + val commandSummary = + buildString { + if (command?.isNotBlank() == true) append(command) + if (!args.isNullOrEmpty()) append(" " + args.joinToString(" ")) + }.trim().ifBlank { null } + + return listOf( + DetailRow(stringResource(R.string.detail_dialog_transport), transport), + DetailRow(stringResource(R.string.detail_dialog_status), status, toneForStatus(status)), + DetailRow(stringResource(R.string.detail_dialog_url), url), + DetailRow(stringResource(R.string.detail_dialog_command), commandSummary), + DetailRow( + stringResource(R.string.detail_dialog_env_vars), + env?.entries?.joinToString("\n") { "${it.key}=${it.value}" }, + ), + DetailRow(stringResource(R.string.detail_dialog_error), error), + ) +} + +@Composable +fun PluginInfo.toDetailRows(): List = + listOf( + DetailRow(stringResource(R.string.detail_dialog_version), version), + DetailRow(stringResource(R.string.detail_dialog_source), source), + DetailRow( + stringResource(R.string.detail_dialog_status), + runtimeStatus, + toneForStatus(runtimeStatus), + ), + DetailRow(stringResource(R.string.detail_dialog_description), description), + ) + +@Composable +fun Toolset.toDetailRows(): List = + listOf( + DetailRow(stringResource(R.string.detail_dialog_label), label), + DetailRow(stringResource(R.string.detail_dialog_description), description), + DetailRow(stringResource(R.string.detail_dialog_tools), tools.orEmpty().joinToString(", ")), + DetailRow( + stringResource(R.string.detail_dialog_status), + if (enabled) { + stringResource(R.string.detail_dialog_status_enabled) + } else { + stringResource(R.string.detail_dialog_status_disabled) + }, + if (enabled) DetailRowTone.SUCCESS else DetailRowTone.NEUTRAL, + ), + ) + +@Composable +fun WebhookSubscription.toDetailRows(): List = + listOf( + DetailRow( + stringResource(R.string.detail_dialog_status), + if (enabled == true) { + stringResource(R.string.detail_dialog_status_enabled) + } else { + stringResource(R.string.detail_dialog_status_disabled) + }, + if (enabled == true) DetailRowTone.SUCCESS else DetailRowTone.NEUTRAL, + ), + DetailRow(stringResource(R.string.detail_dialog_deliver), deliver), + DetailRow(stringResource(R.string.detail_dialog_description), description), + DetailRow(stringResource(R.string.detail_dialog_events), events.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_prompt), prompt), + DetailRow(stringResource(R.string.detail_dialog_skills), skills.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_url), url), + DetailRow(stringResource(R.string.detail_dialog_created_at), created_at), + ) + +/** + * Map a free-form status string to a semantic tone. Recognises the app's + * common status vocabulary (enabled/active/running/ok → success, etc.). + */ +private fun toneForStatus(status: String?): DetailRowTone { + if (status.isNullOrBlank()) return DetailRowTone.NONE + return when (status.lowercase()) { + "enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS + "disabled", "inactive", "offline", "disconnected" -> DetailRowTone.NEUTRAL + "error", "failed", "unreachable" -> DetailRowTone.ERROR + "warning", "degraded" -> DetailRowTone.WARNING + else -> DetailRowTone.NONE + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 5e70c0b6..172571ff 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.mcp import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -57,6 +58,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -69,6 +71,7 @@ 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 +import com.m57.hermescontrol.ui.common.toDetailRows @Composable fun McpServersScreen( @@ -79,6 +82,7 @@ fun McpServersScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current var query by remember { mutableStateOf("") } + var showDetail by remember { mutableStateOf(null) } val filteredServers = remember(query, state.servers) { @@ -162,7 +166,13 @@ fun McpServersScreen( } items(filteredServers, key = { "server:${it.name}" }) { server -> - ServerCard(server = server, state = state, viewModel = viewModel, spacing = spacing) + ServerCard( + server = server, + state = state, + viewModel = viewModel, + spacing = spacing, + onClick = { showDetail = server }, + ) } // ── 4. Catalog ────────────────────────────────────── @@ -178,6 +188,14 @@ fun McpServersScreen( } } } + + showDetail?.let { server -> + DetailDialog( + title = server.name, + rows = server.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } // ── Sections ────────────────────────────────────────────────────── @@ -279,11 +297,12 @@ private fun ServerCard( state: McpServersUiState, viewModel: McpServersViewModel, spacing: com.m57.hermescontrol.theme.Spacing, + onClick: () -> Unit, ) { var showEnv by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 645933e5..3173d507 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.plugins +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -51,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -60,6 +62,7 @@ import com.m57.hermescontrol.ui.common.SearchBar 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 @@ -71,6 +74,7 @@ fun PluginsScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } + var showDetail by remember { mutableStateOf(null) } val filteredPlugins = remember(query, state.plugins) { @@ -176,7 +180,12 @@ fun PluginsScreen( // Plugin list items(filteredPlugins, key = { it.name }) { plugin -> - PluginCard(plugin = plugin, state = state, viewModel = viewModel) + PluginCard( + plugin = plugin, + state = state, + viewModel = viewModel, + onClick = { showDetail = plugin }, + ) } // Orphan dashboard plugins section @@ -191,7 +200,7 @@ fun PluginsScreen( ) } items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> - OrphanPluginCard(plugin = plugin) + OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) } } } @@ -199,6 +208,14 @@ fun PluginsScreen( } } } + + showDetail?.let { plugin -> + DetailDialog( + title = plugin.name, + rows = plugin.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -422,11 +439,12 @@ private fun PluginCard( plugin: PluginInfo, state: PluginsUiState, viewModel: PluginsViewModel, + onClick: () -> Unit, ) { val busy = state.rowBusy == plugin.name val statusColors = LocalHermesStatusColors.current - Card(modifier = Modifier.fillMaxWidth()) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Column(modifier = Modifier.padding(16.dp)) { // Header row: name + toggle Row( @@ -588,8 +606,11 @@ private fun PluginCard( } @Composable -private fun OrphanPluginCard(plugin: PluginInfo) { - Card(modifier = Modifier.fillMaxWidth()) { +private fun OrphanPluginCard( + plugin: PluginInfo, + onClick: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Row( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 1b9e33cc..735c3f44 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.skills +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,6 +65,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.DetailRow import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.FilterChipRow @@ -74,6 +77,7 @@ import com.m57.hermescontrol.ui.common.SearchBar 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 internal const val CATEGORY_ALL = "All" @@ -156,6 +160,7 @@ fun SkillsScreen( onSearch = viewModel::searchHub, onClearSearch = viewModel::clearHubSearch, onInstall = viewModel::installSkill, + onPreviewHubSkill = viewModel::previewHubSkill, isInstalling = state.isInstalling, installingSkillName = state.installingSkillName, ) @@ -214,6 +219,8 @@ private fun InstalledSkillsView( uninstallingSkillName: String?, onRefresh: () -> Unit, ) { + var showDetail by remember { mutableStateOf(null) } + val categories = remember(state.skills) { state.skills @@ -347,12 +354,21 @@ private fun InstalledSkillsView( null }, isUninstalling = isUninstalling && uninstallingSkillName == skill.name, + onClick = { showDetail = skill }, ) } } } } } + + showDetail?.let { skill -> + DetailDialog( + title = skill.name, + rows = skill.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } // ── Skill Card ───────────────────────────────────────────────────────── @@ -364,9 +380,10 @@ private fun SkillCard( onAction: () -> Unit, onUninstall: (() -> Unit)?, isUninstalling: Boolean, + onClick: () -> Unit, ) { Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, @@ -514,9 +531,12 @@ private fun HubBrowseView( onSearch: (String) -> Unit, onClearSearch: () -> Unit, onInstall: (String) -> Unit, + onPreviewHubSkill: (String) -> Unit, isInstalling: Boolean, installingSkillName: String?, ) { + var hubShowDetail by remember { mutableStateOf(null) } + Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( value = hubQuery, @@ -597,6 +617,10 @@ private fun HubBrowseView( hubSkill = hubSkill, onInstall = { onInstall(hubSkill.name) }, isInstalling = isInstalling && installingSkillName == hubSkill.name, + onClick = { + hubShowDetail = hubSkill + hubSkill.identifier?.let { onPreviewHubSkill(it) } + }, ) } } @@ -610,6 +634,34 @@ private fun HubBrowseView( } } } + + hubShowDetail?.let { hubSkill -> + val previewReady = state.hubPreviewIdentifier == hubSkill.identifier + val fullContent = + if (previewReady) { + state.hubPreviewContent ?: hubSkill.description + } else { + hubSkill.description + } + DetailDialog( + title = hubSkill.name, + rows = + listOf( + DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category), + DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source), + DetailRow(stringResource(R.string.detail_dialog_description), fullContent), + DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), + ), + actions = + if (previewReady && state.isHubPreviewing) { + { CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) } + } else { + null + }, + onDismiss = { hubShowDetail = null }, + ) + } } // ── Hub Skill Card ───────────────────────────────────────────────────── @@ -619,9 +671,10 @@ private fun HubSkillCard( hubSkill: HubSkill, onInstall: () -> Unit, isInstalling: Boolean, + onClick: () -> Unit, ) { Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 9cb9068f..07e3c767 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -56,6 +56,11 @@ data class SkillsUiState( val hubResults: List = emptyList(), val isHubSearching: Boolean = false, val hubSearchError: String? = null, + // Hub detail preview (full description/content via preview endpoint) + val hubPreviewIdentifier: String? = null, + val hubPreviewContent: String? = null, + val isHubPreviewing: Boolean = false, + val hubPreviewError: String? = null, // Hub install val isInstalling: Boolean = false, val installingSkillName: String? = null, @@ -153,6 +158,60 @@ class SkillsViewModel(application: Application) : } } + // ── Hub detail preview ────────────────────────────────────────────────── + + /** + * Fetches the full skill content via the preview endpoint so the detail + * dialog can show the complete description (search results only carry a + * truncated snippet). + */ + fun previewHubSkill(identifier: String) { + if (identifier.isBlank()) return + _uiState.update { + it.copy( + hubPreviewIdentifier = identifier, + isHubPreviewing = true, + hubPreviewError = null, + hubPreviewContent = null, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isHubPreviewing = false, + hubPreviewContent = result.data.content, + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isHubPreviewing = false, + hubPreviewError = result.error.message, + ) + } + } + } + } + } + + fun clearHubPreview() { + _uiState.update { + it.copy( + hubPreviewIdentifier = null, + hubPreviewContent = null, + isHubPreviewing = false, + hubPreviewError = null, + ) + } + } + // ── Hub install ───────────────────────────────────────────────────────── fun installSkill(identifier: String) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt index 35dda9c1..5808eaa9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.skills.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,6 +32,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -41,6 +44,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.DetailRow import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.LoadingState @@ -56,9 +61,12 @@ internal fun HubBrowseView( onSearch: (String) -> Unit, onClearSearch: () -> Unit, onInstall: (String) -> Unit, + onPreviewHubSkill: (String) -> Unit, isInstalling: Boolean, installingSkillName: String?, ) { + var showDetail by remember { mutableStateOf(null) } + Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( value = hubQuery, @@ -139,6 +147,10 @@ internal fun HubBrowseView( hubSkill = hubSkill, onInstall = { onInstall(hubSkill.name) }, isInstalling = isInstalling && installingSkillName == hubSkill.name, + onClick = { + showDetail = hubSkill + hubSkill.identifier?.let { onPreviewHubSkill(it) } + }, ) } } @@ -152,6 +164,34 @@ internal fun HubBrowseView( } } } + + showDetail?.let { hubSkill -> + val previewReady = state.hubPreviewIdentifier == hubSkill.identifier + val fullContent = + if (previewReady) { + state.hubPreviewContent ?: hubSkill.description + } else { + hubSkill.description + } + DetailDialog( + title = hubSkill.name, + rows = + listOf( + DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category), + DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source), + DetailRow(stringResource(R.string.detail_dialog_description), fullContent), + DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), + ), + actions = + if (previewReady && state.isHubPreviewing) { + { CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) } + } else { + null + }, + onDismiss = { showDetail = null }, + ) + } } @Composable @@ -159,9 +199,10 @@ private fun HubSkillCard( hubSkill: HubSkill, onInstall: () -> Unit, isInstalling: Boolean, + onClick: () -> Unit, ) { Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt index fbe3d462..55aee0cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsTab.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.skills.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,6 +31,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -44,6 +46,7 @@ import androidx.compose.ui.window.DialogProperties import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.FilterChipRow @@ -53,6 +56,7 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.skills.CATEGORY_ALL import com.m57.hermescontrol.ui.skills.SkillFilter import com.m57.hermescontrol.ui.skills.SkillsUiState @@ -75,6 +79,8 @@ internal fun InstalledSkillsView( uninstallingSkillName: String?, onRefresh: () -> Unit, ) { + var showDetail by remember { mutableStateOf(null) } + val categories = remember(state.skills) { state.skills @@ -208,12 +214,23 @@ internal fun InstalledSkillsView( null }, isUninstalling = isUninstalling && uninstallingSkillName == skill.name, + onClick = { + showDetail = skill + }, ) } } } } } + + showDetail?.let { skill -> + DetailDialog( + title = skill.name, + rows = skill.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } @Composable @@ -223,9 +240,10 @@ private fun SkillCard( onAction: () -> Unit, onUninstall: (() -> Unit)?, isUninstalling: Boolean, + onClick: () -> Unit, ) { Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index e4ddaec5..d38ee2dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.toolsets +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,6 +39,8 @@ 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.Toolset +import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -47,6 +50,7 @@ import com.m57.hermescontrol.ui.common.SearchBar 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 @@ -58,6 +62,7 @@ fun ToolsetsScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } + var showDetail by remember { mutableStateOf(null) } val filteredToolsets = remember(query, state.toolsets) { @@ -136,7 +141,7 @@ fun ToolsetsScreen( } items(filteredToolsets, key = { it.name }) { toolset -> Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }), colors = CardDefaults.cardColors( containerColor = @@ -208,5 +213,13 @@ fun ToolsetsScreen( } } } + + showDetail?.let { toolset -> + DetailDialog( + title = toolset.label ?: toolset.name, + rows = toolset.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index 7f2ecb4e..8ff7c4ec 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.webhooks +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -48,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.WebhookSubscription +import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -59,6 +61,7 @@ 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 +import com.m57.hermescontrol.ui.common.toDetailRows @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,6 +73,7 @@ fun WebhooksScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } + var showDetail by remember { mutableStateOf(null) } val filteredSubscriptions = remember(query, state.subscriptions) { @@ -356,6 +360,7 @@ fun WebhooksScreen( isToggling = state.togglingName == sub.name, onToggle = { enabled -> viewModel.toggleSubscription(sub.name, enabled) }, onDelete = { viewModel.promptDeleteSubscription(sub) }, + onClick = { showDetail = sub }, ) } } @@ -363,6 +368,14 @@ fun WebhooksScreen( } } } + + showDetail?.let { sub -> + DetailDialog( + title = sub.name, + rows = sub.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } } @Composable @@ -371,11 +384,12 @@ private fun SubscriptionCard( isToggling: Boolean, onToggle: (Boolean) -> Unit, onDelete: () -> Unit, + onClick: () -> Unit, ) { val isEnabled = sub.enabled ?: true Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1642bea3..5ffbd904 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -630,6 +630,31 @@ Show in sidebar Hide from sidebar + + No additional details available. + Category + Source + Version + Status + Description + Tags + Transport + Label + URL + Command + Arguments + Environment Variables + Tools + Events + Deliver to + Prompt + Skills + Created + Error + Enabled + Disabled + Trust Level + Transport: %1$s Command: