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

@@ -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<Skill?>(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<HubSkill?>(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,
@@ -56,6 +56,11 @@ data class SkillsUiState(
val hubResults: List<HubSkill> = 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) {
@@ -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<HubSkill?>(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,
@@ -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<Skill?>(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,