feat(#500): tap-to-reveal detail dialog for truncated item descriptions (#501)

* 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<String?,String?> -> Pair<String,String?>
  (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<DetailRow>
- 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)
This commit is contained in:
M57 authored and GitHub committed 2026-07-08 22:50:03 +03:00
1 parent 7f29cb3660
commit cbbda06157
11 files changed
+522 -13

No files matched your search

@@ -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<DetailRow>,
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()
}
}
}
}
}
}
@@ -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<DetailRow> =
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<DetailRow> =
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<DetailRow> {
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<DetailRow> =
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<DetailRow> =
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<DetailRow> =
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
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.mcp package com.m57.hermescontrol.ui.mcp
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
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
@@ -57,6 +58,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing 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.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
@@ -69,6 +71,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
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
@Composable @Composable
fun McpServersScreen( fun McpServersScreen(
@@ -79,6 +82,7 @@ fun McpServersScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<McpServer?>(null) }
val filteredServers = val filteredServers =
remember(query, state.servers) { remember(query, state.servers) {
@@ -162,7 +166,13 @@ fun McpServersScreen(
} }
items(filteredServers, key = { "server:${it.name}" }) { server -> 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 ────────────────────────────────────── // ── 4. Catalog ──────────────────────────────────────
@@ -178,6 +188,14 @@ fun McpServersScreen(
} }
} }
} }
showDetail?.let { server ->
DetailDialog(
title = server.name,
rows = server.toDetailRows(),
onDismiss = { showDetail = null },
)
}
} }
// ── Sections ────────────────────────────────────────────────────── // ── Sections ──────────────────────────────────────────────────────
@@ -279,11 +297,12 @@ private fun ServerCard(
state: McpServersUiState, state: McpServersUiState,
viewModel: McpServersViewModel, viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing, spacing: com.m57.hermescontrol.theme.Spacing,
onClick: () -> Unit,
) { ) {
var showEnv by remember { mutableStateOf(false) } var showEnv by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.plugins package com.m57.hermescontrol.ui.plugins
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.R
import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.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
@@ -60,6 +62,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
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
@@ -71,6 +74,7 @@ fun PluginsScreen(
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<PluginInfo?>(null) }
val filteredPlugins = val filteredPlugins =
remember(query, state.plugins) { remember(query, state.plugins) {
@@ -176,7 +180,12 @@ fun PluginsScreen(
// Plugin list // Plugin list
items(filteredPlugins, key = { it.name }) { plugin -> 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 // Orphan dashboard plugins section
@@ -191,7 +200,7 @@ fun PluginsScreen(
) )
} }
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -422,11 +439,12 @@ private fun PluginCard(
plugin: PluginInfo, plugin: PluginInfo,
state: PluginsUiState, state: PluginsUiState,
viewModel: PluginsViewModel, viewModel: PluginsViewModel,
onClick: () -> Unit,
) { ) {
val busy = state.rowBusy == plugin.name val busy = state.rowBusy == plugin.name
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Header row: name + toggle // Header row: name + toggle
Row( Row(
@@ -588,8 +606,11 @@ private fun PluginCard(
} }
@Composable @Composable
private fun OrphanPluginCard(plugin: PluginInfo) { private fun OrphanPluginCard(
Card(modifier = Modifier.fillMaxWidth()) { plugin: PluginInfo,
onClick: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row( Row(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills package com.m57.hermescontrol.ui.skills
import androidx.compose.foundation.clickable
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
@@ -64,6 +65,8 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow 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.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
internal const val CATEGORY_ALL = "All" internal const val CATEGORY_ALL = "All"
@@ -156,6 +160,7 @@ fun SkillsScreen(
onSearch = viewModel::searchHub, onSearch = viewModel::searchHub,
onClearSearch = viewModel::clearHubSearch, onClearSearch = viewModel::clearHubSearch,
onInstall = viewModel::installSkill, onInstall = viewModel::installSkill,
onPreviewHubSkill = viewModel::previewHubSkill,
isInstalling = state.isInstalling, isInstalling = state.isInstalling,
installingSkillName = state.installingSkillName, installingSkillName = state.installingSkillName,
) )
@@ -214,6 +219,8 @@ private fun InstalledSkillsView(
uninstallingSkillName: String?, uninstallingSkillName: String?,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
var showDetail by remember { mutableStateOf<Skill?>(null) }
val categories = val categories =
remember(state.skills) { remember(state.skills) {
state.skills state.skills
@@ -347,12 +354,21 @@ private fun InstalledSkillsView(
null null
}, },
isUninstalling = isUninstalling && uninstallingSkillName == skill.name, isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
onClick = { showDetail = skill },
) )
} }
} }
} }
} }
} }
showDetail?.let { skill ->
DetailDialog(
title = skill.name,
rows = skill.toDetailRows(),
onDismiss = { showDetail = null },
)
}
} }
// ── Skill Card ───────────────────────────────────────────────────────── // ── Skill Card ─────────────────────────────────────────────────────────
@@ -364,9 +380,10 @@ private fun SkillCard(
onAction: () -> Unit, onAction: () -> Unit,
onUninstall: (() -> Unit)?, onUninstall: (() -> Unit)?,
isUninstalling: Boolean, isUninstalling: Boolean,
onClick: () -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@@ -514,9 +531,12 @@ private fun HubBrowseView(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onClearSearch: () -> Unit, onClearSearch: () -> Unit,
onInstall: (String) -> Unit, onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean, isInstalling: Boolean,
installingSkillName: String?, installingSkillName: String?,
) { ) {
var hubShowDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField( OutlinedTextField(
value = hubQuery, value = hubQuery,
@@ -597,6 +617,10 @@ private fun HubBrowseView(
hubSkill = hubSkill, hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) }, onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == 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 ───────────────────────────────────────────────────── // ── Hub Skill Card ─────────────────────────────────────────────────────
@@ -619,9 +671,10 @@ private fun HubSkillCard(
hubSkill: HubSkill, hubSkill: HubSkill,
onInstall: () -> Unit, onInstall: () -> Unit,
isInstalling: Boolean, isInstalling: Boolean,
onClick: () -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@@ -56,6 +56,11 @@ data class SkillsUiState(
val hubResults: List<HubSkill> = emptyList(), val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false, val isHubSearching: Boolean = false,
val hubSearchError: String? = null, 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 // Hub install
val isInstalling: Boolean = false, val isInstalling: Boolean = false,
val installingSkillName: String? = null, 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 ───────────────────────────────────────────────────────── // ── Hub install ─────────────────────────────────────────────────────────
fun installSkill(identifier: String) { fun installSkill(identifier: String) {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills.components package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -31,6 +32,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -41,6 +44,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
@@ -56,9 +61,12 @@ internal fun HubBrowseView(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onClearSearch: () -> Unit, onClearSearch: () -> Unit,
onInstall: (String) -> Unit, onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean, isInstalling: Boolean,
installingSkillName: String?, installingSkillName: String?,
) { ) {
var showDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField( OutlinedTextField(
value = hubQuery, value = hubQuery,
@@ -139,6 +147,10 @@ internal fun HubBrowseView(
hubSkill = hubSkill, hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) }, onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == 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 @Composable
@@ -159,9 +199,10 @@ private fun HubSkillCard(
hubSkill: HubSkill, hubSkill: HubSkill,
onInstall: () -> Unit, onInstall: () -> Unit,
isInstalling: Boolean, isInstalling: Boolean,
onClick: () -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills.components package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.clickable
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
@@ -30,6 +31,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -44,6 +46,7 @@ import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow 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.SearchBar
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
import com.m57.hermescontrol.ui.skills.CATEGORY_ALL import com.m57.hermescontrol.ui.skills.CATEGORY_ALL
import com.m57.hermescontrol.ui.skills.SkillFilter import com.m57.hermescontrol.ui.skills.SkillFilter
import com.m57.hermescontrol.ui.skills.SkillsUiState import com.m57.hermescontrol.ui.skills.SkillsUiState
@@ -75,6 +79,8 @@ internal fun InstalledSkillsView(
uninstallingSkillName: String?, uninstallingSkillName: String?,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
var showDetail by remember { mutableStateOf<Skill?>(null) }
val categories = val categories =
remember(state.skills) { remember(state.skills) {
state.skills state.skills
@@ -208,12 +214,23 @@ internal fun InstalledSkillsView(
null null
}, },
isUninstalling = isUninstalling && uninstallingSkillName == skill.name, isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
onClick = {
showDetail = skill
},
) )
} }
} }
} }
} }
} }
showDetail?.let { skill ->
DetailDialog(
title = skill.name,
rows = skill.toDetailRows(),
onDismiss = { showDetail = null },
)
}
} }
@Composable @Composable
@@ -223,9 +240,10 @@ private fun SkillCard(
onAction: () -> Unit, onAction: () -> Unit,
onUninstall: (() -> Unit)?, onUninstall: (() -> Unit)?,
isUninstalling: Boolean, isUninstalling: Boolean,
onClick: () -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.toolsets package com.m57.hermescontrol.ui.toolsets
import androidx.compose.foundation.clickable
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
@@ -38,6 +39,8 @@ 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.R 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.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
@@ -47,6 +50,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
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
@@ -58,6 +62,7 @@ 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) {
@@ -136,7 +141,7 @@ fun ToolsetsScreen(
} }
items(filteredToolsets, key = { it.name }) { toolset -> items(filteredToolsets, key = { it.name }) { toolset ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
@@ -208,5 +213,13 @@ fun ToolsetsScreen(
} }
} }
} }
showDetail?.let { toolset ->
DetailDialog(
title = toolset.label ?: toolset.name,
rows = toolset.toDetailRows(),
onDismiss = { showDetail = null },
)
}
} }
} }
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.webhooks package com.m57.hermescontrol.ui.webhooks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -48,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.WebhookSubscription 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.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
@@ -59,6 +61,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
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
@@ -70,6 +73,7 @@ fun WebhooksScreen(
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<WebhookSubscription?>(null) }
val filteredSubscriptions = val filteredSubscriptions =
remember(query, state.subscriptions) { remember(query, state.subscriptions) {
@@ -356,6 +360,7 @@ fun WebhooksScreen(
isToggling = state.togglingName == sub.name, isToggling = state.togglingName == sub.name,
onToggle = { enabled -> viewModel.toggleSubscription(sub.name, enabled) }, onToggle = { enabled -> viewModel.toggleSubscription(sub.name, enabled) },
onDelete = { viewModel.promptDeleteSubscription(sub) }, 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 @Composable
@@ -371,11 +384,12 @@ private fun SubscriptionCard(
isToggling: Boolean, isToggling: Boolean,
onToggle: (Boolean) -> Unit, onToggle: (Boolean) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit,
) { ) {
val isEnabled = sub.enabled ?: true val isEnabled = sub.enabled ?: true
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
+25
View File
@@ -630,6 +630,31 @@
<string name="plugins_show_in_sidebar">Show in sidebar</string> <string name="plugins_show_in_sidebar">Show in sidebar</string>
<string name="plugins_hide_from_sidebar">Hide from sidebar</string> <string name="plugins_hide_from_sidebar">Hide from sidebar</string>
<!-- Shared detail dialog (issue #500) -->
<string name="detail_dialog_no_info">No additional details available.</string>
<string name="detail_dialog_category">Category</string>
<string name="detail_dialog_source">Source</string>
<string name="detail_dialog_version">Version</string>
<string name="detail_dialog_status">Status</string>
<string name="detail_dialog_description">Description</string>
<string name="detail_dialog_tags">Tags</string>
<string name="detail_dialog_transport">Transport</string>
<string name="detail_dialog_label">Label</string>
<string name="detail_dialog_url">URL</string>
<string name="detail_dialog_command">Command</string>
<string name="detail_dialog_args">Arguments</string>
<string name="detail_dialog_env_vars">Environment Variables</string>
<string name="detail_dialog_tools">Tools</string>
<string name="detail_dialog_events">Events</string>
<string name="detail_dialog_deliver">Deliver to</string>
<string name="detail_dialog_prompt">Prompt</string>
<string name="detail_dialog_skills">Skills</string>
<string name="detail_dialog_created_at">Created</string>
<string name="detail_dialog_error">Error</string>
<string name="detail_dialog_status_enabled">Enabled</string>
<string name="detail_dialog_status_disabled">Disabled</string>
<string name="detail_dialog_trust_level">Trust Level</string>
<!-- McpServers Screen --> <!-- McpServers Screen -->
<string name="mcp_servers_label_transport">Transport: %1$s</string> <string name="mcp_servers_label_transport">Transport: %1$s</string>
<string name="mcp_servers_label_command">Command:</string> <string name="mcp_servers_label_command">Command:</string>