From 4287723ab0c497d70c90d87c1584c6757ee39727 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:45:33 +0300 Subject: [PATCH 01/17] refactor(skills): reconcile duplicate components and drop dead category filter Use extracted components/SkillEditorDialog and SourceBadge. Drop unused CategoryFilter enum. --- .../hermescontrol/ui/skills/SkillsScreen.kt | 166 +----------------- .../ui/skills/SkillsViewModel.kt | 4 - 2 files changed, 2 insertions(+), 168 deletions(-) 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 070c4110..b1f06cf2 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 @@ -89,6 +89,8 @@ import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.components.SkillEditorDialog +import com.m57.hermescontrol.ui.skills.components.SourceBadge internal const val CATEGORY_ALL = "All" @@ -515,29 +517,6 @@ private fun SkillCard( } } } -// ── Source Badge ─────────────────────────────────────────────────────── - -@Composable -private fun SourceBadge(source: String) { - val (color, label) = - when (source) { - "hub" -> MaterialTheme.colorScheme.tertiary to "hub" - "built-in" -> MaterialTheme.colorScheme.secondary to "built-in" - "optional" -> MaterialTheme.colorScheme.outline to "opt" - else -> MaterialTheme.colorScheme.outline to source - } - Surface( - color = color.copy(alpha = 0.15f), - shape = MaterialTheme.shapes.small, - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = color, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } -} // ── Hub Browse View ───────────────────────────────────────────────────── @@ -1270,144 +1249,3 @@ fun SkillPreviewDialog( } } } - -// ── Skill Editor Dialog (existing, preserved unchanged) ──────────────── - -@Composable -fun SkillEditorDialog( - skillName: String, - isLoading: Boolean, - initialContent: String?, - isSaving: Boolean, - saveSuccess: Boolean, - onSave: (String) -> Unit, - onDismiss: () -> Unit, - onClearSaveSuccess: () -> Unit, -) { - var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) } - var showDiscardConfirm by remember { mutableStateOf(false) } - - val hasChanges = - remember(initialContent, contentText) { - val original = initialContent.orEmpty() - original != contentText - } - - LaunchedEffect(saveSuccess) { - if (saveSuccess) { - onClearSaveSuccess() - onDismiss() - } - } - - Dialog( - onDismissRequest = { - if (hasChanges) { - showDiscardConfirm = true - } else { - onDismiss() - } - }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - HermesScaffold( - title = { Text(stringResource(R.string.skills_edit_title, skillName)) }, - navigationIcon = - NavIcon.Back( - onBack = { - if (hasChanges) { - showDiscardConfirm = true - } else { - onDismiss() - } - }, - ), - actions = { - if (!isLoading) { - IconButton( - onClick = { onSave(contentText) }, - enabled = !isSaving, - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.fillMaxSize(0.6f), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - Icon( - imageVector = Icons.Filled.Save, - contentDescription = stringResource(R.string.content_desc_save_changes), - ) - } - } - } - }, - ) { padding -> - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - when { - isLoading -> { - LoadingState() - } - - else -> { - OutlinedTextField( - value = contentText, - onValueChange = { contentText = it }, - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - textStyle = - TextStyle( - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - ), - placeholder = { - Text(stringResource(R.string.skills_content_placeholder)) - }, - ) - } - } - } - } - } - } - - if (showDiscardConfirm) { - AlertDialog( - onDismissRequest = { showDiscardConfirm = false }, - title = { Text(stringResource(R.string.skills_discard_title)) }, - text = { Text(stringResource(R.string.skills_discard_message)) }, - confirmButton = { - TextButton( - onClick = { - showDiscardConfirm = false - onDismiss() - }, - ) { - Text(stringResource(R.string.action_discard)) - } - }, - dismissButton = { - TextButton(onClick = { showDiscardConfirm = false }) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } -} 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 40dad9e7..a7dc2215 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 @@ -39,10 +39,6 @@ enum class SkillFilter( DISABLED(R.string.skills_status_disabled), } -enum class CategoryFilter { - ALL, -} - data class SkillsUiState( val isLoading: Boolean = false, val skills: List = emptyList(), From f0c2d2f87b58a16b55c50192d3e765947a320a55 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:48:13 +0300 Subject: [PATCH 02/17] refactor(util): extract FormatUtils and deduplicate byte formatting Consolidate formatBytes and formatDuration into util/FormatUtils. Use in SystemScreen and MemoryScreen, backed by unit tests. --- .../hermescontrol/ui/memory/MemoryScreen.kt | 8 +-- .../hermescontrol/ui/system/SystemScreen.kt | 23 +------- .../com/m57/hermescontrol/util/FormatUtils.kt | 28 ++++++++++ .../m57/hermescontrol/util/FormatUtilsTest.kt | 54 +++++++++++++++++++ 4 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index b85fa06b..a8d9d5ce 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType +import com.m57.hermescontrol.util.formatBytes /** * Memory management home — owns the memory surface that used to live in the @@ -425,10 +426,3 @@ private fun MemoryProviderRow( } } } - -private fun formatBytes(bytes: Long): String = - when { - bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) - bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0) - else -> "$bytes B" - } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 523f862d..e8a36ea1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -96,31 +96,12 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.system.components.CredentialEntryRow import com.m57.hermescontrol.ui.system.components.HookCard +import com.m57.hermescontrol.util.formatBytes +import com.m57.hermescontrol.util.formatDuration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -// ── Helpers ───────────────────────────────────────────────────────────── - -private fun formatBytes(bytes: Long): String = - when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "${bytes / 1024} KB" - bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" - else -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" - } - -private fun formatDuration(totalSeconds: Double): String { - val days = (totalSeconds / 86400).toInt() - val hours = ((totalSeconds % 86400) / 3600).toInt() - val minutes = ((totalSeconds % 3600) / 60).toInt() - return buildString { - if (days > 0) append("${days}d ") - if (hours > 0 || days > 0) append("${hours}h ") - append("${minutes}m") - } -} - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun SystemScreen( diff --git a/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt b/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt new file mode 100644 index 00000000..37cc926f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.util + +import java.util.Locale + +/** + * Formats a byte count into human-readable B, KB, MB, or GB. + */ +fun formatBytes(bytes: Long): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" + else -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / (1024 * 1024 * 1024)) + } + +/** + * Formats seconds into a human-readable duration (e.g. "2d 4h 15m"). + */ +fun formatDuration(totalSeconds: Double): String { + val days = (totalSeconds / 86400).toInt() + val hours = ((totalSeconds % 86400) / 3600).toInt() + val minutes = ((totalSeconds % 3600) / 60).toInt() + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0 || days > 0) append("${hours}h ") + append("${minutes}m") + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt b/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt new file mode 100644 index 00000000..1e2297a5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormatUtilsTest { + @Test + fun testFormatBytes_bytes() { + assertEquals("0 B", formatBytes(0L)) + assertEquals("500 B", formatBytes(500L)) + assertEquals("1023 B", formatBytes(1023L)) + } + + @Test + fun testFormatBytes_kilobytes() { + assertEquals("1 KB", formatBytes(1024L)) + assertEquals("2 KB", formatBytes(2048L)) + assertEquals("1023 KB", formatBytes(1024 * 1024 - 1L)) + } + + @Test + fun testFormatBytes_megabytes() { + assertEquals("1 MB", formatBytes(1024 * 1024L)) + assertEquals("500 MB", formatBytes(500 * 1024 * 1024L)) + } + + @Test + fun testFormatBytes_gigabytes() { + assertEquals("1.0 GB", formatBytes(1024 * 1024 * 1024L)) + assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong())) + } + + @Test + fun testFormatDuration_minutes() { + assertEquals("0m", formatDuration(0.0)) + assertEquals("0m", formatDuration(45.0)) + assertEquals("1m", formatDuration(60.0)) + assertEquals("59m", formatDuration(3540.0)) + } + + @Test + fun testFormatDuration_hoursAndMinutes() { + assertEquals("1h 0m", formatDuration(3600.0)) + assertEquals("1h 1m", formatDuration(3660.0)) + assertEquals("23h 59m", formatDuration(86399.0)) + } + + @Test + fun testFormatDuration_daysHoursMinutes() { + assertEquals("1d 0h 0m", formatDuration(86400.0)) + assertEquals("1d 1h 1m", formatDuration(90060.0)) + assertEquals("3d 5h 30m", formatDuration(3 * 86400.0 + 5 * 3600.0 + 30 * 60.0)) + } +} From 6876db039335bfbef91caae53ff23aad89441715 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:53:02 +0300 Subject: [PATCH 03/17] refactor(sessions): extract search formatting and paging logic Extract SessionSearchFormatting and SessionsPaging helpers. Deduplicate paging calculation in SessionsViewModel and add unit tests. --- .../ui/sessions/SessionSearchFormatting.kt | 98 +++++++++++++++++++ .../ui/sessions/SessionsPaging.kt | 42 ++++++++ .../ui/sessions/SessionsScreen.kt | 94 ------------------ .../ui/sessions/SessionsViewModel.kt | 38 +++++-- .../sessions/SessionSearchFormattingTest.kt | 72 ++++++++++++++ .../ui/sessions/SessionsPagingTest.kt | 75 ++++++++++++++ .../ui/sessions/SessionsViewModelTest.kt | 8 -- 7 files changed, 315 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt new file mode 100644 index 00000000..1f3d4e41 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionSearchResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.math.abs + +/** + * Converts a backend search hit into a display model WITHOUT faking a title. + * The backend returns no session name, only a matched `snippet` + metadata, so the + * card must render the snippet as a match excerpt (never as the title). `title` is + * deliberately left null so [SearchResultCard] shows the honest "Match" layout. + */ +internal fun SessionSearchResult.toSessionInfo(): SessionInfo = + SessionInfo( + id = session_id, + title = null, + preview = snippet, + source = source, + model = model, + started_at = session_started, + // message_count/status aren't in the search payload; leave null so the + // search card hides those normal-list affordances. + ) + +/** + * Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute + * string. Used for search results where the session name is unknown. + */ +internal fun formatPlayedAt(epochSeconds: Double?): String? { + if (epochSeconds == null || epochSeconds <= 0.0) return null + val instant = + try { + Instant.ofEpochSecond(epochSeconds.toLong()) + } catch (_: Exception) { + return null + } + val now = Instant.now() + val diffSec = abs(now.epochSecond - instant.epochSecond) + val absFormatter = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + return when { + diffSec < 60 -> "just now" + diffSec < 3600 -> "${diffSec / 60}m ago" + diffSec < 86400 -> "${diffSec / 3600}h ago" + diffSec < 7 * 86400 -> "${diffSec / 86400}d ago" + else -> absFormatter.format(instant) + } +} + +private val searchSnippetJson = Json { isLenient = true } + +/** + * Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as + * JSON, so prefer their human-readable text fields rather than rendering the entire payload. + */ +fun cleanSearchSnippet(snippet: String): String { + val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim() + val parsedSnippet = + runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull() + val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers + return displayText + .replace(Regex("\\s+"), " ") + .trim() +} + +private fun JsonElement.searchText(): String? = + when (this) { + is JsonPrimitive -> { + contentOrNull?.takeIf(String::isNotBlank) + } + + is JsonArray -> { + mapNotNull { it.searchText() } + .joinToString(" ") + .takeIf(String::isNotBlank) + } + + is JsonObject -> { + val textFieldNames = + listOf("content", "text", "message", "body", "prompt", "summary", "title") + val wrapperFieldNames = listOf("data", "result", "payload", "parts") + textFieldNames + .firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } + ?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt new file mode 100644 index 00000000..7467fd79 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.ui.sessions + +data class SessionPaginationResolution( + val hasMore: Boolean, + val total: Int, +) + +object SessionsPaging { + /** + * Computes hasMore and effective total when refreshing or loading the initial page of sessions. + */ + fun resolveInitialPaging( + receivedCount: Int, + pageSize: Int, + backendTotal: Int, + accumulatedCount: Int, + ): SessionPaginationResolution { + val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount + return SessionPaginationResolution( + hasMore = hasMore, + total = if (hasMore) backendTotal else accumulatedCount, + ) + } + + /** + * Computes hasMore and effective total when appending a page of sessions. + */ + fun resolveLoadMorePaging( + receivedCount: Int, + pageSize: Int, + addedNewItems: Boolean, + backendTotal: Int, + accumulatedCount: Int, + ): SessionPaginationResolution { + val receivedFullPage = receivedCount >= pageSize + val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount + return SessionPaginationResolution( + hasMore = hasMore, + total = if (hasMore) backendTotal else accumulatedCount, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0e400ca6..bd912149 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,17 +116,6 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import kotlin.math.abs /** * Auto-load the next history page when the user scrolls to within this many @@ -197,89 +186,6 @@ private fun highlightText( } } -/** - * Converts a backend search hit into a display model WITHOUT faking a title. - * The backend returns no session name, only a matched `snippet` + metadata, so the - * card must render the snippet as a match excerpt (never as the title). `title` is - * deliberately left null so [SearchResultCard] shows the honest "Match" layout. - */ -private fun SessionSearchResult.toSessionInfo(): SessionInfo = - SessionInfo( - id = session_id, - title = null, - preview = snippet, - source = source, - model = model, - started_at = session_started, - // message_count/status aren't in the search payload; leave null so the - // search card hides those normal-list affordances. - ) - -/** - * Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute - * string. Used for search results where the session name is unknown. - */ -private fun formatPlayedAt(epochSeconds: Double?): String? { - if (epochSeconds == null || epochSeconds <= 0.0) return null - val instant = - try { - Instant.ofEpochSecond(epochSeconds.toLong()) - } catch (_: Exception) { - return null - } - val now = Instant.now() - val diffSec = abs(now.epochSecond - instant.epochSecond) - val absFormatter = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.MEDIUM) - .withZone(ZoneId.systemDefault()) - return when { - diffSec < 60 -> "just now" - diffSec < 3600 -> "${diffSec / 60}m ago" - diffSec < 86400 -> "${diffSec / 3600}h ago" - diffSec < 7 * 86400 -> "${diffSec / 86400}d ago" - else -> absFormatter.format(instant) - } -} - -private val searchSnippetJson = Json { isLenient = true } - -/** - * Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as - * JSON, so prefer their human-readable text fields rather than rendering the entire payload. - */ -internal fun cleanSearchSnippet(snippet: String): String { - val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim() - val parsedSnippet = - runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull() - val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers - return displayText - .replace(Regex("\\s+"), " ") - .trim() -} - -private fun JsonElement.searchText(): String? = - when (this) { - is JsonPrimitive -> { - contentOrNull?.takeIf(String::isNotBlank) - } - - is JsonArray -> { - mapNotNull { it.searchText() } - .joinToString(" ") - .takeIf(String::isNotBlank) - } - - is JsonObject -> { - val textFieldNames = - listOf("content", "text", "message", "body", "prompt", "summary", "title") - val wrapperFieldNames = listOf("data", "result", "payload", "parts") - textFieldNames - .firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } - ?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } - } - } - private fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { state.searchResults.map { searchResult -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 26da29e3..0dc6f2b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -147,11 +147,17 @@ class SessionsViewModel : rawPaginationOffset = data.nextOffset(0) _uiState.update { val newSessions = data.sessions.orEmpty().pinnedFirst() - val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size + val paging = + SessionsPaging.resolveInitialPaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + backendTotal = data.total, + accumulatedCount = newSessions.size, + ) it.copy( sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } @@ -229,14 +235,20 @@ class SessionsViewModel : if (requestGeneration != generation) return@safeLaunchLoad rawPaginationOffset = data.nextOffset(0) val sessionsList = data.sessions.orEmpty().pinnedFirst() - val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size + val paging = + SessionsPaging.resolveInitialPaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + backendTotal = data.total, + accumulatedCount = sessionsList.size, + ) _uiState.update { it.copy( isLoading = false, isLoadingMore = false, sessions = sessionsList, - total = if (hasMore) data.total else sessionsList.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, selectedIds = emptySet(), ) } @@ -284,14 +296,20 @@ class SessionsViewModel : (it.sessions + data.sessions) .distinctBy { s -> s.id } .pinnedFirst() - val receivedFullPage = data.sessions.size >= PAGE_SIZE val addedNewItems = newSessions.size > it.sessions.size - val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size + val paging = + SessionsPaging.resolveLoadMorePaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + addedNewItems = addedNewItems, + backendTotal = data.total, + accumulatedCount = newSessions.size, + ) it.copy( isLoadingMore = false, sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt new file mode 100644 index 00000000..89c8ed16 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt @@ -0,0 +1,72 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionSearchResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionSearchFormattingTest { + @Test + fun `clean search snippet strips highlight markers from plain text`() { + assertEquals( + "Find the deployment logs", + cleanSearchSnippet(">>>Find<<< the deployment logs"), + ) + } + + @Test + fun `clean search snippet extracts text from JSON payload`() { + assertEquals( + "Find the deployment logs", + cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"), + ) + } + + @Test + fun `clean search snippet extracts nested text array`() { + assertEquals( + "First part second part", + cleanSearchSnippet("{\"parts\":[\">>>First<<< part\",\"second part\"]}"), + ) + } + + @Test + fun `clean search snippet normalizes whitespace`() { + assertEquals( + "hello world", + cleanSearchSnippet(" hello world \n "), + ) + } + + @Test + fun `toSessionInfo leaves title null for match layout`() { + val searchResult = + SessionSearchResult( + session_id = "test-123", + session_started = 1700000000.0, + snippet = "matched query excerpt", + source = "cli", + model = "test-model", + ) + val info = searchResult.toSessionInfo() + assertEquals("test-123", info.id) + assertNull(info.title) + assertEquals("matched query excerpt", info.preview) + assertEquals("cli", info.source) + assertEquals("test-model", info.model) + } + + @Test + fun `formatPlayedAt handles null and negative epoch`() { + assertNull(formatPlayedAt(null)) + assertNull(formatPlayedAt(0.0)) + assertNull(formatPlayedAt(-100.0)) + } + + @Test + fun `formatPlayedAt returns formatted string for valid timestamp`() { + val result = formatPlayedAt(1700000000.0) + assertNotNull(result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt new file mode 100644 index 00000000..d8932bf3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionsPagingTest { + @Test + fun `resolveInitialPaging hasMore when received full page and total exceeds accumulated`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 50, + pageSize = 50, + backendTotal = 120, + accumulatedCount = 50, + ) + assertTrue(resolution.hasMore) + assertEquals(120, resolution.total) + } + + @Test + fun `resolveInitialPaging no more when received less than page size`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 30, + pageSize = 50, + backendTotal = 30, + accumulatedCount = 30, + ) + assertFalse(resolution.hasMore) + assertEquals(30, resolution.total) + } + + @Test + fun `resolveInitialPaging no more when backendTotal equals accumulated`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 50, + pageSize = 50, + backendTotal = 50, + accumulatedCount = 50, + ) + assertFalse(resolution.hasMore) + assertEquals(50, resolution.total) + } + + @Test + fun `resolveLoadMorePaging hasMore when full page added new items and total exceeds accumulated`() { + val resolution = + SessionsPaging.resolveLoadMorePaging( + receivedCount = 50, + pageSize = 50, + addedNewItems = true, + backendTotal = 150, + accumulatedCount = 100, + ) + assertTrue(resolution.hasMore) + assertEquals(150, resolution.total) + } + + @Test + fun `resolveLoadMorePaging no more when no new items added`() { + val resolution = + SessionsPaging.resolveLoadMorePaging( + receivedCount = 50, + pageSize = 50, + addedNewItems = false, + backendTotal = 150, + accumulatedCount = 100, + ) + assertFalse(resolution.hasMore) + assertEquals(100, resolution.total) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 9d2df0e1..1176f89c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -92,14 +92,6 @@ class SessionsViewModelTest { ) } - @Test - fun `clean search snippet extracts text from JSON payload`() { - assertEquals( - "Find the deployment logs", - cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"), - ) - } - @Test fun `sections request conversations excluding cron and automations from cron`() { val vm = createViewModel() From 9eaf9663f306101bbd8f0224429bde225a28f7e9 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:58:41 +0300 Subject: [PATCH 04/17] refactor(skills): extract SkillListFilter and SkillScanPresentation Separate pure skill filtering and scan presentation helpers. Wire into SkillsScreen with unit test coverage. --- .../ui/skills/SkillListFilter.kt | 39 ++++++ .../ui/skills/SkillScanPresentation.kt | 75 ++++++++++ .../hermescontrol/ui/skills/SkillsScreen.kt | 69 ++-------- .../ui/skills/SkillListFilterTest.kt | 130 ++++++++++++++++++ .../ui/skills/SkillScanPresentationTest.kt | 64 +++++++++ 5 files changed, 323 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt new file mode 100644 index 00000000..7f30c87f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.ui.skills + +import com.m57.hermescontrol.data.model.Skill + +object SkillListFilter { + /** + * Filters skills by search query, enabled/disabled status, category, and source. + */ + fun filterSkills( + skills: List, + query: String, + selectedStatus: SkillFilter, + selectedCategory: String?, + sourceFilter: String?, + ): List = + skills.filter { skill -> + val matchesQuery = + query.isBlank() || + skill.name.contains(query, ignoreCase = true) || + skill.description?.contains(query, ignoreCase = true) == true + val matchesStatus = + when (selectedStatus) { + SkillFilter.ALL_STATUSES -> true + SkillFilter.ENABLED -> skill.enabled + SkillFilter.DISABLED -> !skill.enabled + } + val matchesCategory = + selectedCategory == null || + selectedCategory == CATEGORY_ALL || + skill.category == selectedCategory + val matchesSource = sourceFilter == null || skill.source == sourceFilter + + matchesQuery && matchesStatus && matchesCategory && matchesSource + } + + fun extractCategories(skills: List): List = skills.mapNotNull { it.category }.distinct().sorted() + + fun extractSources(skills: List): List = skills.mapNotNull { it.source }.distinct().sorted() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt new file mode 100644 index 00000000..b9726261 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.skills + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors + +enum class ScanVerdictKind { + SAFE, + DANGEROUS, + WARNING, +} + +enum class ScanPolicyKind { + ALLOW, + ASK, + BLOCK, + OTHER, +} + +object SkillScanPresentation { + fun resolveVerdictKind(verdict: String?): ScanVerdictKind = + when (verdict) { + "safe" -> ScanVerdictKind.SAFE + "dangerous" -> ScanVerdictKind.DANGEROUS + else -> ScanVerdictKind.WARNING + } + + fun resolveVerdictColor( + verdict: String?, + statusColors: HermesStatusColors, + ): Color = + when (resolveVerdictKind(verdict)) { + ScanVerdictKind.SAFE -> statusColors.success + ScanVerdictKind.DANGEROUS -> statusColors.error + ScanVerdictKind.WARNING -> statusColors.warning + } + + fun resolvePolicyKind(policy: String?): ScanPolicyKind = + when (policy) { + "allow" -> ScanPolicyKind.ALLOW + "ask" -> ScanPolicyKind.ASK + "block" -> ScanPolicyKind.BLOCK + else -> ScanPolicyKind.OTHER + } + + fun resolvePolicyLabelRes(policy: String?): Int? = + when (policy) { + "allow" -> R.string.skills_hub_scan_policy_allow + "ask" -> R.string.skills_hub_scan_policy_ask + "block" -> R.string.skills_hub_scan_policy_block + else -> null + } + + fun resolvePolicyColor( + policy: String?, + statusColors: HermesStatusColors, + ): Color = + when (policy) { + "allow" -> statusColors.success + "ask" -> statusColors.warning + "block" -> statusColors.error + else -> statusColors.neutral + } + + fun resolveSeverityColor( + severity: String?, + statusColors: HermesStatusColors, + ): Color = + when (severity) { + "critical", "high" -> statusColors.error + "medium" -> statusColors.warning + "low" -> statusColors.neutral + else -> statusColors.neutral + } +} 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 b1f06cf2..8c52332d 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 @@ -256,34 +256,22 @@ private fun InstalledSkillsView( val categories = remember(state.skills) { - state.skills - .mapNotNull { it.category } - .distinct() - .sorted() + SkillListFilter.extractCategories(state.skills) } val sources = remember(state.skills) { - state.skills - .mapNotNull { it.source } - .distinct() - .sorted() + SkillListFilter.extractSources(state.skills) } val filteredSkills = remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { - state.skills.filter { skill -> - ( - query.isBlank() || skill.name.contains(query, ignoreCase = true) || - skill.description?.contains(query, ignoreCase = true) == true - ) && - ( - selectedStatus == SkillFilter.ALL_STATUSES || - (selectedStatus == SkillFilter.ENABLED && skill.enabled) || - (selectedStatus == SkillFilter.DISABLED && !skill.enabled) - ) && - (selectedCategory == CATEGORY_ALL || skill.category == selectedCategory) && - (sourceFilter == null || skill.source == sourceFilter) - } + SkillListFilter.filterSkills( + skills = state.skills, + query = query, + selectedStatus = selectedStatus, + selectedCategory = selectedCategory, + sourceFilter = sourceFilter, + ) } Column(modifier = Modifier.fillMaxSize()) { @@ -891,26 +879,11 @@ private fun SkillScanSection( @Composable private fun ScanResultCard(result: SkillScanResponse) { val statusColors = LocalHermesStatusColors.current - val verdictColor = - when (result.verdict) { - "safe" -> statusColors.success - "dangerous" -> statusColors.error - else -> statusColors.warning - } + val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) val policyLabel = - when (result.policy) { - "allow" -> stringResource(R.string.skills_hub_scan_policy_allow) - "ask" -> stringResource(R.string.skills_hub_scan_policy_ask) - "block" -> stringResource(R.string.skills_hub_scan_policy_block) - else -> result.policy.orEmpty() - } - val policyColor = - when (result.policy) { - "allow" -> statusColors.success - "ask" -> statusColors.warning - "block" -> statusColors.error - else -> statusColors.neutral - } + SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } + ?: result.policy.orEmpty() + val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) Card( modifier = Modifier.fillMaxWidth(), @@ -979,13 +952,6 @@ private fun ScanResultCard(result: SkillScanResponse) { } // ── Severity tally ── - val severityColors = - mapOf( - "critical" to statusColors.error, - "high" to statusColors.error, - "medium" to statusColors.warning, - "low" to statusColors.neutral, - ) Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -993,7 +959,7 @@ private fun ScanResultCard(result: SkillScanResponse) { listOf("critical", "high", "medium", "low").forEach { sev -> val count = result.severityCounts[sev] ?: 0 if (count > 0) { - val sevColor = severityColors[sev] ?: statusColors.neutral + val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) Surface( color = sevColor.copy(alpha = 0.15f), shape = RoundedCornerShape(50), @@ -1039,12 +1005,7 @@ private fun ScanResultCard(result: SkillScanResponse) { @Composable private fun ScanFindingRow(finding: SkillScanFinding) { val statusColors = LocalHermesStatusColors.current - val severityColor = - when (finding.severity) { - "critical", "high" -> statusColors.error - "medium" -> statusColors.warning - else -> statusColors.neutral - } + val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt new file mode 100644 index 00000000..54ef1626 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.ui.skills + +import com.m57.hermescontrol.data.model.Skill +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SkillListFilterTest { + private val sampleSkills = + listOf( + Skill( + name = "code-review", + category = "dev", + description = "Automated code review assistant", + enabled = true, + source = "built-in", + ), + Skill( + name = "deploy-bot", + category = "ops", + description = "Deploy to production environments", + enabled = false, + source = "hub", + ), + Skill( + name = "image-gen", + category = "creative", + description = "Generate creative assets", + enabled = true, + source = "hub", + ), + ) + + @Test + fun `filterSkills empty query and all statuses returns all`() { + val result = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(3, result.size) + } + + @Test + fun `filterSkills filters by search query on name and description`() { + val nameResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "code", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, nameResult.size) + assertEquals("code-review", nameResult[0].name) + + val descResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "production", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, descResult.size) + assertEquals("deploy-bot", descResult[0].name) + } + + @Test + fun `filterSkills filters by enabled and disabled status`() { + val enabledResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ENABLED, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(2, enabledResult.size) + assertTrue(enabledResult.all { it.enabled }) + + val disabledResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.DISABLED, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, disabledResult.size) + assertEquals("deploy-bot", disabledResult[0].name) + } + + @Test + fun `filterSkills filters by category and source`() { + val catResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = "ops", + sourceFilter = null, + ) + assertEquals(1, catResult.size) + assertEquals("deploy-bot", catResult[0].name) + + val sourceResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = "built-in", + ) + assertEquals(1, sourceResult.size) + assertEquals("code-review", sourceResult[0].name) + } + + @Test + fun `extractCategories and extractSources return distinct sorted values`() { + val categories = SkillListFilter.extractCategories(sampleSkills) + assertEquals(listOf("creative", "dev", "ops"), categories) + + val sources = SkillListFilter.extractSources(sampleSkills) + assertEquals(listOf("built-in", "hub"), sources) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt new file mode 100644 index 00000000..a0ee5e2a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.ui.skills + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SkillScanPresentationTest { + private val dummyColors = + HermesStatusColors( + success = Color(0xFF00FF00), + successContainer = Color(0xFF003300), + onSuccess = Color.White, + warning = Color(0xFFFFFF00), + warningContainer = Color(0xFF333300), + onWarning = Color.Black, + error = Color(0xFFFF0000), + errorContainer = Color(0xFF330000), + onError = Color.White, + info = Color(0xFF0000FF), + infoContainer = Color(0xFF000033), + onInfo = Color.White, + neutral = Color.Gray, + ) + + @Test + fun `resolveVerdictKind maps safe dangerous and fallback`() { + assertEquals(ScanVerdictKind.SAFE, SkillScanPresentation.resolveVerdictKind("safe")) + assertEquals(ScanVerdictKind.DANGEROUS, SkillScanPresentation.resolveVerdictKind("dangerous")) + assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind("unknown")) + assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind(null)) + } + + @Test + fun `resolveVerdictColor returns corresponding semantic color`() { + assertEquals(dummyColors.success, SkillScanPresentation.resolveVerdictColor("safe", dummyColors)) + assertEquals(dummyColors.error, SkillScanPresentation.resolveVerdictColor("dangerous", dummyColors)) + assertEquals(dummyColors.warning, SkillScanPresentation.resolveVerdictColor("suspicious", dummyColors)) + } + + @Test + fun `resolvePolicyKind and labels map allow ask block and fallback`() { + assertEquals(ScanPolicyKind.ALLOW, SkillScanPresentation.resolvePolicyKind("allow")) + assertEquals(ScanPolicyKind.ASK, SkillScanPresentation.resolvePolicyKind("ask")) + assertEquals(ScanPolicyKind.BLOCK, SkillScanPresentation.resolvePolicyKind("block")) + assertEquals(ScanPolicyKind.OTHER, SkillScanPresentation.resolvePolicyKind("custom")) + + assertEquals(R.string.skills_hub_scan_policy_allow, SkillScanPresentation.resolvePolicyLabelRes("allow")) + assertEquals(R.string.skills_hub_scan_policy_ask, SkillScanPresentation.resolvePolicyLabelRes("ask")) + assertEquals(R.string.skills_hub_scan_policy_block, SkillScanPresentation.resolvePolicyLabelRes("block")) + assertNull(SkillScanPresentation.resolvePolicyLabelRes("other")) + } + + @Test + fun `resolveSeverityColor maps critical high medium low and fallback`() { + assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("critical", dummyColors)) + assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("high", dummyColors)) + assertEquals(dummyColors.warning, SkillScanPresentation.resolveSeverityColor("medium", dummyColors)) + assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("low", dummyColors)) + assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("info", dummyColors)) + } +} From 4a93772b93017b1e0cb2ae985b32ac50af7a3be2 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:00:47 +0300 Subject: [PATCH 05/17] refactor(mcp): extract AddServerRequestBuilder and McpImportSummary Extract request building and import summary formatting helpers. Wire into McpServersViewModel with unit test coverage. --- .../ui/mcp/AddServerRequestBuilder.kt | 56 +++++++++++ .../hermescontrol/ui/mcp/McpImportSummary.kt | 18 ++++ .../ui/mcp/McpServersViewModel.kt | 41 +------- .../ui/mcp/AddServerRequestBuilderTest.kt | 95 +++++++++++++++++++ .../ui/mcp/McpImportSummaryTest.kt | 24 +++++ 5 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt new file mode 100644 index 00000000..4ab3070f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.model.AddMcpServerRequest + +object AddServerRequestBuilder { + /** + * Constructs a validated AddMcpServerRequest from the add-server form parameters. + */ + fun build( + name: String, + mode: AddServerMode, + url: String, + command: String, + args: String, + auth: String, + bearerToken: String, + ): AddMcpServerRequest { + val isHttp = mode == AddServerMode.HTTP + val isStdio = mode == AddServerMode.Stdio + + val resolvedUrl = if (isHttp) url.trim().ifBlank { null } else null + val resolvedCommand = if (isStdio) command.trim().ifBlank { null } else null + val resolvedArgs = + if (isStdio && args.isNotBlank()) { + args.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + } else { + null + } + val resolvedAuth = if (isHttp && auth != "none") auth else null + val resolvedBearerToken = + if (isHttp && auth == "header") bearerToken.trim().ifBlank { null } else null + + return AddMcpServerRequest( + name = name.trim(), + url = resolvedUrl, + command = resolvedCommand, + args = resolvedArgs, + auth = resolvedAuth, + bearerToken = resolvedBearerToken, + ) + } + + /** + * Overload reading form parameters directly from McpServersUiState. + */ + fun build(state: McpServersUiState): AddMcpServerRequest = + build( + name = state.addServerName, + mode = state.addMode, + url = state.addServerUrl, + command = state.addServerCommand, + args = state.addServerArgs, + auth = state.addServerAuth, + bearerToken = state.addServerBearerToken, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt new file mode 100644 index 00000000..8155137e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt @@ -0,0 +1,18 @@ +package com.m57.hermescontrol.ui.mcp + +object McpImportSummary { + /** + * Formats the human-readable summary message reporting the result of a batch MCP JSON import. + */ + fun formatSummary( + successCount: Int, + errors: List, + ): String { + val errSummary = errors.joinToString("; ") + return when { + errors.isEmpty() -> "Successfully imported $successCount server(s)" + successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary" + else -> "Failed to import: $errSummary" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 4d1d119e..4e9ead31 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -297,13 +297,7 @@ class McpServersViewModel : } _uiState.update { state -> - val errSummary = errors.joinToString("; ") - val msg = - when { - errors.isEmpty() -> "Successfully imported $successCount server(s)" - successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary" - else -> "Failed to import: $errSummary" - } + val msg = McpImportSummary.formatSummary(successCount, errors) state.copy( isImportingJson = false, showImportDialog = false, @@ -357,38 +351,7 @@ class McpServersViewModel : } _uiState.update { it.copy(addingServer = true) } viewModelScope.launch { - val request = - AddMcpServerRequest( - name = state.addServerName.trim(), - url = if (state.addMode == AddServerMode.HTTP) state.addServerUrl.trim().ifBlank { null } else null, - command = - if (state.addMode == AddServerMode.Stdio) { - state.addServerCommand.trim().ifBlank { null } - } else { - null - }, - args = - if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) { - state.addServerArgs - .trim() - .split("\\s+".toRegex()) - .filter { it.isNotEmpty() } - } else { - null - }, - auth = - if (state.addMode == AddServerMode.HTTP && state.addServerAuth != "none") { - state.addServerAuth - } else { - null - }, - bearerToken = - if (state.addMode == AddServerMode.HTTP && state.addServerAuth == "header") { - state.addServerBearerToken.trim().ifBlank { null } - } else { - null - }, - ) + val request = AddServerRequestBuilder.build(state) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.addMcpServer(request) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt new file mode 100644 index 00000000..36094914 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt @@ -0,0 +1,95 @@ +package com.m57.hermescontrol.ui.mcp + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AddServerRequestBuilderTest { + @Test + fun `build HTTP mode creates request with url and auth`() { + val request = + AddServerRequestBuilder.build( + name = " weather-server ", + mode = AddServerMode.HTTP, + url = " https://api.weather.com/mcp ", + command = "ignored-cmd", + args = "ignored-arg", + auth = "header", + bearerToken = " secret-token ", + ) + assertEquals("weather-server", request.name) + assertEquals("https://api.weather.com/mcp", request.url) + assertNull(request.command) + assertNull(request.args) + assertEquals("header", request.auth) + assertEquals("secret-token", request.bearerToken) + } + + @Test + fun `build HTTP mode with none auth nulls auth and bearerToken`() { + val request = + AddServerRequestBuilder.build( + name = "public-server", + mode = AddServerMode.HTTP, + url = "https://example.com/mcp", + command = "", + args = "", + auth = "none", + bearerToken = "should-be-ignored", + ) + assertEquals("public-server", request.name) + assertEquals("https://example.com/mcp", request.url) + assertNull(request.auth) + assertNull(request.bearerToken) + } + + @Test + fun `build Stdio mode creates request with command and parsed args`() { + val request = + AddServerRequestBuilder.build( + name = "local-tools", + mode = AddServerMode.Stdio, + url = "https://ignored.com", + command = " python3 ", + args = " -m mcp_server --debug ", + auth = "header", + bearerToken = "token", + ) + assertEquals("local-tools", request.name) + assertNull(request.url) + assertEquals("python3", request.command) + assertEquals(listOf("-m", "mcp_server", "--debug"), request.args) + assertNull(request.auth) + assertNull(request.bearerToken) + } + + @Test + fun `build Stdio mode with empty args nulls args`() { + val request = + AddServerRequestBuilder.build( + name = "npx-server", + mode = AddServerMode.Stdio, + url = "", + command = "npx server", + args = " ", + auth = "none", + bearerToken = "", + ) + assertEquals("npx server", request.command) + assertNull(request.args) + } + + @Test + fun `build from McpServersUiState populates fields correctly`() { + val state = + McpServersUiState( + addServerName = "state-server", + addMode = AddServerMode.HTTP, + addServerUrl = "https://state.com", + addServerAuth = "none", + ) + val request = AddServerRequestBuilder.build(state) + assertEquals("state-server", request.name) + assertEquals("https://state.com", request.url) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt new file mode 100644 index 00000000..d0ba2738 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt @@ -0,0 +1,24 @@ +package com.m57.hermescontrol.ui.mcp + +import org.junit.Assert.assertEquals +import org.junit.Test + +class McpImportSummaryTest { + @Test + fun `formatSummary returns success message when zero errors`() { + val result = McpImportSummary.formatSummary(3, emptyList()) + assertEquals("Successfully imported 3 server(s)", result) + } + + @Test + fun `formatSummary returns partial success message when some failed`() { + val result = McpImportSummary.formatSummary(2, listOf("serverA: timeout", "serverB: 404")) + assertEquals("Imported 2 server(s), 2 failed: serverA: timeout; serverB: 404", result) + } + + @Test + fun `formatSummary returns failure message when all failed`() { + val result = McpImportSummary.formatSummary(0, listOf("serverA: connection refused")) + assertEquals("Failed to import: serverA: connection refused", result) + } +} From a83f4725a2d3415fa736b32c5ea4118080a39b3d Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:12:58 +0300 Subject: [PATCH 06/17] refactor(chat): extract ChatTranscriptMerge helpers from ChatViewModel Extract tool result canonicalization, logical message equality, attachment ref stripping, and transcript merge logic from ChatViewModel. --- .../ui/chat/ChatTranscriptMerge.kt | 218 ++++++++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 211 ----------------- 2 files changed, 218 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt new file mode 100644 index 00000000..a485a53e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -0,0 +1,218 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Canonical comparison key for a tool message's result payload. + * + * WS tool messages store the full tool.complete payload + * (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST + * transcript rows store just the result object (`{"output":..., "exit_code":...}`). + * This key normalizes both sides — preferring the `result` field when present, + * and treating int/float JSON numbers as equal — so the two representations of + * the SAME tool call can be matched regardless of position or pagination. + * Returns null for unparseable content (no match possible). + */ +internal fun canonicalToolResultKey(content: String): String? { + val element = + try { + OkHttpProvider.json.parseToJsonElement(content) + } catch (_: Exception) { + return null + } + + fun canon(e: JsonElement): String = + when (e) { + is JsonObject -> { + e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" } + } + + is JsonArray -> { + e.joinToString(",") { canon(it) } + } + + is JsonPrimitive -> { + // Canonicalize ALL numbers through double, collapsing int/float + // spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5"). + val s = e.content + val d = s.toDoubleOrNull() + if (d != null) { + if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d" + } else { + "s$s" + } + } + } + return when (element) { + is JsonObject -> { + element["result"]?.let { canon(it) } ?: canon(element) + } + + else -> { + canon(element) + } + } +} + +/** + * True when [a] and [b] are the same logical message (the WS-persisted and + * REST-persisted copies of one row — they carry different ids, see #771). + * Tool messages match on their normalized result payload; other roles on + * exact content, IGNORING leading/trailing whitespace. + * + * Issue #842: the app seals the RAW streamed text (which can carry leading + * blank lines the model emits before its narration), while the backend + * persists a CLEANED copy (leading whitespace stripped). An exact-content + * compare made the reload merge treat them as different messages and add + * the REST copy on top — the commentary duplicated ~10s after the stream + * ended. Trim closes the drift: the sealed live bubble is covered by the + * REST row and the duplicate never renders. + */ +internal fun sameLogicalMessage( + a: ChatMessage, + b: ChatMessage, +): Boolean { + if (a.role != b.role) return false + if (a.role == MessageRole.TOOL) { + // Issue #842: prefer the gateway's tool call id when both sides carry + // it — the REST transcript stores `tool_call_id` and the live WS + // bubble keeps it from `tool.start`. Content canonicalization cannot + // cover MCP/web tools: the REST side stores the payload as raw + // `` text (not JSON), so it has no key at all. + if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { + return a.toolCallId == b.toolCallId + } + val ka = canonicalToolResultKey(a.content) + val kb = canonicalToolResultKey(b.content) + return ka != null && ka == kb + } + val ta = a.content.trim() + val tb = b.content.trim() + if (ta == tb) return true + // Attachment dedupe: when the user sends an image/file, the backend + // persists an ENRICHED copy of the prompt — the real caption plus + // `@image:`/`@file:` reference lines and a `[screenshot]` marker + // (tui_gateway _build_image_ref_message / run_agent flattening). The + // optimistic bubble carries the raw caption only, so the exact compare + // above fails and the REST sync renders the same message twice. Compare + // USER rows on the caption with injection lines stripped (both sides — + // stripping a clean string is a no-op). USER-only so assistant text that + // legitimately mentions these tokens is never collapsed. + if (a.role == MessageRole.USER) { + val ca = stripAttachmentRefLines(ta) + val cb = stripAttachmentRefLines(tb) + if (ca == cb) return true + } + // Issue #842: a seal race can leave the live orphan a few trailing tokens + // short of the streamed narration (the last delta was still in the + // throttled buffer when tool.start sealed the message). The backend + // persists the COMPLETE copy, so the orphan is a strict prefix of the + // REST row. Accept prefix-covering only for substantial texts (>=40 + // chars) so a short reply can never be swallowed by a longer message + // that merely starts with it. + return ta.length >= 40 && + tb.length >= 40 && + (tb.startsWith(ta) || ta.startsWith(tb)) +} + +/** + * Remove backend attachment-injection lines from a user message so the + * optimistic bubble and the server-enriched REST copy compare equal. + * Lines the gateway adds on top of the user's caption: `@image:`, + * `@file:`, and `[screenshot]` (multipart image placeholder written by + * run_agent). Blank lines left behind are dropped too. + */ +internal fun stripAttachmentRefLines(content: String): String = + content + .lines() + .map { it.trim() } + .filterNot { line -> + line.startsWith("@image:") || + line.startsWith("@file:") || + line == "[screenshot]" + }.joinToString("\n") + .trim() + +/** + * Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload, + * tool name) and the REST-persisted copy (`rest-` id, result-only payload, + * no tool name) of every message. Painting the cache verbatim renders the + * same call twice. Drop the `rest-` copy whenever a WS copy of the same + * logical message exists (issue #771). + */ +internal fun dedupeCachedMessages(messages: List): List { + val rest = messages.filter { it.id.startsWith("rest-") } + if (rest.isEmpty()) return messages + val nonRest = messages.filterNot { it.id.startsWith("rest-") } + if (nonRest.isEmpty()) return messages + val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } + return (nonRest + keepRest).sortedBy { it.timestamp } +} + +/** + * A transcript reload must NOT yank live WS bubbles the server has not + * persisted yet. The gateway stores a tool row only once the tool + * COMPLETES server-side, so a reload that lands while a tool is running + * (app background/foreground mid-turn, reconnect re-resume, pull-refresh) + * returns a page without the tool row — replacing the list outright made + * the in-flight tool bubble vanish and left tool.complete with no RUNNING + * message to update (issue #771). + * + * Merge instead of replace: append any current message the REST page does + * not already cover (checked by id AND logical content via + * [sameLogicalMessage]) and keep chronological order. + */ +internal fun mergeTranscriptWithLive( + restMessages: List, + currentMessages: List, +): List { + val dedupedRest = restMessages.dedupeById() + val dedupedCurrent = currentMessages.dedupeById() + // User rows can carry live/cache-only metadata (for example whether the + // bubble continues the active turn). Keep that richer copy when REST + // returns the same logical row. + val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() + val mergedRest = + dedupedRest.map { rest -> + if (rest.role != MessageRole.USER) return@map rest + val matchIndex = + currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } + ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } + if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest + } + val restIds = dedupedRest.map { it.id }.toSet() + val liveTail = + dedupedCurrent.filter { old -> + old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } + } + if (liveTail.isEmpty()) return mergedRest.dedupeById() + return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } +} + +/** Merge a REST page without matching it against already-settled transcript rows. */ +internal fun mergeIncrementalTranscriptPage( + restMessages: List, + currentMessages: List, + sessionId: String, + offset: Int, +): List { + val settledEnd = + currentMessages.indexOfLast { message -> + serverMessageIndex(message.id, sessionId)?.let { it < offset } == true + } + return ( + currentMessages.take(settledEnd + 1) + + mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) + ).dedupeById() +} + +internal fun serverMessageIndex( + id: String, + sessionId: String, +): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() + +internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 828a53a9..551faf95 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -70,217 +70,6 @@ private sealed interface PrepareAttachmentResult { data object Unreadable : PrepareAttachmentResult } -/** - * Canonical comparison key for a tool message's result payload. - * - * WS tool messages store the full tool.complete payload - * (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST - * transcript rows store just the result object (`{"output":..., "exit_code":...}`). - * This key normalizes both sides — preferring the `result` field when present, - * and treating int/float JSON numbers as equal — so the two representations of - * the SAME tool call can be matched regardless of position or pagination. - * Returns null for unparseable content (no match possible). - */ -internal fun canonicalToolResultKey(content: String): String? { - val element = - try { - OkHttpProvider.json.parseToJsonElement(content) - } catch (_: Exception) { - return null - } - - fun canon(e: kotlinx.serialization.json.JsonElement): String = - when (e) { - is kotlinx.serialization.json.JsonObject -> { - e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" } - } - - is kotlinx.serialization.json.JsonArray -> { - e.joinToString(",") { canon(it) } - } - - is kotlinx.serialization.json.JsonPrimitive -> { - // Canonicalize ALL numbers through double, collapsing int/float - // spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5"). - val s = e.content - val d = s.toDoubleOrNull() - if (d != null) { - if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d" - } else { - "s$s" - } - } - } - return when (element) { - is kotlinx.serialization.json.JsonObject -> { - element["result"]?.let { canon(it) } ?: canon(element) - } - - else -> { - canon(element) - } - } -} - -/** - * True when [a] and [b] are the same logical message (the WS-persisted and - * REST-persisted copies of one row — they carry different ids, see #771). - * Tool messages match on their normalized result payload; other roles on - * exact content, IGNORING leading/trailing whitespace. - * - * Issue #842: the app seals the RAW streamed text (which can carry leading - * blank lines the model emits before its narration), while the backend - * persists a CLEANED copy (leading whitespace stripped). An exact-content - * compare made the reload merge treat them as different messages and add - * the REST copy on top — the commentary duplicated ~10s after the stream - * ended. Trim closes the drift: the sealed live bubble is covered by the - * REST row and the duplicate never renders. - */ -internal fun sameLogicalMessage( - a: ChatMessage, - b: ChatMessage, -): Boolean { - if (a.role != b.role) return false - if (a.role == MessageRole.TOOL) { - // Issue #842: prefer the gateway's tool call id when both sides carry - // it — the REST transcript stores `tool_call_id` and the live WS - // bubble keeps it from `tool.start`. Content canonicalization cannot - // cover MCP/web tools: the REST side stores the payload as raw - // `` text (not JSON), so it has no key at all. - if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { - return a.toolCallId == b.toolCallId - } - val ka = canonicalToolResultKey(a.content) - val kb = canonicalToolResultKey(b.content) - return ka != null && ka == kb - } - val ta = a.content.trim() - val tb = b.content.trim() - if (ta == tb) return true - // Attachment dedupe: when the user sends an image/file, the backend - // persists an ENRICHED copy of the prompt — the real caption plus - // `@image:`/`@file:` reference lines and a `[screenshot]` marker - // (tui_gateway _build_image_ref_message / run_agent flattening). The - // optimistic bubble carries the raw caption only, so the exact compare - // above fails and the REST sync renders the same message twice. Compare - // USER rows on the caption with injection lines stripped (both sides — - // stripping a clean string is a no-op). USER-only so assistant text that - // legitimately mentions these tokens is never collapsed. - if (a.role == MessageRole.USER) { - val ca = stripAttachmentRefLines(ta) - val cb = stripAttachmentRefLines(tb) - if (ca == cb) return true - } - // Issue #842: a seal race can leave the live orphan a few trailing tokens - // short of the streamed narration (the last delta was still in the - // throttled buffer when tool.start sealed the message). The backend - // persists the COMPLETE copy, so the orphan is a strict prefix of the - // REST row. Accept prefix-covering only for substantial texts (>=40 - // chars) so a short reply can never be swallowed by a longer message - // that merely starts with it. - return ta.length >= 40 && - tb.length >= 40 && - (tb.startsWith(ta) || ta.startsWith(tb)) -} - -/** - * Remove backend attachment-injection lines from a user message so the - * optimistic bubble and the server-enriched REST copy compare equal. - * Lines the gateway adds on top of the user's caption: `@image:`, - * `@file:`, and `[screenshot]` (multipart image placeholder written by - * run_agent). Blank lines left behind are dropped too. - */ -internal fun stripAttachmentRefLines(content: String): String = - content - .lines() - .map { it.trim() } - .filterNot { line -> - line.startsWith("@image:") || - line.startsWith("@file:") || - line == "[screenshot]" - }.joinToString("\n") - .trim() - -/** - * Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload, - * tool name) and the REST-persisted copy (`rest-` id, result-only payload, - * no tool name) of every message. Painting the cache verbatim renders the - * same call twice. Drop the `rest-` copy whenever a WS copy of the same - * logical message exists (issue #771). - */ -internal fun dedupeCachedMessages(messages: List): List { - val rest = messages.filter { it.id.startsWith("rest-") } - if (rest.isEmpty()) return messages - val nonRest = messages.filterNot { it.id.startsWith("rest-") } - if (nonRest.isEmpty()) return messages - val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } - return (nonRest + keepRest).sortedBy { it.timestamp } -} - -/** - * A transcript reload must NOT yank live WS bubbles the server has not - * persisted yet. The gateway stores a tool row only once the tool - * COMPLETES server-side, so a reload that lands while a tool is running - * (app background/foreground mid-turn, reconnect re-resume, pull-refresh) - * returns a page without the tool row — replacing the list outright made - * the in-flight tool bubble vanish and left tool.complete with no RUNNING - * message to update (issue #771). - * - * Merge instead of replace: append any current message the REST page does - * not already cover (checked by id AND logical content via - * [sameLogicalMessage]) and keep chronological order. - */ -internal fun mergeTranscriptWithLive( - restMessages: List, - currentMessages: List, -): List { - val dedupedRest = restMessages.dedupeById() - val dedupedCurrent = currentMessages.dedupeById() - // User rows can carry live/cache-only metadata (for example whether the - // bubble continues the active turn). Keep that richer copy when REST - // returns the same logical row. - val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() - val mergedRest = - dedupedRest.map { rest -> - if (rest.role != MessageRole.USER) return@map rest - val matchIndex = - currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } - ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } - if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest - } - val restIds = dedupedRest.map { it.id }.toSet() - val liveTail = - dedupedCurrent.filter { old -> - old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } - } - if (liveTail.isEmpty()) return mergedRest.dedupeById() - return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } -} - -/** Merge a REST page without matching it against already-settled transcript rows. */ -internal fun mergeIncrementalTranscriptPage( - restMessages: List, - currentMessages: List, - sessionId: String, - offset: Int, -): List { - val settledEnd = - currentMessages.indexOfLast { message -> - serverMessageIndex(message.id, sessionId)?.let { it < offset } == true - } - return ( - currentMessages.take(settledEnd + 1) + - mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) - ).dedupeById() -} - -private fun serverMessageIndex( - id: String, - sessionId: String, -): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() - -internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() - data class ChatUiState( val messages: List = emptyList(), val currentSessionId: String? = null, From 70f331b9311b8cc4fedcb8d0fa608fa412c5c05e Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:19:47 +0300 Subject: [PATCH 07/17] refactor(markdown): extract MarkdownModels and MarkdownParser Split AST block models and zero-Compose Markdown block parser into ui/chat/markdown/ package. Wire into MarkdownText. --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 650 +----------------- .../ui/chat/markdown/MarkdownModels.kt | 99 +++ .../ui/chat/markdown/MarkdownParser.kt | 539 +++++++++++++++ .../ui/chat/MarkdownTextBenchmark.kt | 1 + .../ui/chat/MarkdownTextFeatureTest.kt | 4 + 5 files changed, 649 insertions(+), 644 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 71d8c628..ffb7b3a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -61,15 +61,16 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.FnNote +import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath import com.m57.hermescontrol.util.BidiUtils private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val TABLE_COL_WIDTH = 160.dp -private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") -private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""") -private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") -private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") -private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr @@ -508,32 +509,6 @@ fun MarkdownText( } } -/** - * Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL - * (gateway-served media the phone can reach), a `data:image/...;base64,...` - * data URL (e.g. agent-delivered inline media), or any other resolvable model - * string (Coil handles all three). Bare `![]()` with empty uri is skipped. - */ -private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""") - -private fun tryParseImage(line: String): MdBlock.Image? { - val m = IMAGE_RE.matchAt(line, 0) ?: return null - val uri = m.groupValues[2].trim() - if (uri.isEmpty()) return null - return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim()) -} - -private fun tryParseVideo(line: String): MdBlock.Video? { - val m = IMAGE_RE.matchAt(line, 0) ?: return null - val uri = m.groupValues[2].trim() - if (uri.isEmpty()) return null - val ext = uri.substringAfterLast('.', "").lowercase() - if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) { - return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim()) - } - return null -} - @Composable private fun MarkdownInlineText( text: String, @@ -702,531 +677,6 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? = else -> null } -internal sealed interface InlineMathSegment { - data class Text( - val value: String, - ) : InlineMathSegment - - data class Math( - val latex: String, - ) : InlineMathSegment -} - -/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */ -internal fun splitInlineMath(text: String): List { - val segments = mutableListOf() - var plainStart = 0 - var i = 0 - - while (i < text.length) { - if (text[i] == '`' && !text.isEscaped(i)) { - val codeEnd = text.indexOf('`', i + 1) - i = if (codeEnd == -1) text.length else codeEnd + 1 - continue - } - if (text.startsWith("\\(", i)) { - val end = text.indexOf("\\)", i + 2) - if (end != -1 && end > i + 2) { - if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) - segments.add(InlineMathSegment.Math(text.substring(i + 2, end))) - i = end + 2 - plainStart = i - continue - } - } - if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') { - i++ - continue - } - - var end = i + 1 - while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++ - if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) { - i++ - continue - } - - if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) - segments.add(InlineMathSegment.Math(text.substring(i + 1, end))) - i = end + 1 - plainStart = i - } - - if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart))) - return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) } -} - -private fun String.isEscaped(index: Int): Boolean { - var slashes = 0 - var i = index - 1 - while (i >= 0 && this[i] == '\\') { - slashes++ - i-- - } - return slashes % 2 == 1 -} - -private fun tryParseDisplayMath( - lines: List, - start: Int, -): Pair? { - val first = lines[start].trim() - if (first.startsWith("\\[")) { - if (first.length > 4 && first.endsWith("\\]")) { - val latex = first.substring(2, first.length - 2).trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } - } - if (first != "\\[") return null - val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null - val latex = lines.subList(start + 1, end).joinToString("\n").trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } - } - if (!first.startsWith("$$")) return null - if (first.length > 4 && first.endsWith("$$")) { - val latex = first.substring(2, first.length - 2).trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } - } - if (first != "$$") return null - - val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null - val latex = lines.subList(start + 1, end).joinToString("\n").trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } -} - -/** - * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first; - * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. - */ -internal fun parseBlocks(src: String): List { - val lines = src.lines() - val blocks = mutableListOf() - val footnotes = mutableListOf() - var i = 0 - - while (i < lines.size) { - val line = lines[i] - - // Footnote definition: [^id]: text (collected, not rendered inline) - val fnMatch = FN_DEF_RE.matchAt(line, 0) - if (fnMatch != null) { - footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2])) - i++ - continue - } - - val displayMath = tryParseDisplayMath(lines, i) - if (displayMath != null) { - blocks.add(displayMath.first) - i = displayMath.second - continue - } - - val codeFence = parseCodeFenceStart(line) - when { - codeFence != null -> { - val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) } - if (end != null) { - blocks.add( - MdBlock.Code( - code = lines.subList(i + 1, end).joinToString("\n"), - language = codeFence.language, - ), - ) - i = end + 1 - } else { - blocks.add( - MdBlock.Code( - code = lines.subList(i + 1, lines.size).joinToString("\n"), - language = codeFence.language, - ), - ) - i = lines.size - } - } - - line.isBlank() -> { - i++ - } - - isHorizontalRule(line) -> { - blocks.add(MdBlock.Hr) - i++ - } - - // Heading: requires a space after the '#' run so "#572" stays a paragraph - isValidHeading(line) -> { - val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) - blocks.add(MdBlock.Heading(level, line.substring(level).trim())) - i++ - } - - isTableStart(lines, i) -> { - val (header, alignments, body) = parseTable(lines, i) - blocks.add(MdBlock.Table(header, alignments, body)) - i += body.size + 2 - } - - line.startsWith(">") -> { - val quote = mutableListOf() - while (i < lines.size && lines[i].startsWith(">")) { - quote.add(lines[i].removePrefix(">").trim()) - i++ - } - blocks.add(MdBlock.Quote(quote.joinToString("\n"))) - } - - // Definition list: term line followed by one+ ": definition" lines - isDefListStart(lines, i) -> { - val term = line.trim() - val defs = mutableListOf() - i++ - while (i < lines.size && lines[i].trim().startsWith(":")) { - defs.add(lines[i].trim().removePrefix(":").trim()) - i++ - } - blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) - } - - LIST_ITEM_LINE_RE.matches(line) -> { - val (listBlocks, nextIndex) = parseList(lines, i) - blocks.addAll(listBlocks) - i = nextIndex - } - - // Standalone Markdown image: ![alt](uri) on its own line. - // Inline images inside a paragraph are left as-is (rendered as text) - // to keep scope tight; standalone is the common agent-media case. - line.isNotBlank() && tryParseVideo(line) != null -> { - blocks.add(tryParseVideo(line)!!) - i++ - } - - line.isNotBlank() && tryParseImage(line) != null -> { - blocks.add(tryParseImage(line)!!) - i++ - } - - else -> { - i = fallthroughToParagraph(lines, i, blocks) - } - } - } - - if (footnotes.isNotEmpty()) { - blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) })) - } - return blocks -} - -private sealed interface ParsedItem { - val indent: Int - val continuationLines: MutableList - - data class Bullet( - override val indent: Int, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem - - data class Task( - override val indent: Int, - val checked: Boolean, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem - - data class Ordered( - override val indent: Int, - val rawNumber: Int, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem -} - -private fun computeIndent(prefix: String): Int { - var count = 0 - for (ch in prefix) { - if (ch == '\t') { - count += 4 - (count % 4) - } else { - count++ - } - } - return count -} - -private fun parseList( - lines: List, - startIndex: Int, -): Pair, Int> { - val items = mutableListOf() - var j = startIndex - - while (j < lines.size) { - val line = lines[j] - - val taskMatch = TASK_LINE_RE.matchEntire(line) - val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null - val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null - - if (taskMatch != null) { - val indent = computeIndent(taskMatch.groupValues[1]) - val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true) - val text = taskMatch.groupValues[3] - items.add(ParsedItem.Task(indent, checked, text)) - j++ - } else if (bulletMatch != null) { - val indent = computeIndent(bulletMatch.groupValues[1]) - val text = bulletMatch.groupValues[2] - items.add(ParsedItem.Bullet(indent, text)) - j++ - } else if (orderedMatch != null) { - val indent = computeIndent(orderedMatch.groupValues[1]) - val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1 - val text = orderedMatch.groupValues[3] - items.add(ParsedItem.Ordered(indent, num, text)) - j++ - } else if (line.isBlank()) { - // Check if there is a following list item - var lookahead = j + 1 - while (lookahead < lines.size && lines[lookahead].isBlank()) { - lookahead++ - } - if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { - j = lookahead - } else { - break - } - } else { - // Check if this is a continuation line for the previous list item - val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' }) - if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) { - items.last().continuationLines.add(line.trim()) - j++ - } else { - break - } - } - } - - if (items.isEmpty()) { - return emptyList() to j - } - - val blocks = mutableListOf() - - // Track state of list items and their relative nesting depth using an indent stack - val indentStack = mutableListOf() // maps level (index) -> base indent - var lastItemWasOrdered = false - var currentSequenceNumber = 1 - var lastLevel = -1 - - for (item in items) { - // Resolve level using the indent stack - val level: Int - if (indentStack.isEmpty() || item.indent == 0) { - indentStack.clear() - indentStack.add(item.indent) - level = 0 - } else if (item.indent > indentStack.last()) { - indentStack.add(item.indent) - level = indentStack.size - 1 - } else if (item.indent == indentStack.last()) { - level = indentStack.size - 1 - } else { - // Unwind stack to the matching or nearest smaller indent - while (indentStack.size > 1 && indentStack.last() > item.indent) { - indentStack.removeAt(indentStack.size - 1) - } - level = indentStack.size - 1 - } - - val fullText = - if (item.continuationLines.isEmpty()) { - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - } else { - val base = - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - base + " " + item.continuationLines.joinToString(" ") - } - - when (item) { - is ParsedItem.Bullet -> { - blocks.add(MdBlock.Bullet(fullText, level = level)) - lastItemWasOrdered = false - lastLevel = level - } - - is ParsedItem.Task -> { - blocks.add(MdBlock.Task(item.checked, fullText, level = level)) - lastItemWasOrdered = false - lastLevel = level - } - - is ParsedItem.Ordered -> { - val indexToUse = - if (level == lastLevel && lastItemWasOrdered) { - if (item.rawNumber > currentSequenceNumber) { - currentSequenceNumber = item.rawNumber - } else { - currentSequenceNumber++ - } - currentSequenceNumber - } else { - currentSequenceNumber = item.rawNumber - currentSequenceNumber - } - blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) - lastItemWasOrdered = true - lastLevel = level - } - } - } - - return blocks to j -} - -private fun isHorizontalRule(line: String): Boolean { - val trimmed = line.trim() - if (trimmed.length < 3) return false - val c = trimmed[0] - return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c } -} - -private fun isTableStart( - lines: List, - i: Int, -): Boolean { - val l = lines[i] - if (!l.contains('|')) return false - if (i + 1 >= lines.size) return false - return isTableSeparator(lines[i + 1]) -} - -private fun isTableSeparator(line: String): Boolean { - val t = line.trim().trim('|') - if (t.isEmpty()) return false - // every cell must be only dashes/colons/spaces, with at least one dash - return t.split('|').all { cell -> - val c = cell.trim() - c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' } - } -} - -private fun parseTable( - lines: List, - i: Int, -): Triple, List, List>> { - val header = splitRow(lines[i]) - val alignments = - splitRow(lines[i + 1]).map { cell -> - val c = cell.trim() - when { - c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER - c.endsWith(":") -> TableAlign.RIGHT - c.startsWith(":") -> TableAlign.LEFT - else -> TableAlign.LEFT - } - } - val body = mutableListOf>() - var j = i + 2 - while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) { - body.add(splitRow(lines[j])) - j++ - } - return Triple(header, alignments, body) -} - -private fun splitRow(line: String): List { - val trimmed = line.trim().trim('|') - return trimmed.split('|').map { it.trim() } -} - -private data class CodeFenceInfo( - val fenceChar: Char, - val fenceLength: Int, - val language: String?, -) - -private fun parseCodeFenceStart(line: String): CodeFenceInfo? { - val trimmed = line.trimStart() - if (trimmed.length < 3) return null - val firstChar = trimmed[0] - if (firstChar != '`' && firstChar != '~') return null - val count = trimmed.takeWhile { it == firstChar }.length - if (count < 3) return null - val rest = trimmed.substring(count).trim() - if (firstChar == '`' && rest.contains('`')) return null - return CodeFenceInfo( - fenceChar = firstChar, - fenceLength = count, - language = rest.ifBlank { null }, - ) -} - -private fun isCodeFenceEnd( - line: String, - fence: CodeFenceInfo, -): Boolean { - val trimmed = line.trimStart() - val count = trimmed.takeWhile { it == fence.fenceChar }.length - if (count < fence.fenceLength) return false - val rest = trimmed.substring(count).trim() - return rest.isEmpty() -} - -private fun isDefListStart( - lines: List, - i: Int, -): Boolean { - val l = lines[i].trim() - if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false - if (LIST_ITEM_LINE_RE.matches(lines[i])) return false - if (i + 1 >= lines.size) return false - return lines[i + 1].trim().startsWith(":") -} - -private fun isValidHeading(line: String): Boolean { - if (!line.startsWith("#")) return false - val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) - return level < line.length && line[level] == ' ' -} - -private fun fallthroughToParagraph( - lines: List, - start: Int, - blocks: MutableList, -): Int { - var i = start - val para = mutableListOf() - while ( - i < lines.size && - lines[i].isNotBlank() && - parseCodeFenceStart(lines[i]) == null && - !isValidHeading(lines[i]) && - !lines[i].startsWith(">") && - !LIST_ITEM_LINE_RE.matches(lines[i]) && - !isHorizontalRule(lines[i]) && - !isTableStart(lines, i) && - !isDefListStart(lines, i) && - tryParseDisplayMath(lines, i) == null && - FN_DEF_RE.matchAt(lines[i], 0) == null - ) { - para.add(lines[i]) - i++ - } - if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n"))) - return i -} - /** * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and @@ -1549,94 +999,6 @@ private fun parseInlineSource( } } -internal sealed interface MdBlock { - data class Code( - val code: String, - val language: String? = null, - ) : MdBlock - - data class Math( - val latex: String, - ) : MdBlock - - data class Heading( - val level: Int, - val text: String, - ) : MdBlock - - data class Bullet( - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Task( - val checked: Boolean, - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Ordered( - val index: Int, - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Image( - val uri: String, - val alt: String = "", - ) : MdBlock - - data class Video( - val uri: String, - val alt: String = "", - ) : MdBlock - - data class Quote( - val text: String, - ) : MdBlock - - data class Paragraph( - val text: String, - ) : MdBlock - - data class Table( - val header: List, - val alignments: List, - val rows: List>, - ) : MdBlock - - object Hr : MdBlock - - data class DefList( - val items: List, - ) : MdBlock - - data class Footnotes( - val notes: List, - ) : MdBlock -} - -internal data class DefItem( - val term: String, - val definitions: List, -) - -internal data class Footnote( - val id: String, - val text: String, -) - -internal data class FnNote( - val id: String, - val text: String, -) - -internal enum class TableAlign { - LEFT, - CENTER, - RIGHT, -} - private fun resolveImageUrl(uri: String): String { val trimmed = uri.trim() if (trimmed.isBlank()) return uri diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt new file mode 100644 index 00000000..87bfed26 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.markdown + +sealed interface MdBlock { + data class Code( + val code: String, + val language: String? = null, + ) : MdBlock + + data class Math( + val latex: String, + ) : MdBlock + + data class Heading( + val level: Int, + val text: String, + ) : MdBlock + + data class Bullet( + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Task( + val checked: Boolean, + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Ordered( + val index: Int, + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Image( + val uri: String, + val alt: String = "", + ) : MdBlock + + data class Video( + val uri: String, + val alt: String = "", + ) : MdBlock + + data class Quote( + val text: String, + ) : MdBlock + + data class Paragraph( + val text: String, + ) : MdBlock + + data class Table( + val header: List, + val alignments: List, + val rows: List>, + ) : MdBlock + + object Hr : MdBlock + + data class DefList( + val items: List, + ) : MdBlock + + data class Footnotes( + val notes: List, + ) : MdBlock +} + +data class DefItem( + val term: String, + val definitions: List, +) + +data class Footnote( + val id: String, + val text: String, +) + +data class FnNote( + val id: String, + val text: String, +) + +enum class TableAlign { + LEFT, + CENTER, + RIGHT, +} + +sealed interface InlineMathSegment { + data class Text( + val value: String, + ) : InlineMathSegment + + data class Math( + val latex: String, + ) : InlineMathSegment +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt new file mode 100644 index 00000000..d7879285 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.ui.chat.markdown + +private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") +private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""") +private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") +private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") +private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") +private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""") + +/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */ +fun splitInlineMath(text: String): List { + val segments = mutableListOf() + var plainStart = 0 + var i = 0 + + while (i < text.length) { + if (text[i] == '`' && !text.isEscaped(i)) { + val codeEnd = text.indexOf('`', i + 1) + i = if (codeEnd == -1) text.length else codeEnd + 1 + continue + } + if (text.startsWith("\\(", i)) { + val end = text.indexOf("\\)", i + 2) + if (end != -1 && end > i + 2) { + if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) + segments.add(InlineMathSegment.Math(text.substring(i + 2, end))) + i = end + 2 + plainStart = i + continue + } + } + if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') { + i++ + continue + } + + var end = i + 1 + while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++ + if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) { + i++ + continue + } + + if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) + segments.add(InlineMathSegment.Math(text.substring(i + 1, end))) + i = end + 1 + plainStart = i + } + + if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart))) + return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) } +} + +private fun String.isEscaped(index: Int): Boolean { + var slashes = 0 + var i = index - 1 + while (i >= 0 && this[i] == '\\') { + slashes++ + i-- + } + return slashes % 2 == 1 +} + +private fun tryParseDisplayMath( + lines: List, + start: Int, +): Pair? { + val first = lines[start].trim() + if (first.startsWith("\\[")) { + if (first.length > 4 && first.endsWith("\\]")) { + val latex = first.substring(2, first.length - 2).trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } + } + if (first != "\\[") return null + val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null + val latex = lines.subList(start + 1, end).joinToString("\n").trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } + } + if (!first.startsWith("$$")) return null + if (first.length > 4 && first.endsWith("$$")) { + val latex = first.substring(2, first.length - 2).trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } + } + if (first != "$$") return null + + val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null + val latex = lines.subList(start + 1, end).joinToString("\n").trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } +} + +/** + * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first; + * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. + */ +fun parseBlocks(src: String): List { + val lines = src.lines() + val blocks = mutableListOf() + val footnotes = mutableListOf() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + + // Footnote definition: [^id]: text (collected, not rendered inline) + val fnMatch = FN_DEF_RE.matchAt(line, 0) + if (fnMatch != null) { + footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2])) + i++ + continue + } + + val displayMath = tryParseDisplayMath(lines, i) + if (displayMath != null) { + blocks.add(displayMath.first) + i = displayMath.second + continue + } + + val codeFence = parseCodeFenceStart(line) + when { + codeFence != null -> { + val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) } + if (end != null) { + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, end).joinToString("\n"), + language = codeFence.language, + ), + ) + i = end + 1 + } else { + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, lines.size).joinToString("\n"), + language = codeFence.language, + ), + ) + i = lines.size + } + } + + line.isBlank() -> { + i++ + } + + isHorizontalRule(line) -> { + blocks.add(MdBlock.Hr) + i++ + } + + // Heading: requires a space after the '#' run so "#572" stays a paragraph + isValidHeading(line) -> { + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + blocks.add(MdBlock.Heading(level, line.substring(level).trim())) + i++ + } + + isTableStart(lines, i) -> { + val (header, alignments, body) = parseTable(lines, i) + blocks.add(MdBlock.Table(header, alignments, body)) + i += body.size + 2 + } + + line.startsWith(">") -> { + val quote = mutableListOf() + while (i < lines.size && lines[i].startsWith(">")) { + quote.add(lines[i].removePrefix(">").trim()) + i++ + } + blocks.add(MdBlock.Quote(quote.joinToString("\n"))) + } + + // Definition list: term line followed by one+ ": definition" lines + isDefListStart(lines, i) -> { + val term = line.trim() + val defs = mutableListOf() + i++ + while (i < lines.size && lines[i].trim().startsWith(":")) { + defs.add(lines[i].trim().removePrefix(":").trim()) + i++ + } + blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) + } + + LIST_ITEM_LINE_RE.matches(line) -> { + val (listBlocks, nextIndex) = parseList(lines, i) + blocks.addAll(listBlocks) + i = nextIndex + } + + // Standalone Markdown image: ![alt](uri) on its own line. + line.isNotBlank() && tryParseVideo(line) != null -> { + blocks.add(tryParseVideo(line)!!) + i++ + } + + line.isNotBlank() && tryParseImage(line) != null -> { + blocks.add(tryParseImage(line)!!) + i++ + } + + else -> { + i = fallthroughToParagraph(lines, i, blocks) + } + } + } + + if (footnotes.isNotEmpty()) { + blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) })) + } + return blocks +} + +private sealed interface ParsedItem { + val indent: Int + val continuationLines: MutableList + + data class Bullet( + override val indent: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Task( + override val indent: Int, + val checked: Boolean, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Ordered( + override val indent: Int, + val rawNumber: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem +} + +private fun computeIndent(prefix: String): Int { + var count = 0 + for (ch in prefix) { + if (ch == '\t') { + count += 4 - (count % 4) + } else { + count++ + } + } + return count +} + +private fun parseList( + lines: List, + startIndex: Int, +): Pair, Int> { + val items = mutableListOf() + var j = startIndex + + while (j < lines.size) { + val line = lines[j] + + val taskMatch = TASK_LINE_RE.matchEntire(line) + val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null + val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null + + if (taskMatch != null) { + val indent = computeIndent(taskMatch.groupValues[1]) + val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true) + val text = taskMatch.groupValues[3] + items.add(ParsedItem.Task(indent, checked, text)) + j++ + } else if (bulletMatch != null) { + val indent = computeIndent(bulletMatch.groupValues[1]) + val text = bulletMatch.groupValues[2] + items.add(ParsedItem.Bullet(indent, text)) + j++ + } else if (orderedMatch != null) { + val indent = computeIndent(orderedMatch.groupValues[1]) + val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1 + val text = orderedMatch.groupValues[3] + items.add(ParsedItem.Ordered(indent, num, text)) + j++ + } else if (line.isBlank()) { + // Check if there is a following list item + var lookahead = j + 1 + while (lookahead < lines.size && lines[lookahead].isBlank()) { + lookahead++ + } + if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { + j = lookahead + } else { + break + } + } else { + // Check if this is a continuation line for the previous list item + val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' }) + if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) { + items.last().continuationLines.add(line.trim()) + j++ + } else { + break + } + } + } + + if (items.isEmpty()) { + return emptyList() to j + } + + val blocks = mutableListOf() + + // Track state of list items and their relative nesting depth using an indent stack + val indentStack = mutableListOf() // maps level (index) -> base indent + var lastItemWasOrdered = false + var currentSequenceNumber = 1 + var lastLevel = -1 + + for (item in items) { + // Resolve level using the indent stack + val level: Int + if (indentStack.isEmpty() || item.indent == 0) { + indentStack.clear() + indentStack.add(item.indent) + level = 0 + } else if (item.indent > indentStack.last()) { + indentStack.add(item.indent) + level = indentStack.size - 1 + } else if (item.indent == indentStack.last()) { + level = indentStack.size - 1 + } else { + // Unwind stack to the matching or nearest smaller indent + while (indentStack.size > 1 && indentStack.last() > item.indent) { + indentStack.removeAt(indentStack.size - 1) + } + level = indentStack.size - 1 + } + + val fullText = + if (item.continuationLines.isEmpty()) { + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + } else { + val base = + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + base + " " + item.continuationLines.joinToString(" ") + } + + when (item) { + is ParsedItem.Bullet -> { + blocks.add(MdBlock.Bullet(fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Task -> { + blocks.add(MdBlock.Task(item.checked, fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Ordered -> { + val indexToUse = + if (level == lastLevel && lastItemWasOrdered) { + if (item.rawNumber > currentSequenceNumber) { + currentSequenceNumber = item.rawNumber + } else { + currentSequenceNumber++ + } + currentSequenceNumber + } else { + currentSequenceNumber = item.rawNumber + currentSequenceNumber + } + blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) + lastItemWasOrdered = true + lastLevel = level + } + } + } + + return blocks to j +} + +private fun isHorizontalRule(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.length < 3) return false + val c = trimmed[0] + return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c } +} + +private fun isTableStart( + lines: List, + i: Int, +): Boolean { + val l = lines[i] + if (!l.contains('|')) return false + if (i + 1 >= lines.size) return false + return isTableSeparator(lines[i + 1]) +} + +private fun isTableSeparator(line: String): Boolean { + val t = line.trim().trim('|') + if (t.isEmpty()) return false + // every cell must be only dashes/colons/spaces, with at least one dash + return t.split('|').all { cell -> + val c = cell.trim() + c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' } + } +} + +private fun parseTable( + lines: List, + i: Int, +): Triple, List, List>> { + val header = splitRow(lines[i]) + val alignments = + splitRow(lines[i + 1]).map { cell -> + val c = cell.trim() + when { + c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER + c.endsWith(":") -> TableAlign.RIGHT + c.startsWith(":") -> TableAlign.LEFT + else -> TableAlign.LEFT + } + } + val body = mutableListOf>() + var j = i + 2 + while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) { + body.add(splitRow(lines[j])) + j++ + } + return Triple(header, alignments, body) +} + +private fun splitRow(line: String): List { + val trimmed = line.trim().trim('|') + return trimmed.split('|').map { it.trim() } +} + +private data class CodeFenceInfo( + val fenceChar: Char, + val fenceLength: Int, + val language: String?, +) + +private fun parseCodeFenceStart(line: String): CodeFenceInfo? { + val trimmed = line.trimStart() + if (trimmed.length < 3) return null + val firstChar = trimmed[0] + if (firstChar != '`' && firstChar != '~') return null + val count = trimmed.takeWhile { it == firstChar }.length + if (count < 3) return null + val rest = trimmed.substring(count).trim() + if (firstChar == '`' && rest.contains('`')) return null + return CodeFenceInfo( + fenceChar = firstChar, + fenceLength = count, + language = rest.ifBlank { null }, + ) +} + +private fun isCodeFenceEnd( + line: String, + fence: CodeFenceInfo, +): Boolean { + val trimmed = line.trimStart() + val count = trimmed.takeWhile { it == fence.fenceChar }.length + if (count < fence.fenceLength) return false + val rest = trimmed.substring(count).trim() + return rest.isEmpty() +} + +private fun isDefListStart( + lines: List, + i: Int, +): Boolean { + val l = lines[i].trim() + if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false + if (LIST_ITEM_LINE_RE.matches(lines[i])) return false + if (i + 1 >= lines.size) return false + return lines[i + 1].trim().startsWith(":") +} + +private fun isValidHeading(line: String): Boolean { + if (!line.startsWith("#")) return false + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + return level < line.length && line[level] == ' ' +} + +private fun tryParseImage(line: String): MdBlock.Image? { + val m = IMAGE_RE.matchAt(line, 0) ?: return null + val uri = m.groupValues[2].trim() + if (uri.isEmpty()) return null + return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim()) +} + +private fun tryParseVideo(line: String): MdBlock.Video? { + val m = IMAGE_RE.matchAt(line, 0) ?: return null + val uri = m.groupValues[2].trim() + if (uri.isEmpty()) return null + val ext = uri.substringAfterLast('.', "").lowercase() + if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) { + return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim()) + } + return null +} + +private fun fallthroughToParagraph( + lines: List, + start: Int, + blocks: MutableList, +): Int { + var i = start + val para = mutableListOf() + while ( + i < lines.size && + lines[i].isNotBlank() && + parseCodeFenceStart(lines[i]) == null && + !isValidHeading(lines[i]) && + !lines[i].startsWith(">") && + !LIST_ITEM_LINE_RE.matches(lines[i]) && + !isHorizontalRule(lines[i]) && + !isTableStart(lines, i) && + !isDefListStart(lines, i) && + tryParseDisplayMath(lines, i) == null && + FN_DEF_RE.matchAt(lines[i], 0) == null + ) { + para.add(lines[i]) + i++ + } + if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n"))) + return i +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt index 0ea45c65..cbe5511e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import org.junit.Test import kotlin.system.measureTimeMillis diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index c531107b..6cd4de15 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.FnNote +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import com.m57.hermescontrol.util.BidiUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse From e6e79be10d22c3dc49d9a6caeca72fa415dadc5a Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:24:00 +0300 Subject: [PATCH 08/17] feat(common): add ConfirmDialog and safeLaunchAction helper Add reusable ConfirmDialog with destructive action styling. Add safeLaunchAction to ViewModelExt for mutating calls with unit tests. --- .../hermescontrol/ui/common/ConfirmDialog.kt | 101 ++++++++++++++++++ .../hermescontrol/ui/common/ViewModelExt.kt | 27 +++++ .../ui/common/ViewModelExtTest.kt | 74 +++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt new file mode 100644 index 00000000..96ed49a0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Standard confirmation dialog across the app. + * Supports destructive actions with error styling, optional title icons, and optional extra body content. + */ +@Composable +fun ConfirmDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + confirmText: String = stringResource(R.string.action_confirm), + dismissText: String = stringResource(R.string.action_cancel), + isDestructive: Boolean = false, + icon: ImageVector? = null, + extraContent: (@Composable () -> Unit)? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + if (icon != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + } else { + Text(title) + } + }, + text = { + if (extraContent != null) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(message) + extraContent() + } + } else { + Text(message) + } + }, + confirmButton = { + if (isDestructive) { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(confirmText) + } + } else { + Button(onClick = onConfirm) { + Text(confirmText) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 0ceb4520..56d2473e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -34,6 +34,33 @@ inline fun ViewModel.safeLaunchLoad( } } +/** + * Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks. + * Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning. + */ +inline fun ViewModel.safeLaunchAction( + currentJob: Job? = null, + crossinline onStart: () -> Unit = {}, + crossinline apiCall: suspend () -> NetworkResult, + crossinline onSuccess: (T) -> Unit = {}, + crossinline onError: (String) -> Unit = {}, + crossinline onComplete: () -> Unit = {}, +): Job { + if (currentJob?.isActive == true) return currentJob + onStart() + return viewModelScope.launch { + try { + val result = apiCall() + when (result) { + is NetworkResult.Success -> onSuccess(result.data) + is NetworkResult.Failure -> onError(result.error.message) + } + } finally { + onComplete() + } + } +} + /** * Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents]) * from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt new file mode 100644 index 00000000..962e6b37 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.ui.common + +import androidx.lifecycle.ViewModel +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelExtTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class TestViewModel : ViewModel() + + @Test + fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var started = false + var successResult: String? = null + var completed = false + + vm.safeLaunchAction( + onStart = { started = true }, + apiCall = { NetworkResult.Success("ok") }, + onSuccess = { successResult = it }, + onError = {}, + onComplete = { completed = true }, + ) + + assertTrue(started) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("ok", successResult) + assertTrue(completed) + } + + @Test + fun `safeLaunchAction triggers onError and onComplete on failure`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var errorMessage: String? = null + var completed = false + + vm.safeLaunchAction( + apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) }, + onSuccess = {}, + onError = { errorMessage = it }, + onComplete = { completed = true }, + ) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("network error", errorMessage) + assertTrue(completed) + } +} From 02d99e159f2f83b62196c13cecc130c275959dff Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:27:20 +0300 Subject: [PATCH 09/17] fix(groupchat): adopt lifecycle state collection, add error/skeleton states, and fix duplicate test tag Use collectAsStateWithLifecycle, SkeletonListState, and ErrorState. Disambiguate composer stop button test tag. --- .../ui/bots/group/GroupChatScreen.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 948ed979..4835b2a9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -92,6 +91,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection 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.theme.LocalChatFontScale @@ -103,8 +103,10 @@ import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -117,7 +119,7 @@ fun GroupChatScreen( modifier: Modifier = Modifier, viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() }, ) { - val state by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -220,15 +222,23 @@ fun GroupChatScreen( ) { when { state.isLoading -> { - Box( + SkeletonListState( modifier = Modifier .weight(1f) .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(32.dp)) - } + ) + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.setGroup(groupName) }, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) } state.messages.isEmpty() -> { @@ -515,7 +525,7 @@ fun GroupChatScreen( modifier = Modifier .size(36.dp) - .testTag("group_chat_stop_button"), + .testTag("group_chat_composer_stop_button"), ) { Icon( imageVector = Icons.Filled.Stop, From 5ecc2e7d88ea3b16f4ac9e1b0402008bd70ea348 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:44:09 +0300 Subject: [PATCH 10/17] refactor(skills): decompose SkillsScreen into focused UI components Extract InstalledSkillsView, HubBrowseView, SkillCard, HubSkillCard, SourceChip, SkillScanViews, and SkillPreviewDialog into components/. --- .../hermescontrol/ui/skills/SkillsScreen.kt | 1045 +---------------- .../ui/skills/components/HubBrowseView.kt | 314 +++++ .../ui/skills/components/HubSkillCard.kt | 118 ++ .../skills/components/InstalledSkillsView.kt | 183 +++ .../ui/skills/components/SkillCard.kt | 161 +++ .../skills/components/SkillPreviewDialog.kt | 92 ++ .../ui/skills/components/SkillScanViews.kt | 270 +++++ .../ui/skills/components/SourceChip.kt | 56 + 8 files changed, 1197 insertions(+), 1042 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt 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 8c52332d..9e58ccb2 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,96 +1,37 @@ package com.m57.hermescontrol.ui.skills -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.HubSkill -import com.m57.hermescontrol.data.model.Skill -import com.m57.hermescontrol.data.model.SkillHubSource -import com.m57.hermescontrol.data.model.SkillScanFinding -import com.m57.hermescontrol.data.model.SkillScanResponse -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 import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SearchBar -import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect -import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.components.HubBrowseView +import com.m57.hermescontrol.ui.skills.components.InstalledSkillsView import com.m57.hermescontrol.ui.skills.components.SkillEditorDialog -import com.m57.hermescontrol.ui.skills.components.SourceBadge +import com.m57.hermescontrol.ui.skills.components.SkillPreviewDialog internal const val CATEGORY_ALL = "All" @@ -230,983 +171,3 @@ fun SkillsScreen( onClearToast = viewModel::clearToast, ) } - -// ── Installed Skills View ─────────────────────────────────────────────── - -@Composable -private fun InstalledSkillsView( - state: SkillsUiState, - query: String, - onQueryChange: (String) -> Unit, - selectedStatus: SkillFilter, - onStatusChange: (SkillFilter) -> Unit, - selectedCategory: String?, - onCategoryChange: (String) -> Unit, - onToggle: (Skill) -> Unit, - onEdit: (String) -> Unit, - onUninstall: (String) -> Unit, - onSetSourceFilter: (String?) -> Unit, - sourceFilter: String?, - isUninstalling: Boolean, - uninstallingSkillName: String?, - onRefresh: () -> Unit, - onBrowseHub: () -> Unit, -) { - var showDetail by remember { mutableStateOf(null) } - - val categories = - remember(state.skills) { - SkillListFilter.extractCategories(state.skills) - } - val sources = - remember(state.skills) { - SkillListFilter.extractSources(state.skills) - } - - val filteredSkills = - remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { - SkillListFilter.filterSkills( - skills = state.skills, - query = query, - selectedStatus = selectedStatus, - selectedCategory = selectedCategory, - sourceFilter = sourceFilter, - ) - } - - Column(modifier = Modifier.fillMaxSize()) { - SearchBar( - query = query, - onQueryChange = onQueryChange, - placeholder = stringResource(R.string.skills_search_placeholder), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - - FilterChipRow( - chips = SkillFilter.entries.toList(), - selectedChip = selectedStatus, - onChipSelected = onStatusChange, - chipLabel = { chip -> - Text( - text = - if (chip == SkillFilter.ALL_STATUSES) { - stringResource(R.string.skills_category_all) - } else { - stringResource(chip.labelRes) - }, - ) - }, - ) - - if (categories.isNotEmpty() || sources.isNotEmpty()) { - FilterChipRow( - chips = listOf(stringResource(R.string.skills_category_all)) + categories, - selectedChip = selectedCategory, - onChipSelected = onCategoryChange, - ) - if (sources.isNotEmpty()) { - FilterChipRow( - chips = listOf(null) + sources, - selectedChip = sourceFilter, - onChipSelected = onSetSourceFilter, - chipLabel = { source -> - Text( - text = source ?: stringResource(R.string.skills_source_all), - style = MaterialTheme.typography.labelSmall, - ) - }, - ) - } - } - - when { - state.isLoading -> { - SkeletonListState() - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage, - onRetry = onRefresh, - ) - } - - filteredSkills.isEmpty() -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_empty_message), - actionLabel = stringResource(R.string.empty_action_browse_hub), - onAction = onBrowseHub, - ) - } - - else -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - itemsIndexed( - filteredSkills, - key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" }, - ) { _, skill -> - SkillCard( - skill = skill, - onToggle = { onToggle(skill) }, - onAction = { onEdit(skill.name) }, - onUninstall = - if (skill.source == "hub") { - { onUninstall(skill.name) } - } else { - null - }, - isUninstalling = isUninstalling && uninstallingSkillName == skill.name, - onClick = { showDetail = skill }, - ) - } - } - } - } - } - - showDetail?.let { skill -> - DetailDialog( - title = skill.name, - rows = skill.toDetailRows(), - onDismiss = { showDetail = null }, - ) - } -} - -// ── Skill Card ───────────────────────────────────────────────────────── - -@Composable -private fun SkillCard( - skill: Skill, - onToggle: () -> Unit, - onAction: () -> Unit, - onUninstall: (() -> Unit)?, - isUninstalling: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp), - ) { - // ── Top row: name + source badge + toggle ── - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = skill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - skill.source?.let { source -> - Spacer(modifier = Modifier.width(6.dp)) - SourceBadge(source = source) - } - Spacer(modifier = Modifier.width(8.dp)) - Switch( - checked = skill.enabled, - onCheckedChange = { onToggle() }, - ) - } - - // ── Category ── - skill.category?.let { cat -> - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Description ── - skill.description?.let { desc -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - // ── Action buttons row ── - if (onUninstall != null) { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onAction) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton( - onClick = onUninstall, - enabled = !isUninstalling, - ) { - if (isUninstalling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - val statusColors = LocalHermesStatusColors.current - Icon( - imageVector = Icons.Filled.CloudOff, - contentDescription = stringResource(R.string.content_desc_uninstall_skill), - tint = statusColors.error, - ) - } - } - } - } else { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - IconButton(onClick = onAction) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - -// ── Hub Browse View ───────────────────────────────────────────────────── - -@Composable -private fun HubBrowseView( - state: SkillsUiState, - hubQuery: String, - onHubQueryChange: (String) -> Unit, - onSearch: (String) -> Unit, - onClearSearch: () -> Unit, - onInstall: (String) -> Unit, - onPreviewHubSkill: (String) -> Unit, - onScanHubSkill: (String) -> Unit, - onClearHubScan: () -> Unit, - onLoadHubSources: () -> Unit, - isInstalling: Boolean, - installingSkillName: String?, -) { - var hubShowDetail by remember { mutableStateOf(null) } - - Column(modifier = Modifier.fillMaxSize()) { - OutlinedTextField( - value = hubQuery, - onValueChange = onHubQueryChange, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) }, - trailingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (hubQuery.isNotEmpty()) { - IconButton(onClick = { - onHubQueryChange("") - onClearSearch() - }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.action_clear), - ) - } - } - IconButton( - onClick = { onSearch(hubQuery) }, - enabled = hubQuery.isNotBlank(), - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.skills_hub_search_button), - ) - } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = - KeyboardActions( - onSearch = { - if (hubQuery.isNotBlank()) { - onSearch(hubQuery) - } - }, - ), - ) - - when { - state.isHubSearching -> { - SkeletonListState() - } - - state.hubSearchError != null -> { - ErrorState( - message = state.hubSearchError, - onRetry = { onSearch(hubQuery) }, - ) - } - - state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_hub_empty_results), - ) - } - - state.hubResults.isNotEmpty() -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - itemsIndexed( - state.hubResults, - key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" }, - ) { _, hubSkill -> - HubSkillCard( - hubSkill = hubSkill, - onInstall = { onInstall(hubSkill.name) }, - isInstalling = isInstalling && installingSkillName == hubSkill.name, - onClick = { - hubShowDetail = hubSkill - hubSkill.identifier?.let { onPreviewHubSkill(it) } - }, - ) - } - } - } - - else -> { - HubLandingView( - state = state, - onLoadHubSources = onLoadHubSources, - onInstall = onInstall, - isInstalling = isInstalling, - installingSkillName = installingSkillName, - onOpenDetail = { skill -> - hubShowDetail = skill - skill.identifier?.let { onPreviewHubSkill(it) } - }, - ) - } - } - } - - 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 = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (previewReady && state.isHubPreviewing) { - CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) - } - hubSkill.identifier?.let { identifier -> - SkillScanSection( - scanResult = - state.hubScanResult.takeIf { - state.hubScanIdentifier == identifier - }, - isScanning = - state.isHubScanning && - state.hubScanIdentifier == identifier, - scanError = - state.hubScanError.takeIf { - state.hubScanIdentifier == identifier - }, - onScan = { onScanHubSkill(identifier) }, - ) - } - } - }, - onDismiss = { - hubShowDetail = null - onClearHubScan() - }, - ) - } -} - -// ── Hub Landing (sources + featured) ─────────────────────────────────── - -@Composable -private fun HubLandingView( - state: SkillsUiState, - onLoadHubSources: () -> Unit, - onInstall: (String) -> Unit, - isInstalling: Boolean, - installingSkillName: String?, - onOpenDetail: (HubSkill) -> Unit, -) { - when { - state.isHubSourcesLoading -> { - SkeletonListState() - } - - state.hubSourcesError != null -> { - ErrorState( - message = state.hubSourcesError, - onRetry = onLoadHubSources, - ) - } - - state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_hub_landing_hint), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - if (state.hubSources.isNotEmpty()) { - item(key = "hub_sources_header") { - Column( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) { - Text( - text = stringResource(R.string.skills_hub_sources_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = - Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - state.hubSources.forEach { source -> - SourceChip(source = source) - } - } - } - } - } - - if (state.hubFeatured.isNotEmpty()) { - item(key = "hub_featured_header") { - Column( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) { - Text( - text = stringResource(R.string.skills_hub_featured_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(R.string.skills_hub_featured_subtitle), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - itemsIndexed( - state.hubFeatured, - key = { index, hubSkill -> - "featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" - }, - ) { _, hubSkill -> - HubSkillCard( - hubSkill = hubSkill, - onInstall = { onInstall(hubSkill.name) }, - isInstalling = - isInstalling && installingSkillName == hubSkill.name, - onClick = { onOpenDetail(hubSkill) }, - ) - } - } - } - } - } -} - -@Composable -private fun SourceChip(source: SkillHubSource) { - val statusColors = LocalHermesStatusColors.current - val label = - buildString { - append(source.label ?: source.id) - when { - source.rateLimited == true -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_rate_limited)) - } - - source.available == false -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_offline)) - } - - source.searchable == false -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_via_index)) - } - } - } - val color = - when { - source.rateLimited == true -> statusColors.warning - source.available == false -> statusColors.error - else -> MaterialTheme.colorScheme.primary - } - Surface( - color = color.copy(alpha = 0.12f), - shape = RoundedCornerShape(50), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = color, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - ) - } -} - -// ── Hub security scan ────────────────────────────────────────────────── - -@Composable -private fun SkillScanSection( - scanResult: SkillScanResponse?, - isScanning: Boolean, - scanError: String?, - onScan: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = onScan, - enabled = !isScanning, - ) { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Filled.Shield, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(stringResource(R.string.skills_hub_scan)) - } - } - - when { - isScanning -> { - Unit - } - - scanError != null -> { - Surface( - color = LocalHermesStatusColors.current.errorContainer, - shape = RoundedCornerShape(8.dp), - ) { - Text( - text = scanError, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - ) - } - } - - scanResult != null -> { - ScanResultCard(result = scanResult) - } - } - } -} - -@Composable -private fun ScanResultCard(result: SkillScanResponse) { - val statusColors = LocalHermesStatusColors.current - val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) - val policyLabel = - SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } - ?: result.policy.orEmpty() - val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // ── Verdict header ── - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Filled.Shield, - contentDescription = null, - tint = verdictColor, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()), - style = MaterialTheme.typography.titleSmall, - color = verdictColor, - modifier = Modifier.weight(1f), - ) - Surface( - color = policyColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(50), - ) { - Text( - text = policyLabel, - style = MaterialTheme.typography.labelSmall, - color = policyColor, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } - } - - // ── Trust + finding count ── - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (!result.trustLevel.isNullOrBlank()) { - Text( - text = result.trustLevel, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = - pluralStringResource( - R.plurals.skills_hub_scan_findings_count, - result.findings.size, - result.findings.size, - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Severity tally ── - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - listOf("critical", "high", "medium", "low").forEach { sev -> - val count = result.severityCounts[sev] ?: 0 - if (count > 0) { - val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) - Surface( - color = sevColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(50), - ) { - Text( - text = "$count $sev", - style = MaterialTheme.typography.labelSmall, - color = sevColor, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } - } - } - if (result.findings.isEmpty()) { - Text( - text = stringResource(R.string.skills_hub_scan_no_findings), - style = MaterialTheme.typography.labelSmall, - color = statusColors.success, - ) - } - } - - // ── Policy reason ── - if (!result.policyReason.isNullOrBlank()) { - Text( - text = result.policyReason, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Findings ── - if (result.findings.isNotEmpty()) { - HorizontalDivider() - result.findings.forEach { finding -> - ScanFindingRow(finding = finding) - } - } - } - } -} - -@Composable -private fun ScanFindingRow(finding: SkillScanFinding) { - val statusColors = LocalHermesStatusColors.current - val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Surface( - color = severityColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(4.dp), - ) { - Text( - text = finding.severity, - style = MaterialTheme.typography.labelSmall, - color = severityColor, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - Text( - text = finding.category, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - val location = - buildString { - finding.file?.let { append(it) } - finding.line?.let { append(":").append(it) } - } - if (location.isNotEmpty()) { - Text( - text = location, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - finding.description?.let { description -> - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -// ── Hub Skill Card ───────────────────────────────────────────────────── - -@Composable -private fun HubSkillCard( - hubSkill: HubSkill, - onInstall: () -> Unit, - isInstalling: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), - ) { - // ── Top row: name ── - Text( - text = hubSkill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - // ── Category ── - hubSkill.category?.let { cat -> - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Description ── - hubSkill.description?.let { desc -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - // ── Source badge + Install button ── - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - hubSkill.source?.let { source -> - SourceBadge(source = source) - Spacer(modifier = Modifier.width(8.dp)) - } - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = onInstall, - enabled = !isInstalling, - ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Filled.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.skills_hub_install)) - } - } - } - } - } -} - -// ── Skill Preview Dialog ─────────────────────────────────────────────── - -@Composable -fun SkillPreviewDialog( - skillName: String, - isLoading: Boolean, - content: String?, - onDismiss: () -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - HermesScaffold( - title = { Text(stringResource(R.string.skills_preview_title, skillName)) }, - navigationIcon = NavIcon.Back(onBack = onDismiss), - ) { padding -> - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - when { - isLoading -> { - LoadingState() - } - - content != null -> { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = content, - style = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } - } - - else -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_preview_empty), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt new file mode 100644 index 00000000..7d31f04a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt @@ -0,0 +1,314 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.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 +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +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.SkeletonListState +import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.skills.SkillsUiState + +@Composable +fun HubBrowseView( + state: SkillsUiState, + hubQuery: String, + onHubQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onClearSearch: () -> Unit, + onInstall: (String) -> Unit, + onPreviewHubSkill: (String) -> Unit, + onScanHubSkill: (String) -> Unit, + onClearHubScan: () -> Unit, + onLoadHubSources: () -> Unit, + isInstalling: Boolean, + installingSkillName: String?, +) { + var hubShowDetail by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = hubQuery, + onValueChange = onHubQueryChange, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) }, + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (hubQuery.isNotEmpty()) { + IconButton(onClick = { + onHubQueryChange("") + onClearSearch() + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.action_clear), + ) + } + } + IconButton( + onClick = { onSearch(hubQuery) }, + enabled = hubQuery.isNotBlank(), + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.skills_hub_search_button), + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = + KeyboardActions( + onSearch = { + if (hubQuery.isNotBlank()) { + onSearch(hubQuery) + } + }, + ), + ) + + when { + state.isHubSearching -> { + SkeletonListState() + } + + state.hubSearchError != null -> { + ErrorState( + message = state.hubSearchError, + onRetry = { onSearch(hubQuery) }, + ) + } + + state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_hub_empty_results), + ) + } + + state.hubResults.isNotEmpty() -> { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + itemsIndexed( + state.hubResults, + key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" }, + ) { _, hubSkill -> + HubSkillCard( + hubSkill = hubSkill, + onInstall = { onInstall(hubSkill.name) }, + isInstalling = isInstalling && installingSkillName == hubSkill.name, + onClick = { + hubShowDetail = hubSkill + hubSkill.identifier?.let { onPreviewHubSkill(it) } + }, + ) + } + } + } + + else -> { + HubLandingView( + state = state, + onLoadHubSources = onLoadHubSources, + onInstall = onInstall, + isInstalling = isInstalling, + installingSkillName = installingSkillName, + onOpenDetail = { skill -> + hubShowDetail = skill + skill.identifier?.let { onPreviewHubSkill(it) } + }, + ) + } + } + } + + 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 = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (previewReady && state.isHubPreviewing) { + CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) + } + hubSkill.identifier?.let { identifier -> + SkillScanSection( + scanResult = + state.hubScanResult.takeIf { + state.hubScanIdentifier == identifier + }, + isScanning = + state.isHubScanning && + state.hubScanIdentifier == identifier, + scanError = + state.hubScanError.takeIf { + state.hubScanIdentifier == identifier + }, + onScan = { onScanHubSkill(identifier) }, + ) + } + } + }, + onDismiss = { + hubShowDetail = null + onClearHubScan() + }, + ) + } +} + +@Composable +fun HubLandingView( + state: SkillsUiState, + onLoadHubSources: () -> Unit, + onInstall: (String) -> Unit, + isInstalling: Boolean, + installingSkillName: String?, + onOpenDetail: (HubSkill) -> Unit, +) { + when { + state.isHubSourcesLoading -> { + SkeletonListState() + } + + state.hubSourcesError != null -> { + ErrorState( + message = state.hubSourcesError, + onRetry = onLoadHubSources, + ) + } + + state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_hub_landing_hint), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + if (state.hubSources.isNotEmpty()) { + item(key = "hub_sources_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_sources_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + state.hubSources.forEach { source -> + SourceChip(source = source) + } + } + } + } + } + + if (state.hubFeatured.isNotEmpty()) { + item(key = "hub_featured_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_featured_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.skills_hub_featured_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + itemsIndexed( + state.hubFeatured, + key = { index, hubSkill -> + "featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" + }, + ) { _, hubSkill -> + HubSkillCard( + hubSkill = hubSkill, + onInstall = { onInstall(hubSkill.name) }, + isInstalling = + isInstalling && installingSkillName == hubSkill.name, + onClick = { onOpenDetail(hubSkill) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt new file mode 100644 index 00000000..11414133 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 + +@Composable +fun HubSkillCard( + hubSkill: HubSkill, + onInstall: () -> Unit, + isInstalling: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + ) { + // ── Top row: name ── + Text( + text = hubSkill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + // ── Category ── + hubSkill.category?.let { cat -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = cat, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Description ── + hubSkill.description?.let { desc -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + // ── Source badge + Install button ── + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + hubSkill.source?.let { source -> + SourceBadge(source = source) + Spacer(modifier = Modifier.width(8.dp)) + } + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onInstall, + enabled = !isInstalling, + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Filled.CloudDownload, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.skills_hub_install)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt new file mode 100644 index 00000000..abd94af1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt @@ -0,0 +1,183 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Skill +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 +import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SkeletonListState +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.SkillListFilter +import com.m57.hermescontrol.ui.skills.SkillsUiState + +@Composable +fun InstalledSkillsView( + state: SkillsUiState, + query: String, + onQueryChange: (String) -> Unit, + selectedStatus: SkillFilter, + onStatusChange: (SkillFilter) -> Unit, + selectedCategory: String?, + onCategoryChange: (String) -> Unit, + onToggle: (Skill) -> Unit, + onEdit: (String) -> Unit, + onUninstall: (String) -> Unit, + onSetSourceFilter: (String?) -> Unit, + sourceFilter: String?, + isUninstalling: Boolean, + uninstallingSkillName: String?, + onRefresh: () -> Unit, + onBrowseHub: () -> Unit, +) { + var showDetail by remember { mutableStateOf(null) } + + val categories = + remember(state.skills) { + SkillListFilter.extractCategories(state.skills) + } + val sources = + remember(state.skills) { + SkillListFilter.extractSources(state.skills) + } + + val filteredSkills = + remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { + SkillListFilter.filterSkills( + skills = state.skills, + query = query, + selectedStatus = selectedStatus, + selectedCategory = selectedCategory, + sourceFilter = sourceFilter, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + placeholder = stringResource(R.string.skills_search_placeholder), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + + FilterChipRow( + chips = SkillFilter.entries.toList(), + selectedChip = selectedStatus, + onChipSelected = onStatusChange, + chipLabel = { chip -> + Text( + text = + if (chip == SkillFilter.ALL_STATUSES) { + stringResource(R.string.skills_category_all) + } else { + stringResource(chip.labelRes) + }, + ) + }, + ) + + if (categories.isNotEmpty() || sources.isNotEmpty()) { + FilterChipRow( + chips = listOf(stringResource(R.string.skills_category_all)) + categories, + selectedChip = selectedCategory, + onChipSelected = onCategoryChange, + ) + if (sources.isNotEmpty()) { + FilterChipRow( + chips = listOf(null) + sources, + selectedChip = sourceFilter, + onChipSelected = onSetSourceFilter, + chipLabel = { source -> + Text( + text = source ?: stringResource(R.string.skills_source_all), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + + when { + state.isLoading -> { + SkeletonListState() + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage, + onRetry = onRefresh, + ) + } + + filteredSkills.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_empty_message), + actionLabel = stringResource(R.string.empty_action_browse_hub), + onAction = onBrowseHub, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + itemsIndexed( + filteredSkills, + key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" }, + ) { _, skill -> + SkillCard( + skill = skill, + onToggle = { onToggle(skill) }, + onAction = { onEdit(skill.name) }, + onUninstall = + if (skill.source == "hub") { + { onUninstall(skill.name) } + } else { + null + }, + isUninstalling = isUninstalling && uninstallingSkillName == skill.name, + onClick = { showDetail = skill }, + ) + } + } + } + } + } + + showDetail?.let { skill -> + DetailDialog( + title = skill.name, + rows = skill.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt new file mode 100644 index 00000000..fcedce17 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt @@ -0,0 +1,161 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun SkillCard( + skill: Skill, + onToggle: () -> Unit, + onAction: () -> Unit, + onUninstall: (() -> Unit)?, + isUninstalling: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp), + ) { + // ── Top row: name + source badge + toggle ── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = skill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + skill.source?.let { source -> + Spacer(modifier = Modifier.width(6.dp)) + SourceBadge(source = source) + } + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = skill.enabled, + onCheckedChange = { onToggle() }, + ) + } + + // ── Category ── + skill.category?.let { cat -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = cat, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Description ── + skill.description?.let { desc -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // ── Action buttons row ── + if (onUninstall != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAction) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit_skill), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onUninstall, + enabled = !isUninstalling, + ) { + if (isUninstalling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + val statusColors = LocalHermesStatusColors.current + Icon( + imageVector = Icons.Filled.CloudOff, + contentDescription = stringResource(R.string.content_desc_uninstall_skill), + tint = statusColors.error, + ) + } + } + } + } else { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAction) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit_skill), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt new file mode 100644 index 00000000..a26bb55c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt @@ -0,0 +1,92 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon + +@Composable +fun SkillPreviewDialog( + skillName: String, + isLoading: Boolean, + content: String?, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + HermesScaffold( + title = { Text(stringResource(R.string.skills_preview_title, skillName)) }, + navigationIcon = NavIcon.Back(onBack = onDismiss), + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + when { + isLoading -> { + LoadingState() + } + + content != null -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = content, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + + else -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_preview_empty), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt new file mode 100644 index 00000000..daecd21c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt @@ -0,0 +1,270 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SkillScanFinding +import com.m57.hermescontrol.data.model.SkillScanResponse +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.skills.SkillScanPresentation + +@Composable +fun SkillScanSection( + scanResult: SkillScanResponse?, + isScanning: Boolean, + scanError: String?, + onScan: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onScan, + enabled = !isScanning, + ) { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.skills_hub_scan)) + } + } + + when { + isScanning -> { + Unit + } + + scanError != null -> { + Surface( + color = LocalHermesStatusColors.current.errorContainer, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = scanError, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } + + scanResult != null -> { + ScanResultCard(result = scanResult) + } + } + } +} + +@Composable +fun ScanResultCard(result: SkillScanResponse) { + val statusColors = LocalHermesStatusColors.current + val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) + val policyLabel = + SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } + ?: result.policy.orEmpty() + val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Verdict header ── + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + tint = verdictColor, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()), + style = MaterialTheme.typography.titleSmall, + color = verdictColor, + modifier = Modifier.weight(1f), + ) + Surface( + color = policyColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = policyLabel, + style = MaterialTheme.typography.labelSmall, + color = policyColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + + // ── Trust + finding count ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!result.trustLevel.isNullOrBlank()) { + Text( + text = result.trustLevel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = + pluralStringResource( + R.plurals.skills_hub_scan_findings_count, + result.findings.size, + result.findings.size, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Severity tally ── + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf("critical", "high", "medium", "low").forEach { sev -> + val count = result.severityCounts[sev] ?: 0 + if (count > 0) { + val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) + Surface( + color = sevColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = "$count $sev", + style = MaterialTheme.typography.labelSmall, + color = sevColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + } + if (result.findings.isEmpty()) { + Text( + text = stringResource(R.string.skills_hub_scan_no_findings), + style = MaterialTheme.typography.labelSmall, + color = statusColors.success, + ) + } + } + + // ── Policy reason ── + if (!result.policyReason.isNullOrBlank()) { + Text( + text = result.policyReason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Findings ── + if (result.findings.isNotEmpty()) { + HorizontalDivider() + result.findings.forEach { finding -> + ScanFindingRow(finding = finding) + } + } + } + } +} + +@Composable +fun ScanFindingRow(finding: SkillScanFinding) { + val statusColors = LocalHermesStatusColors.current + val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Surface( + color = severityColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = finding.severity, + style = MaterialTheme.typography.labelSmall, + color = severityColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + Text( + text = finding.category, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + ) + val fileLocation = + buildString { + finding.file?.let { append(it) } + finding.line?.let { append(":").append(it) } + } + if (fileLocation.isNotEmpty()) { + Text( + text = fileLocation, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + finding.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt new file mode 100644 index 00000000..4412308d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SkillHubSource +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun SourceChip(source: SkillHubSource) { + val statusColors = LocalHermesStatusColors.current + val label = + buildString { + append(source.label ?: source.id) + when { + source.rateLimited == true -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_rate_limited)) + } + + source.available == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_offline)) + } + + source.searchable == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_via_index)) + } + } + } + val color = + when { + source.rateLimited == true -> statusColors.warning + source.available == false -> statusColors.error + else -> MaterialTheme.colorScheme.primary + } + Surface( + color = color.copy(alpha = 0.12f), + shape = RoundedCornerShape(50), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } +} From 35dc7787023dfe3ccee2fd00fca650ee1a3bf493 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:07:03 +0300 Subject: [PATCH 11/17] refactor(system): decompose SystemScreen into focused section components Extract Host, Portal, Curator, Gateway, Credentials, Operations, Checkpoints, ShellHooks, ActionLog, and HookCreateDialog into components/. --- .../hermescontrol/ui/system/SystemScreen.kt | 1636 ++--------------- .../system/components/ActionButtonContent.kt | 55 + .../ui/system/components/ActionLogSection.kt | 135 ++ .../system/components/CheckpointsSection.kt | 78 + .../system/components/CredentialsSection.kt | 152 ++ .../ui/system/components/CuratorSection.kt | 136 ++ .../ui/system/components/GatewaySection.kt | 187 ++ .../ui/system/components/HookCreateDialog.kt | 121 ++ .../ui/system/components/HostSection.kt | 234 +++ .../ui/system/components/OperationsSection.kt | 392 ++++ .../ui/system/components/PortalSection.kt | 115 ++ .../ui/system/components/ShellHooksSection.kt | 60 + 12 files changed, 1794 insertions(+), 1507 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index e8a36ea1..9b287edb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -5,55 +5,12 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Backup -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -63,46 +20,44 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -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.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.chat.MediaImageStore import com.m57.hermescontrol.ui.common.ActionProgressDialog +import com.m57.hermescontrol.ui.common.ConfirmDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.InfoRow import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.SkeletonListState -import com.m57.hermescontrol.ui.common.StatCard -import com.m57.hermescontrol.ui.common.StatusBadge -import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect -import com.m57.hermescontrol.ui.system.components.CredentialEntryRow -import com.m57.hermescontrol.ui.system.components.HookCard -import com.m57.hermescontrol.util.formatBytes -import com.m57.hermescontrol.util.formatDuration +import com.m57.hermescontrol.ui.system.components.HookCreateDialog +import com.m57.hermescontrol.ui.system.components.actionLogSection +import com.m57.hermescontrol.ui.system.components.checkpointsSection +import com.m57.hermescontrol.ui.system.components.credentialsSection +import com.m57.hermescontrol.ui.system.components.curatorSection +import com.m57.hermescontrol.ui.system.components.gatewaySection +import com.m57.hermescontrol.ui.system.components.hostSection +import com.m57.hermescontrol.ui.system.components.operationsSection +import com.m57.hermescontrol.ui.system.components.portalSection +import com.m57.hermescontrol.ui.system.components.shellHooksSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +/** Staged SAF-picked backup archive for the restore flow (issue #786). */ +private data class PendingImport( + val uri: Uri, + val name: String, + val mime: String, +) + @Composable fun SystemScreen( modifier: Modifier = Modifier, @@ -110,21 +65,18 @@ fun SystemScreen( viewModel: SystemViewModel? = null, ) { val app = LocalContext.current.applicationContext as Application - val viewModel = viewModel ?: viewModel { SystemViewModel(app) } - val state by viewModel.uiState.collectAsStateWithLifecycle() + val resolvedViewModel = viewModel ?: viewModel { SystemViewModel(app) } + val state by resolvedViewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - viewModel.loadAll() + resolvedViewModel.loadAll() } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + ToastEffect(toastMessage = state.toastMessage, onClearToast = resolvedViewModel::clearToast) // ── Backup import (issue #786) ──────────────────────────────────────── - // SAF file picker → stage uri/name/mime → confirm dialog → read bytes on - // IO → multipart upload via SystemViewModel.importArchive. val context = LocalContext.current val importScope = rememberCoroutineScope() var pendingImport by remember { mutableStateOf(null) } @@ -147,9 +99,9 @@ fun SystemScreen( meta.fold( onSuccess = { pendingImport = it - viewModel.setImportFile(it.name) + resolvedViewModel.setImportFile(it.name) }, - onFailure = { viewModel.showToast("Could not read selected file: ${it.message}") }, + onFailure = { resolvedViewModel.showToast("Could not read selected file: ${it.message}") }, ) } } @@ -158,132 +110,127 @@ fun SystemScreen( if (state.updateConfirmOpen) { AlertDialog( - onDismissRequest = viewModel::closeUpdateConfirm, + onDismissRequest = resolvedViewModel::closeUpdateConfirm, title = { Text(stringResource(R.string.system_update_confirm_title)) }, text = { Text(stringResource(R.string.system_update_confirm_desc)) }, confirmButton = { TextButton(onClick = { - viewModel.closeUpdateConfirm() - viewModel.applyUpdate() + resolvedViewModel.closeUpdateConfirm() + resolvedViewModel.applyUpdate() }) { Text(stringResource(R.string.system_confirm_update_now)) } }, dismissButton = { - TextButton(onClick = viewModel::closeUpdateConfirm) { + TextButton(onClick = resolvedViewModel::closeUpdateConfirm) { Text(stringResource(R.string.system_confirm_cancel)) } }, ) } - // Update progress popup (issue #863): replaces the old bottom-of-screen - // action log for the update flow — spinner + log tail while running, - // success/failure state once it exits. ActionProgressDialog( - controller = viewModel.actionProgress, + controller = resolvedViewModel.actionProgress, title = stringResource(R.string.system_update_progress_title), ) - // Credential remove confirmation + // Credential remove confirmation using ConfirmDialog var credToRemove by remember { mutableStateOf?>(null) } - if (credToRemove != null) { - AlertDialog( - onDismissRequest = { credToRemove = null }, - title = { Text(stringResource(R.string.system_credentials_remove_confirm_title)) }, - text = { Text(stringResource(R.string.system_credentials_remove_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - credToRemove?.let { (p, i) -> viewModel.removeCredential(p, i) } - credToRemove = null - }) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = { credToRemove = null }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + credToRemove?.let { (provider, index) -> + ConfirmDialog( + title = stringResource(R.string.system_credentials_remove_confirm_title), + message = stringResource(R.string.system_credentials_remove_confirm_desc), + onConfirm = { + resolvedViewModel.removeCredential(provider, index) + credToRemove = null }, + onDismiss = { credToRemove = null }, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + icon = Icons.Filled.Delete, ) } // Prune checkpoints confirmation var pruneConfirm by remember { mutableStateOf(false) } if (pruneConfirm) { - AlertDialog( - onDismissRequest = { pruneConfirm = false }, - title = { Text(stringResource(R.string.system_checkpoints_prune_confirm_title)) }, - text = { Text(stringResource(R.string.system_checkpoints_prune_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - pruneConfirm = false - viewModel.pruneCheckpoints() - }) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = { pruneConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + ConfirmDialog( + title = stringResource(R.string.system_checkpoints_prune_confirm_title), + message = stringResource(R.string.system_checkpoints_prune_confirm_desc), + onConfirm = { + pruneConfirm = false + resolvedViewModel.pruneCheckpoints() }, + onDismiss = { pruneConfirm = false }, + confirmText = stringResource(R.string.action_ok), + isDestructive = true, ) } - // Import / Restore confirmation + // Restore confirmation var importConfirm by remember { mutableStateOf(false) } if (importConfirm) { - AlertDialog( - onDismissRequest = { importConfirm = false }, - title = { Text(stringResource(R.string.system_op_import_confirm_title)) }, - text = { Text(stringResource(R.string.system_op_import_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - importConfirm = false - val pending = pendingImport - pendingImport = null - if (pending != null) { - importScope.launch { - val read = - withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } - } + ConfirmDialog( + title = stringResource(R.string.system_op_import_confirm_title), + message = stringResource(R.string.system_op_import_confirm_desc), + onConfirm = { + importConfirm = false + val pending = pendingImport + pendingImport = null + if (pending != null) { + importScope.launch { + val read = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } } - read.fold( - onSuccess = { bytes -> - if (bytes != null) { - viewModel.importArchive(pending.name, bytes, pending.mime) - } else { - viewModel.showToast("Could not read selected file") - } - }, - onFailure = { - viewModel.showToast("Could not read selected file: ${it.message}") - }, - ) - } + } + read.fold( + onSuccess = { bytes -> + if (bytes != null) { + resolvedViewModel.importArchive(pending.name, bytes, pending.mime) + } else { + resolvedViewModel.showToast("Could not read selected file") + } + }, + onFailure = { + resolvedViewModel.showToast("Could not read selected file: ${it.message}") + }, + ) } - }) { - Text(stringResource(R.string.system_confirm_restore)) - } - }, - dismissButton = { - TextButton(onClick = { importConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) } }, + onDismiss = { importConfirm = false }, + confirmText = stringResource(R.string.system_confirm_restore), + isDestructive = true, ) } + // Hook creation modal (hoisted out of LazyColumn item) + HookCreateDialog( + isOpen = state.hookModalOpen, + event = state.hookEvent, + command = state.hookCommand, + matcher = state.hookMatcher, + timeout = state.hookTimeout, + approve = state.hookApprove, + isCreating = state.creatingHook, + onEventChange = resolvedViewModel::updateHookEvent, + onCommandChange = resolvedViewModel::updateHookCommand, + onMatcherChange = resolvedViewModel::updateHookMatcher, + onTimeoutChange = resolvedViewModel::updateHookTimeout, + onApproveChange = resolvedViewModel::updateHookApprove, + onCreate = resolvedViewModel::createHook, + onDismiss = resolvedViewModel::toggleHookModal, + ) + HermesScaffold( modifier = modifier, title = { Text(stringResource(R.string.screen_system)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadAll() }, - ) { + onRefresh = { resolvedViewModel.loadAll() }, + ) { padding -> when { state.isLoading && state.stats == null && state.doctorReport == null && state.status == null -> { @@ -293,7 +240,7 @@ fun SystemScreen( state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: stringResource(R.string.error_unknown), - onRetry = { viewModel.loadAll() }, + onRetry = { resolvedViewModel.loadAll() }, ) } @@ -308,35 +255,34 @@ fun SystemScreen( LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = - androidx.compose.foundation.layout.PaddingValues( + PaddingValues( horizontal = spacing.md, vertical = spacing.sm, ), verticalArrangement = Arrangement.spacedBy(spacing.sm), ) { - // ── 1. Host ──────────────────────────────────────────── - hostSection(state, spacing, statusColors, viewModel) + // ── 1. Host ── + hostSection(state, spacing, resolvedViewModel) - // ── 2. Portal ────────────────────────────────────────── + // ── 2. Portal ── portalSection(state, spacing, uriHandler) - // ── 3. Curator ───────────────────────────────────────── - curatorSection(state, spacing, statusColors, viewModel) + // ── 3. Curator ── + curatorSection(state, spacing, resolvedViewModel) - // ── 4. Gateway ───────────────────────────────────────── - gatewaySection(state, spacing, statusColors, viewModel) + // ── 4. Gateway ── + gatewaySection(state, spacing, resolvedViewModel) - // ── 6. Credentials ───────────────────────────────────── - credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) + // ── 6. Credentials ── + credentialsSection(state, spacing, resolvedViewModel) { credToRemove = it } - // ── 7. Operations ────────────────────────────────────── + // ── 7. Operations ── operationsSection( - state, - spacing, - viewModel, - importConfirm, - { importConfirm = it }, - { + state = state, + spacing = spacing, + viewModel = resolvedViewModel, + onImportConfirm = { importConfirm = it }, + onImportPick = { importPickerLauncher.launch( arrayOf( "application/zip", @@ -346,8 +292,8 @@ fun SystemScreen( ), ) }, - { - viewModel.downloadBackup { body, fileName -> + downloadBackup = { + resolvedViewModel.downloadBackup { body, fileName -> importScope.launch { val uri = withContext(Dispatchers.IO) { @@ -362,1351 +308,27 @@ fun SystemScreen( }.getOrNull() } if (uri != null) { - viewModel.showToast("Backup saved to Downloads") + resolvedViewModel.showToast("Backup saved to Downloads") } else { - viewModel.showToast("Could not save backup to Downloads") + resolvedViewModel.showToast("Could not save backup to Downloads") } } } }, ) - // ── 8. Checkpoints ───────────────────────────────────── - checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it }) + // ── 8. Checkpoints ── + checkpointsSection(state, spacing) { pruneConfirm = it } - // ── 9. Shell Hooks ───────────────────────────────────── - shellHooksSection(state, spacing, viewModel) + // ── 9. Shell Hooks ── + shellHooksSection(state, spacing, resolvedViewModel) - // ── 10. Action Log ───────────────────────────────────── + // ── 10. Action Log ── if (state.activeAction != null) { - actionLogSection(state, spacing, viewModel) + actionLogSection(state, spacing, resolvedViewModel) } } } } } } - -// ── Section composables ───────────────────────────────────────────────── - -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -private fun LazyListScope.hostSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.stats?.let { stats -> - item { - SectionHeader(title = stringResource(R.string.system_sec_host)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // OS, Arch, Hostname - stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) } - stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) } - stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) } - stats.python_version?.let { python -> - val impl = stats.python_impl?.let { " ($it)" } ?: "" - InfoRow(stringResource(R.string.system_label_python), "$python$impl") - } - - // Hermes version + update badge - stats.hermes_version?.let { ver -> - val updateBadge = - state.updateInfo?.let { info -> - when { - info.update_available == true && info.behind != null && info.behind > 0 -> { - " (${stringResource( - R.string.system_version_update_available, - )}: ${stringResource(R.string.system_version_behind, info.behind)})" - } - - info.update_available == true -> { - " (${stringResource( - R.string.system_version_update_available, - )})" - } - - else -> { - " (${stringResource(R.string.system_version_latest)})" - } - } - } ?: "" - InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge") - } - - // Update buttons - Row( - modifier = Modifier.fillMaxWidth().padding(top = spacing.sm), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedButton( - onClick = { viewModel.checkForUpdate(false) }, - enabled = !state.checkingUpdate, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (state.checkingUpdate) { - ActionButtonContent( - icon = null, - text = stringResource(R.string.system_action_check_updates), - loading = true, - ) - } else { - ActionButtonContent( - icon = Icons.Filled.Update, - text = stringResource(R.string.system_action_check_updates), - ) - } - } - state.updateInfo?.let { info -> - if (info.update_available == true && info.can_apply == true) { - Button( - onClick = { viewModel.openUpdateConfirm() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_action_update_now), - ) - } - } - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // CPU / Memory / Disk stat cards - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - stats.cpu_percent?.let { cpu -> - StatCard( - label = stringResource(R.string.system_label_cpu), - value = "${cpu.toInt()}%", - icon = Icons.Filled.Speed, - modifier = Modifier.weight(1f), - ) - } - stats.memory?.percent?.let { mem -> - StatCard( - label = stringResource(R.string.system_label_memory), - value = "${mem.toInt()}%", - icon = Icons.Filled.Memory, - modifier = Modifier.weight(1f), - ) - } - stats.disk?.percent?.let { disk -> - StatCard( - label = stringResource(R.string.system_label_disk), - value = "${disk.toInt()}%", - icon = Icons.Filled.Storage, - modifier = Modifier.weight(1f), - ) - } - } - - // Memory details - stats.memory?.let { mem -> - InfoRow( - label = stringResource(R.string.system_label_memory), - value = - stringResource( - R.string.system_label_used_total_percent, - formatBytes(mem.used ?: 0L), - formatBytes(mem.total ?: 0L), - "${(mem.percent ?: 0.0).toInt()}", - ), - ) - } - - // Disk details - stats.disk?.let { disk -> - InfoRow( - label = stringResource(R.string.system_label_disk), - value = - stringResource( - R.string.system_label_used_total_percent, - formatBytes(disk.used ?: 0L), - formatBytes(disk.total ?: 0L), - "${(disk.percent ?: 0.0).toInt()}", - ), - ) - } - - // Uptime - stats.uptime_seconds?.let { secs -> - InfoRow( - label = stringResource(R.string.system_label_uptime), - value = formatDuration(secs), - ) - } - - // Load avg - stats.load_avg?.let { loads -> - if (loads.isNotEmpty()) { - val coresText = - stats.cpu_count?.let { - " ($it ${stringResource( - R.string.system_label_cores, - )})" - } ?: "" - InfoRow( - label = stringResource(R.string.system_label_load_avg), - value = loads.joinToString(", ") { "%.2f".format(it) } + coresText, - ) - } - } ?: stats.cpu_count?.let { cores -> - InfoRow( - label = stringResource(R.string.system_label_cpu), - value = "$cores ${stringResource(R.string.system_label_cores)}", - ) - } - - // psutil warning - if (stats.psutil == false) { - Spacer(modifier = Modifier.height(spacing.sm)) - StatusBadge( - text = stringResource(R.string.system_psutil_warning), - status = StatusBadgeType.WARNING, - ) - } - } - } - } - } -} - -private fun LazyListScope.portalSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - uriHandler: androidx.compose.ui.platform.UriHandler, -) { - state.portal?.let { portal -> - item { - SectionHeader(title = stringResource(R.string.system_sec_portal)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Login status - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val loggedIn = portal.logged_in == true - StatusBadge( - text = - if (loggedIn) { - stringResource(R.string.system_status_logged_in) - } else { - stringResource(R.string.system_status_not_logged_in) - }, - status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, - ) - if (!loggedIn) { - Text( - text = stringResource(R.string.system_portal_login_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // Provider - portal.provider?.let { provider -> - InfoRow( - label = stringResource(R.string.system_portal_provider, provider), - value = "", - ) - } - - // Manage subscription link - portal.subscription_url?.let { url -> - TextButton( - onClick = { uriHandler.openUri(url) }, - modifier = Modifier.padding(top = spacing.xs), - ) { - Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text( - stringResource(R.string.system_portal_manage_subscription), - maxLines = 1, - softWrap = false, - ) - } - } - - // Feature routing - portal.features?.let { features -> - if (features.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - features.forEach { feature -> - InfoRow( - label = feature.label ?: stringResource(R.string.system_portal_feature_routing), - value = feature.state ?: "", - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.curatorSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.curator?.let { curator -> - item { - SectionHeader(title = stringResource(R.string.system_sec_curator)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Status - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val statusText: String - val statusType: StatusBadgeType - when { - curator.enabled != true -> { - statusText = stringResource(R.string.system_status_disabled) - statusType = StatusBadgeType.ERROR - } - - curator.paused == true -> { - statusText = stringResource(R.string.system_status_paused) - statusType = StatusBadgeType.WARNING - } - - else -> { - statusText = stringResource(R.string.system_status_active) - statusType = StatusBadgeType.SUCCESS - } - } - StatusBadge(text = statusText, status = statusType) - } - - // Interval info - curator.interval_hours?.let { hrs -> - InfoRow( - label = stringResource(R.string.system_curator_interval, hrs), - value = "", - ) - Spacer(modifier = Modifier.height(spacing.xs)) - } - - // Last run - curator.last_run_at?.let { lastRun -> - InfoRow( - label = stringResource(R.string.system_curator_last_run, lastRun), - value = "", - ) - } ?: InfoRow( - label = stringResource(R.string.system_curator_never_run), - value = "", - ) - - // Actions - if (curator.enabled == true) { - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedButton( - onClick = { viewModel.toggleCuratorPaused() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (curator.paused == true) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_curator_resume), - ) - } else { - ActionButtonContent( - icon = Icons.Filled.Pause, - text = stringResource(R.string.system_curator_pause), - ) - } - } - Button( - onClick = { viewModel.runCuratorNow() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Refresh, - text = stringResource(R.string.system_curator_run_now), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.gatewaySection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.status?.let { status -> - item { - SectionHeader(title = stringResource(R.string.system_sec_gateway)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Status badge + version - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val isRunning = status.gateway_running == true - StatusBadge( - text = - if (isRunning) { - stringResource(R.string.system_status_running) - } else { - stringResource(R.string.system_status_stopped) - }, - status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - } - } - - // Version · Sessions · PID - status.version?.let { ver -> - val parts = mutableListOf(ver) - status.active_sessions?.let { parts.add("$it session(s)") } - state.doctorReport?.let { report -> - if (report.ok && report.pid != null) { - parts.add("pid ${report.pid}") - } - } - InfoRow( - label = stringResource(R.string.system_label_hermes_version), - value = parts.joinToString(" · "), - ) - } - - // Auth required - status.auth_required?.let { auth -> - InfoRow( - label = stringResource(R.string.system_auth_required), - value = if (auth) "yes" else "no", - ) - } - - // Active platforms - status.gateway_platforms?.let { platforms -> - if (platforms.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - platforms.forEach { (name, platform) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Icon( - Icons.Filled.Devices, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - platform?.state?.let { platformState -> - StatusBadge( - text = platformState, - status = - when (platformState.lowercase()) { - "connected", "running", "ready" -> StatusBadgeType.SUCCESS - "error", "disconnected", "failed" -> StatusBadgeType.ERROR - else -> StatusBadgeType.NEUTRAL - }, - ) - } - } - } - } - } - - // Action buttons - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val isRunning = status.gateway_running == true - if (!isRunning) { - Button( - onClick = { viewModel.startGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_gateway_start), - ) - } - } - if (isRunning) { - Button( - onClick = { viewModel.restartGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.RestartAlt, - text = stringResource(R.string.system_gateway_restart), - ) - } - OutlinedButton( - onClick = { viewModel.stopGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Stop, - text = stringResource(R.string.system_gateway_stop), - iconTint = MaterialTheme.colorScheme.error, - textColor = MaterialTheme.colorScheme.error, - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.credentialsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - credToRemove: Pair?, - onRemoveRequest: (Pair) -> Unit, -) { - item { - SectionHeader( - title = stringResource(R.string.system_sec_credentials), - trailing = { - val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true } - if (hasCreds) { - Text( - text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - ) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Add credential form - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedTextField( - value = state.credProvider, - onValueChange = viewModel::updateCredProvider, - label = { Text(stringResource(R.string.system_credentials_provider)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedTextField( - value = state.credKey, - onValueChange = viewModel::updateCredKey, - label = { Text(stringResource(R.string.system_credentials_api_key)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - OutlinedTextField( - value = state.credLabel, - onValueChange = viewModel::updateCredLabel, - label = { Text(stringResource(R.string.system_credentials_label)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Button( - onClick = { viewModel.addCredential() }, - enabled = !state.addingCred && state.credKey.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { - if (state.addingCred) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } else { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Provider list with entries - if (state.credentials.isEmpty()) { - Text( - text = stringResource(R.string.system_credentials_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.sm), - ) - } - - state.credentials.forEach { provider -> - Text( - text = provider.provider ?: "?", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(vertical = spacing.xs), - ) - provider.entries?.forEach { entry -> - CredentialEntryRow( - entry = entry, - providerName = provider.provider ?: "", - onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) }, - spacing = spacing, - ) - } - } - } - } - } -} - -private fun LazyListScope.operationsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - importConfirm: Boolean, - onImportConfirm: (Boolean) -> Unit, - onImportPick: () -> Unit, - downloadBackup: () -> Unit, -) { - item { - SectionHeader(title = stringResource(R.string.system_sec_operations)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Operation buttons row 1 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilledTonalButton( - onClick = { - // loadAll already fetches doctor report, just call it - viewModel.loadAll() - }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.HealthAndSafety, - text = stringResource(R.string.system_op_doctor), - ) - } - FilledTonalButton( - onClick = { viewModel.runSecurityAudit() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.VerifiedUser, - text = stringResource(R.string.system_op_security_audit), - ) - } - FilledTonalButton( - onClick = { viewModel.runUpdateSkills() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Refresh, - text = stringResource(R.string.system_op_update_skills), - ) - } - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - // Operation buttons row 2 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilledTonalButton( - onClick = { viewModel.runPromptSize() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.HealthAndSafety, - text = stringResource(R.string.system_op_prompt_size), - ) - } - FilledTonalButton( - onClick = { viewModel.runDump() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Build, - text = stringResource(R.string.system_op_dump), - ) - } - FilledTonalButton( - onClick = { viewModel.runConfigMigrate() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Build, - text = stringResource(R.string.system_op_config_migrate), - ) - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Backup section - Text( - text = stringResource(R.string.system_sec_backup), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Button( - onClick = { viewModel.triggerBackup() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Backup, - text = stringResource(R.string.system_op_backup_create), - ) - } - OutlinedButton( - onClick = downloadBackup, - enabled = state.backupArchive != null && !state.isDownloading, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (state.isDownloading) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } else { - Icon( - Icons.Filled.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text( - text = stringResource(R.string.system_op_backup_download), - maxLines = 1, - softWrap = false, - ) - } - } - - // Restore from picked archive (issue #786: SAF picker → upload) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.system_sec_restore), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - OutlinedButton( - onClick = onImportPick, - enabled = !state.isImporting, - modifier = Modifier.fillMaxWidth(), - contentPadding = actionButtonPadding, - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text( - text = stringResource(R.string.system_op_restore_pick), - maxLines = 1, - softWrap = false, - ) - } - - state.importFileName?.let { name -> - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - Spacer(modifier = Modifier.height(spacing.sm)) - - Button( - onClick = { onImportConfirm(true) }, - enabled = state.importFileName != null && !state.isImporting, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isImporting) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onError, - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } else { - Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Debug share section - SectionHeader(title = stringResource(R.string.system_sec_debug_share)) - Spacer(modifier = Modifier.height(spacing.sm)) - - Text( - text = stringResource(R.string.system_debug_share_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - androidx.compose.material3.Switch( - checked = state.shareRedact, - onCheckedChange = { viewModel.toggleShareRedact() }, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - Text( - text = stringResource(R.string.system_debug_share_redact), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = spacing.xs), - ) - - Spacer(modifier = Modifier.height(spacing.sm)) - - Button( - onClick = { viewModel.runDebugShare() }, - enabled = !state.sharing, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.sharing) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } else { - Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text( - if (state.sharing) { - stringResource(R.string.system_debug_share_uploading) - } else { - stringResource(R.string.system_debug_share_generate) - }, - maxLines = 1, - softWrap = false, - ) - } - - // Debug share results - state.debugShare?.let { share -> - Spacer(modifier = Modifier.height(spacing.sm)) - @Suppress("DEPRECATION") - val clipboardManager = LocalClipboardManager.current - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatusBadge( - text = - if (share.ok == true) { - stringResource(R.string.system_debug_share_uploaded) - } else { - stringResource(R.string.system_debug_share_failed) - }, - status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - share.redacted?.let { redacted -> - StatusBadge( - text = - if (redacted) { - stringResource(R.string.system_debug_share_redacted) - } else { - stringResource(R.string.system_debug_share_not_redacted) - }, - status = StatusBadgeType.INFO, - ) - } - share.auto_delete_seconds?.let { secs -> - StatusBadge( - text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600), - status = StatusBadgeType.NEUTRAL, - ) - } - } - - share.urls?.forEach { (name, url) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "$name: $url", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { - @Suppress("DEPRECATION") - clipboardManager.setText(AnnotatedString(url)) - }) { - Icon( - Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.system_debug_share_copy_all), - modifier = Modifier.size(16.dp), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.checkpointsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - pruneConfirm: Boolean, - onPruneConfirm: (Boolean) -> Unit, -) { - state.checkpoints?.let { checkpoints -> - item { - SectionHeader(title = stringResource(R.string.system_sec_checkpoints)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - val sessionsCount = checkpoints.sessions?.size ?: 0 - val totalBytes = checkpoints.total_bytes ?: 0L - InfoRow( - label = "", - value = - stringResource( - R.string.system_checkpoints_summary, - sessionsCount, - formatBytes(totalBytes), - ), - ) - - Spacer(modifier = Modifier.height(spacing.sm)) - Button( - onClick = { onPruneConfirm(true) }, - enabled = sessionsCount > 0, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false) - } - } - } - } - } -} - -private fun LazyListScope.shellHooksSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, -) { - val hooks = state.hooks?.hooks ?: emptyList() - - item { - SectionHeader( - title = stringResource(R.string.system_sec_hooks), - trailing = { - FilledTonalButton(onClick = { viewModel.toggleHookModal() }) { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false) - } - }, - ) - } - - if (hooks.isEmpty()) { - item { - Text( - text = stringResource(R.string.system_hooks_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm), - ) - } - } - - items(hooks, key = { "${it.event}:${it.command}" }) { hook -> - HookCard(hook = hook, spacing = spacing, onDelete = { - viewModel.deleteHook(hook.event ?: "", hook.command ?: "") - }) - } - - // Hook creation modal - if (state.hookModalOpen) { - item { - AlertDialog( - onDismissRequest = { viewModel.toggleHookModal() }, - title = { Text(stringResource(R.string.system_hooks_modal_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { - OutlinedTextField( - value = state.hookEvent, - onValueChange = viewModel::updateHookEvent, - label = { Text(stringResource(R.string.system_hooks_field_event)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookCommand, - onValueChange = viewModel::updateHookCommand, - label = { Text(stringResource(R.string.system_hooks_field_command)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookMatcher, - onValueChange = viewModel::updateHookMatcher, - label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookTimeout, - onValueChange = viewModel::updateHookTimeout, - label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.Switch( - checked = state.hookApprove, - onCheckedChange = viewModel::updateHookApprove, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = stringResource(R.string.system_hooks_field_approve), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = stringResource(R.string.system_hooks_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - }, - confirmButton = { - TextButton( - onClick = { viewModel.createHook() }, - enabled = !state.creatingHook && state.hookCommand.isNotBlank(), - ) { - if (state.creatingHook) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } - Text(stringResource(R.string.system_hooks_create)) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.toggleHookModal() }) { - Text(stringResource(R.string.system_confirm_cancel)) - } - }, - ) - } - } -} - -private fun LazyListScope.actionLogSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, -) { - item { - SectionHeader( - title = stringResource(R.string.system_action_log_title), - trailing = { - TextButton(onClick = { viewModel.closeActionLog() }) { - Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Action title - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Text( - text = state.activeAction ?: "", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - val actionLog = state.actionLog - if (actionLog != null) { - if (actionLog.running == true) { - StatusBadge( - text = stringResource(R.string.system_action_log_running), - status = StatusBadgeType.INFO, - ) - } else { - StatusBadge( - text = stringResource(R.string.system_action_log_done), - status = StatusBadgeType.SUCCESS, - ) - actionLog.exit_code?.let { code -> - Spacer(modifier = Modifier.size(spacing.xs)) - StatusBadge( - text = stringResource(R.string.system_action_log_exit, code), - status = - if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - } - } - } else { - StatusBadge( - text = stringResource(R.string.system_action_log_starting), - status = StatusBadgeType.NEUTRAL, - ) - } - } - - // Output lines - state.actionLog?.lines?.let { lines -> - if (lines.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(spacing.sm)) { - lines.take(20).forEach { line -> - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (lines.size > 20) { - Text( - text = stringResource(R.string.system_more_lines, lines.size - 20), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } - } - } -} - -private val actionButtonPadding = - androidx.compose.foundation.layout - .PaddingValues(horizontal = 8.dp, vertical = 8.dp) - -@Composable -private fun ActionButtonContent( - icon: ImageVector?, - text: String, - iconTint: androidx.compose.ui.graphics.Color = androidx.compose.material3.LocalContentColor.current, - textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified, - loading: Boolean = false, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - if (loading) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else if (icon != null) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint) - } - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = textColor, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** Staged SAF-picked backup archive for the restore flow (issue #786). */ -private data class PendingImport( - val uri: Uri, - val name: String, - val mime: String, -) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt new file mode 100644 index 00000000..59cb4a6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt @@ -0,0 +1,55 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +val ActionButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + +@Composable +fun ActionButtonContent( + icon: ImageVector?, + text: String, + iconTint: Color = LocalContentColor.current, + textColor: Color = Color.Unspecified, + loading: Boolean = false, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else if (icon != null) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt new file mode 100644 index 00000000..20a58659 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt @@ -0,0 +1,135 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.actionLogSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + item { + SectionHeader( + title = stringResource(R.string.system_action_log_title), + trailing = { + TextButton(onClick = { viewModel.closeActionLog() }) { + Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Action title + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text( + text = state.activeAction ?: "", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + val actionLog = state.actionLog + if (actionLog != null) { + if (actionLog.running == true) { + StatusBadge( + text = stringResource(R.string.system_action_log_running), + status = StatusBadgeType.INFO, + ) + } else { + StatusBadge( + text = stringResource(R.string.system_action_log_done), + status = StatusBadgeType.SUCCESS, + ) + actionLog.exit_code?.let { code -> + Spacer(modifier = Modifier.size(spacing.xs)) + StatusBadge( + text = stringResource(R.string.system_action_log_exit, code), + status = + if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + } + } + } else { + StatusBadge( + text = stringResource(R.string.system_action_log_starting), + status = StatusBadgeType.NEUTRAL, + ) + } + } + + // Output lines + state.actionLog?.lines?.let { lines -> + if (lines.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(spacing.sm)) { + lines.take(20).forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (lines.size > 20) { + Text( + text = stringResource(R.string.system_more_lines, lines.size - 20), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt new file mode 100644 index 00000000..2659c158 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.util.formatBytes + +fun LazyListScope.checkpointsSection( + state: SystemUiState, + spacing: Spacing, + onPruneConfirm: (Boolean) -> Unit, +) { + state.checkpoints?.let { checkpoints -> + item { + SectionHeader(title = stringResource(R.string.system_sec_checkpoints)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + val sessionsCount = checkpoints.sessions?.size ?: 0 + val totalBytes = checkpoints.total_bytes ?: 0L + InfoRow( + label = "", + value = + stringResource( + R.string.system_checkpoints_summary, + sessionsCount, + formatBytes(totalBytes), + ), + ) + + Spacer(modifier = Modifier.height(spacing.sm)) + Button( + onClick = { onPruneConfirm(true) }, + enabled = sessionsCount > 0, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt new file mode 100644 index 00000000..8ab04680 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.credentialsSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, + onRemoveRequest: (Pair) -> Unit, +) { + item { + SectionHeader( + title = stringResource(R.string.system_sec_credentials), + trailing = { + val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true } + if (hasCreds) { + Text( + text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Add credential form + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedTextField( + value = state.credProvider, + onValueChange = viewModel::updateCredProvider, + label = { Text(stringResource(R.string.system_credentials_provider)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedTextField( + value = state.credKey, + onValueChange = viewModel::updateCredKey, + label = { Text(stringResource(R.string.system_credentials_api_key)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + value = state.credLabel, + onValueChange = viewModel::updateCredLabel, + label = { Text(stringResource(R.string.system_credentials_label)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Button( + onClick = { viewModel.addCredential() }, + enabled = !state.addingCred && state.credKey.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (state.addingCred) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } else { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Provider list with entries + if (state.credentials.isEmpty()) { + Text( + text = stringResource(R.string.system_credentials_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.sm), + ) + } + + state.credentials.forEach { provider -> + Text( + text = provider.provider ?: "?", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = spacing.xs), + ) + provider.entries?.forEach { entry -> + CredentialEntryRow( + entry = entry, + providerName = provider.provider ?: "", + onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) }, + spacing = spacing, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt new file mode 100644 index 00000000..8ea2fc39 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.curatorSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.curator?.let { curator -> + item { + SectionHeader(title = stringResource(R.string.system_sec_curator)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val statusText: String + val statusType: StatusBadgeType + when { + curator.enabled != true -> { + statusText = stringResource(R.string.system_status_disabled) + statusType = StatusBadgeType.ERROR + } + + curator.paused == true -> { + statusText = stringResource(R.string.system_status_paused) + statusType = StatusBadgeType.WARNING + } + + else -> { + statusText = stringResource(R.string.system_status_active) + statusType = StatusBadgeType.SUCCESS + } + } + StatusBadge(text = statusText, status = statusType) + } + + // Interval info + curator.interval_hours?.let { hrs -> + InfoRow( + label = stringResource(R.string.system_curator_interval, hrs), + value = "", + ) + Spacer(modifier = Modifier.height(spacing.xs)) + } + + // Last run + curator.last_run_at?.let { lastRun -> + InfoRow( + label = stringResource(R.string.system_curator_last_run, lastRun), + value = "", + ) + } ?: InfoRow( + label = stringResource(R.string.system_curator_never_run), + value = "", + ) + + // Actions + if (curator.enabled == true) { + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedButton( + onClick = { viewModel.toggleCuratorPaused() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (curator.paused == true) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_curator_resume), + ) + } else { + ActionButtonContent( + icon = Icons.Filled.Pause, + text = stringResource(R.string.system_curator_pause), + ) + } + } + Button( + onClick = { viewModel.runCuratorNow() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Refresh, + text = stringResource(R.string.system_curator_run_now), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt new file mode 100644 index 00000000..35262e2d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt @@ -0,0 +1,187 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.gatewaySection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.status?.let { status -> + item { + SectionHeader(title = stringResource(R.string.system_sec_gateway)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Status badge + version + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val isRunning = status.gateway_running == true + StatusBadge( + text = + if (isRunning) { + stringResource(R.string.system_status_running) + } else { + stringResource(R.string.system_status_stopped) + }, + status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + } + } + + // Version · Sessions · PID + status.version?.let { ver -> + val parts = mutableListOf(ver) + status.active_sessions?.let { parts.add("$it session(s)") } + state.doctorReport?.let { report -> + if (report.ok && report.pid != null) { + parts.add("pid ${report.pid}") + } + } + InfoRow( + label = stringResource(R.string.system_label_hermes_version), + value = parts.joinToString(" · "), + ) + } + + // Auth required + status.auth_required?.let { auth -> + InfoRow( + label = stringResource(R.string.system_auth_required), + value = if (auth) "yes" else "no", + ) + } + + // Active platforms + status.gateway_platforms?.let { platforms -> + if (platforms.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + platforms.forEach { (name, platform) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Icon( + Icons.Filled.Devices, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + platform?.state?.let { platformState -> + StatusBadge( + text = platformState, + status = + when (platformState.lowercase()) { + "connected", "running", "ready" -> StatusBadgeType.SUCCESS + "error", "disconnected", "failed" -> StatusBadgeType.ERROR + else -> StatusBadgeType.NEUTRAL + }, + ) + } + } + } + } + } + + // Action buttons + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val isRunning = status.gateway_running == true + if (!isRunning) { + Button( + onClick = { viewModel.startGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_gateway_start), + ) + } + } + if (isRunning) { + Button( + onClick = { viewModel.restartGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.RestartAlt, + text = stringResource(R.string.system_gateway_restart), + ) + } + OutlinedButton( + onClick = { viewModel.stopGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Stop, + text = stringResource(R.string.system_gateway_stop), + iconTint = MaterialTheme.colorScheme.error, + textColor = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt new file mode 100644 index 00000000..5a55d978 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun HookCreateDialog( + isOpen: Boolean, + event: String, + command: String, + matcher: String, + timeout: String, + approve: Boolean, + isCreating: Boolean, + onEventChange: (String) -> Unit, + onCommandChange: (String) -> Unit, + onMatcherChange: (String) -> Unit, + onTimeoutChange: (String) -> Unit, + onApproveChange: (Boolean) -> Unit, + onCreate: () -> Unit, + onDismiss: () -> Unit, +) { + if (!isOpen) return + val spacing = LocalSpacing.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.system_hooks_modal_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { + OutlinedTextField( + value = event, + onValueChange = onEventChange, + label = { Text(stringResource(R.string.system_hooks_field_event)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = command, + onValueChange = onCommandChange, + label = { Text(stringResource(R.string.system_hooks_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = matcher, + onValueChange = onMatcherChange, + label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = timeout, + onValueChange = onTimeoutChange, + label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = approve, + onCheckedChange = onApproveChange, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = stringResource(R.string.system_hooks_field_approve), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(R.string.system_hooks_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton( + onClick = onCreate, + enabled = !isCreating && command.isNotBlank(), + ) { + if (isCreating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } + Text(stringResource(R.string.system_hooks_create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt new file mode 100644 index 00000000..6a85336b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt @@ -0,0 +1,234 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel +import com.m57.hermescontrol.util.formatBytes +import com.m57.hermescontrol.util.formatDuration + +@OptIn(ExperimentalLayoutApi::class) +fun LazyListScope.hostSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.stats?.let { stats -> + item { + SectionHeader(title = stringResource(R.string.system_sec_host)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // OS, Arch, Hostname + stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) } + stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) } + stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) } + stats.python_version?.let { python -> + val impl = stats.python_impl?.let { " ($it)" } ?: "" + InfoRow(stringResource(R.string.system_label_python), "$python$impl") + } + + // Hermes version + update badge + stats.hermes_version?.let { ver -> + val updateBadge = + state.updateInfo?.let { info -> + when { + info.update_available == true && info.behind != null && info.behind > 0 -> { + " (${stringResource( + R.string.system_version_update_available, + )}: ${stringResource(R.string.system_version_behind, info.behind)})" + } + + info.update_available == true -> { + " (${stringResource( + R.string.system_version_update_available, + )})" + } + + else -> { + " (${stringResource(R.string.system_version_latest)})" + } + } + } ?: "" + InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge") + } + + // Update buttons + Row( + modifier = Modifier.fillMaxWidth().padding(top = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedButton( + onClick = { viewModel.checkForUpdate(false) }, + enabled = !state.checkingUpdate, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (state.checkingUpdate) { + ActionButtonContent( + icon = null, + text = stringResource(R.string.system_action_check_updates), + loading = true, + ) + } else { + ActionButtonContent( + icon = Icons.Filled.Update, + text = stringResource(R.string.system_action_check_updates), + ) + } + } + state.updateInfo?.let { info -> + if (info.update_available == true && info.can_apply == true) { + Button( + onClick = { viewModel.openUpdateConfirm() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_action_update_now), + ) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // CPU / Memory / Disk stat cards + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + stats.cpu_percent?.let { cpu -> + StatCard( + label = stringResource(R.string.system_label_cpu), + value = "${cpu.toInt()}%", + icon = Icons.Filled.Speed, + modifier = Modifier.weight(1f), + ) + } + stats.memory?.percent?.let { mem -> + StatCard( + label = stringResource(R.string.system_label_memory), + value = "${mem.toInt()}%", + icon = Icons.Filled.Memory, + modifier = Modifier.weight(1f), + ) + } + stats.disk?.percent?.let { disk -> + StatCard( + label = stringResource(R.string.system_label_disk), + value = "${disk.toInt()}%", + icon = Icons.Filled.Storage, + modifier = Modifier.weight(1f), + ) + } + } + + // Memory details + stats.memory?.let { mem -> + InfoRow( + label = stringResource(R.string.system_label_memory), + value = + stringResource( + R.string.system_label_used_total_percent, + formatBytes(mem.used ?: 0L), + formatBytes(mem.total ?: 0L), + "${(mem.percent ?: 0.0).toInt()}", + ), + ) + } + + // Disk details + stats.disk?.let { disk -> + InfoRow( + label = stringResource(R.string.system_label_disk), + value = + stringResource( + R.string.system_label_used_total_percent, + formatBytes(disk.used ?: 0L), + formatBytes(disk.total ?: 0L), + "${(disk.percent ?: 0.0).toInt()}", + ), + ) + } + + // Uptime + stats.uptime_seconds?.let { secs -> + InfoRow( + label = stringResource(R.string.system_label_uptime), + value = formatDuration(secs), + ) + } + + // Load avg + stats.load_avg?.let { loads -> + if (loads.isNotEmpty()) { + val coresText = + stats.cpu_count?.let { + " ($it ${stringResource( + R.string.system_label_cores, + )})" + } ?: "" + InfoRow( + label = stringResource(R.string.system_label_load_avg), + value = loads.joinToString(", ") { "%.2f".format(it) } + coresText, + ) + } + } ?: stats.cpu_count?.let { cores -> + InfoRow( + label = stringResource(R.string.system_label_cpu), + value = "$cores ${stringResource(R.string.system_label_cores)}", + ) + } + + // psutil warning + if (stats.psutil == false) { + Spacer(modifier = Modifier.height(spacing.sm)) + StatusBadge( + text = stringResource(R.string.system_psutil_warning), + status = StatusBadgeType.WARNING, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt new file mode 100644 index 00000000..892e8082 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt @@ -0,0 +1,392 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.operationsSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, + onImportConfirm: (Boolean) -> Unit, + onImportPick: () -> Unit, + downloadBackup: () -> Unit, +) { + item { + SectionHeader(title = stringResource(R.string.system_sec_operations)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Operation buttons row 1 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { viewModel.loadAll() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.HealthAndSafety, + text = stringResource(R.string.system_op_doctor), + ) + } + FilledTonalButton( + onClick = { viewModel.runSecurityAudit() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.VerifiedUser, + text = stringResource(R.string.system_op_security_audit), + ) + } + FilledTonalButton( + onClick = { viewModel.runUpdateSkills() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Refresh, + text = stringResource(R.string.system_op_update_skills), + ) + } + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + // Operation buttons row 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { viewModel.runPromptSize() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.HealthAndSafety, + text = stringResource(R.string.system_op_prompt_size), + ) + } + FilledTonalButton( + onClick = { viewModel.runDump() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Build, + text = stringResource(R.string.system_op_dump), + ) + } + FilledTonalButton( + onClick = { viewModel.runConfigMigrate() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Build, + text = stringResource(R.string.system_op_config_migrate), + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Backup section + Text( + text = stringResource(R.string.system_sec_backup), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Button( + onClick = { viewModel.triggerBackup() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Backup, + text = stringResource(R.string.system_op_backup_create), + ) + } + OutlinedButton( + onClick = downloadBackup, + enabled = state.backupArchive != null && !state.isDownloading, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (state.isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } else { + Icon( + Icons.Filled.CloudDownload, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text( + text = stringResource(R.string.system_op_backup_download), + maxLines = 1, + softWrap = false, + ) + } + } + + // Restore from picked archive (issue #786: SAF picker → upload) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.system_sec_restore), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedButton( + onClick = onImportPick, + enabled = !state.isImporting, + modifier = Modifier.fillMaxWidth(), + contentPadding = ActionButtonPadding, + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text( + text = stringResource(R.string.system_op_restore_pick), + maxLines = 1, + softWrap = false, + ) + } + + state.importFileName?.let { name -> + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(modifier = Modifier.height(spacing.sm)) + + Button( + onClick = { onImportConfirm(true) }, + enabled = state.importFileName != null && !state.isImporting, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isImporting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onError, + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } else { + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Debug share section + SectionHeader(title = stringResource(R.string.system_sec_debug_share)) + Spacer(modifier = Modifier.height(spacing.sm)) + + Text( + text = stringResource(R.string.system_debug_share_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + Switch( + checked = state.shareRedact, + onCheckedChange = { viewModel.toggleShareRedact() }, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + Text( + text = stringResource(R.string.system_debug_share_redact), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = spacing.xs), + ) + + Spacer(modifier = Modifier.height(spacing.sm)) + + Button( + onClick = { viewModel.runDebugShare() }, + enabled = !state.sharing, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.sharing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } else { + Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text( + if (state.sharing) { + stringResource(R.string.system_debug_share_uploading) + } else { + stringResource(R.string.system_debug_share_generate) + }, + maxLines = 1, + softWrap = false, + ) + } + + // Debug share results + state.debugShare?.let { share -> + Spacer(modifier = Modifier.height(spacing.sm)) + @Suppress("DEPRECATION") + val clipboardManager = LocalClipboardManager.current + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatusBadge( + text = + if (share.ok == true) { + stringResource(R.string.system_debug_share_uploaded) + } else { + stringResource(R.string.system_debug_share_failed) + }, + status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + share.redacted?.let { redacted -> + StatusBadge( + text = + if (redacted) { + stringResource(R.string.system_debug_share_redacted) + } else { + stringResource(R.string.system_debug_share_not_redacted) + }, + status = StatusBadgeType.INFO, + ) + } + share.auto_delete_seconds?.let { secs -> + StatusBadge( + text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600), + status = StatusBadgeType.NEUTRAL, + ) + } + } + + share.urls?.forEach { (name, url) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$name: $url", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { + @Suppress("DEPRECATION") + clipboardManager.setText(AnnotatedString(url)) + }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.system_debug_share_copy_all), + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt new file mode 100644 index 00000000..37dd7720 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt @@ -0,0 +1,115 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState + +fun LazyListScope.portalSection( + state: SystemUiState, + spacing: Spacing, + uriHandler: UriHandler, +) { + state.portal?.let { portal -> + item { + SectionHeader(title = stringResource(R.string.system_sec_portal)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Login status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val loggedIn = portal.logged_in == true + StatusBadge( + text = + if (loggedIn) { + stringResource(R.string.system_status_logged_in) + } else { + stringResource(R.string.system_status_not_logged_in) + }, + status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + if (!loggedIn) { + Text( + text = stringResource(R.string.system_portal_login_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Provider + portal.provider?.let { provider -> + InfoRow( + label = stringResource(R.string.system_portal_provider, provider), + value = "", + ) + } + + // Manage subscription link + portal.subscription_url?.let { url -> + TextButton( + onClick = { uriHandler.openUri(url) }, + modifier = Modifier.padding(top = spacing.xs), + ) { + Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text( + stringResource(R.string.system_portal_manage_subscription), + maxLines = 1, + softWrap = false, + ) + } + } + + // Feature routing + portal.features?.let { features -> + if (features.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + features.forEach { feature -> + InfoRow( + label = feature.label ?: stringResource(R.string.system_portal_feature_routing), + value = feature.state ?: "", + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt new file mode 100644 index 00000000..66c9db3e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.shellHooksSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + val hooks = state.hooks?.hooks ?: emptyList() + + item { + SectionHeader( + title = stringResource(R.string.system_sec_hooks), + trailing = { + FilledTonalButton(onClick = { viewModel.toggleHookModal() }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false) + } + }, + ) + } + + if (hooks.isEmpty()) { + item { + Text( + text = stringResource(R.string.system_hooks_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm), + ) + } + } + + items(hooks, key = { "${it.event}:${it.command}" }) { hook -> + HookCard(hook = hook, spacing = spacing, onDelete = { + viewModel.deleteHook(hook.event ?: "", hook.command ?: "") + }) + } +} From 3d711c5a5184f07224a7d15032e154864461b991 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:28:52 +0300 Subject: [PATCH 12/17] refactor(sessions): decompose SessionsScreen into focused components Extract SessionCard, SearchResultCard, SessionActionMenu, SessionsStatsRow, SessionsBulkActionBar, SessionsDialogs, and AutomationGroupList. --- .../ui/sessions/SessionsScreen.kt | 680 +----------------- .../components/AutomationGroupList.kt | 90 +++ .../sessions/components/SearchResultCard.kt | 198 +++++ .../sessions/components/SessionActionMenu.kt | 109 +++ .../ui/sessions/components/SessionCard.kt | 285 ++++++++ .../components/SessionsBulkActionBar.kt | 127 ++++ .../ui/sessions/components/SessionsDialogs.kt | 152 ++++ .../sessions/components/SessionsStatsRow.kt | 98 +++ 8 files changed, 1098 insertions(+), 641 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index bd912149..0de2a4cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,6 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.sessions.components.SearchResultCard +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar +import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs +import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow +import com.m57.hermescontrol.ui.sessions.components.automationGroups /** * Auto-load the next history page when the user scrolls to within this many @@ -680,65 +686,14 @@ fun SessionsScreen( else -> { Column(modifier = Modifier.fillMaxSize()) { // ── Stats row ─────────────────────────────────────── - Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatCard( - label = stringResource(R.string.sessions_stat_total), - value = formatCompactCount(state.total), - icon = Icons.Filled.History, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - StatCard( - label = stringResource(R.string.sessions_stat_messages_loaded), - value = formatCompactCount(loadedMessageCount), - icon = Icons.Filled.Email, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - // Prune button — compact card sized to its label, so the - // stat cards keep enough width for large counts. - if (state.section == HistorySection.CONVERSATIONS) { - Card( - modifier = Modifier.fillMaxHeight(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - onClick = { viewModel.showPruneDialog() }, - ) { - Box( - // NOTE: fillMaxHeight only — never fillMaxSize here. - // An unweighted Row child with fillMaxSize expands to the - // full row width and starves the weighted stat cards. - modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Icon( - imageVector = Icons.Filled.DeleteSweep, - contentDescription = null, - tint = statusColors.warning, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.sessions_action_prune), - style = MaterialTheme.typography.labelMedium, - color = statusColors.warning, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - } - } + SessionsStatsRow( + total = state.total, + loadedMessageCount = loadedMessageCount, + section = state.section, + spacing = spacing, + statusColors = statusColors, + onPruneClick = { viewModel.showPruneDialog() }, + ) // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() @@ -822,66 +777,21 @@ fun SessionsScreen( verticalArrangement = listItemSpacing, ) { if (state.section == HistorySection.AUTOMATIONS) { - automationSessionGroups.forEach { group -> - item(key = "automation-group:${group.key}") { - val expanded = group.key in expandedAutomationGroups - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { - expandedAutomationGroups = - if (expanded) { - expandedAutomationGroups - group.key - } else { - expandedAutomationGroups + group.key - } - }.padding(horizontal = spacing.sm, vertical = spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = - if (expanded) { - Icons.Filled.KeyboardArrowDown - } else { - Icons.AutoMirrored.Filled.KeyboardArrowRight - }, - contentDescription = null, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Column { - Text( - text = - group.title - ?: group.jobId?.let { - stringResource( - R.string.sessions_section_automation_job, - it, - ) - } ?: stringResource( - R.string.sessions_section_automation_unknown, - ), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - stringResource( - R.string.sessions_section_automation_runs, - group.sessions.size, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + automationGroups( + groups = automationSessionGroups, + expandedGroups = expandedAutomationGroups, + sessionItemsById = sessionItemsById, + spacing = spacing, + onToggleGroup = { groupKey -> + expandedAutomationGroups = + if (groupKey in expandedAutomationGroups) { + expandedAutomationGroups - groupKey + } else { + expandedAutomationGroups + groupKey } - } - } - if (group.key in expandedAutomationGroups) { - items(group.sessions, key = { it.id }) { session -> - sessionItemsById[session.id]?.let { sessionCard(it) } - } - } - } + }, + renderSessionCard = sessionCard, + ) } else { items(sessionsToDisplay, key = { it.session.id }) { item -> sessionCard(item) @@ -927,533 +837,21 @@ fun SessionsScreen( } // ── Bulk action toolbar (animated) ────────────────────────────────── - AnimatedVisibility( + SessionsBulkActionBar( visible = state.isSelecting && hasSelection, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shadowElevation = 8.dp, - tonalElevation = 4.dp, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Select all / deselect - OutlinedButton( - onClick = { - if (allVisibleSessionsSelected) { - viewModel.clearSelection() - } else { - viewModel.selectAll(visibleSessionIds) - } - }, - ) { - Icon( - imageVector = - if (allVisibleSessionsSelected) { - Icons.Filled.Close - } else { - Icons.Filled.SelectAll - }, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - if (allVisibleSessionsSelected) { - stringResource(R.string.sessions_action_deselect_all) - } else { - stringResource(R.string.sessions_action_select_all) - }, - ) - } - - // Delete selected - Button( - onClick = { viewModel.requestBulkDelete() }, - enabled = !state.isDeletingBulk, - colors = - ButtonDefaults.buttonColors( - containerColor = statusColors.error, - ), - ) { - if (state.isDeletingBulk) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = statusColors.onError, - ) - } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - stringResource( - R.string.sessions_action_delete_n, - state.selectedIds.size, - ), - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SessionCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - displayTitle: String, - branchStem: String?, - isFork: Boolean = false, - forkDepth: Int = 0, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - isPinned: Boolean, - isHidden: Boolean = false, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onTogglePin: () -> Unit, - onToggleHide: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current - val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" - val srcIcon = sourceIcon(session.source) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785): - // Select / Rename / Delete. The old long-press behavior - // (jump straight into selection mode) is now the menu's - // "Select" item. - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isActive && !isSelecting) { - BorderStroke(2.dp, statusColors.success) - } else if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Fork marker: a fork icon + its nesting depth, so a chain reads - // 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the - // number carries the structure so names never get squished. - if (isFork && !isSelecting) { - Icon( - imageVector = Icons.AutoMirrored.Filled.CallSplit, - contentDescription = stringResource(R.string.sessions_fork_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - if (forkDepth > 0) { - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = forkDepth.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Source icon - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Main content - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - if (query.isNotBlank()) { - highlightText(displayTitle, query, highlightBackground, highlightForeground) - } else { - AnnotatedString(displayTitle) - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (isPinned) { - Spacer(modifier = Modifier.width(spacing.xs)) - Icon( - imageVector = Icons.Filled.PushPin, - contentDescription = stringResource(R.string.sessions_pinned_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(14.dp), - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.history_message_count, session.message_count ?: 0), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (session.hidden == true) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = stringResource(R.string.sessions_hidden_badge), - status = StatusBadgeType.NEUTRAL, - ) - } - if (!session.status.isNullOrBlank()) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = session.status, - status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - isPinned = isPinned, - onTogglePin = { - menuExpanded = false - onTogglePin() - }, - isHidden = isHidden, - onToggleHide = { - menuExpanded = false - onToggleHide() - }, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Search-result card. The backend search payload has no session title, so this card is - * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus - * source / model / played-at metadata chips. It never presents the snippet as a name. - */ -@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) -@Composable -private fun SearchResultCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) - val cleanSnippet = cleanSearchSnippet(snippet) - val playedAt = formatPlayedAt(session.started_at) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785) - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.sm), - verticalAlignment = Alignment.Top, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - Column(modifier = Modifier.weight(1f)) { - // Header row: "Match" label + source icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - val srcIcon = sourceIcon(session.source) - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - } - Text( - text = stringResource(R.string.sessions_search_match_label), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - letterSpacing = 0.5.sp, - ) - if (!session.id.isNullOrBlank()) { - Text( - text = "· ${session.id.take(8)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - // The matched snippet, highlighted — shown as the body, NOT as a title. - Text( - text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(spacing.xs)) - - // Metadata chips row - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - session.source?.let { src -> - StatusBadge( - text = sourceLabel(src), - status = StatusBadgeType.NEUTRAL, - ) - } - session.model?.let { mdl -> - StatusBadge( - text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, - status = StatusBadgeType.INFO, - ) - } - playedAt?.let { time -> - StatusBadge( - text = stringResource(R.string.sessions_search_played_at, time), - status = StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete. - * Opened by long-pressing a session card. The pin item is only shown when - * [onTogglePin] is provided (history cards; search-result cards omit it). - */ -@Composable -private fun SessionActionMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDeleting: Boolean, - isPinned: Boolean = false, - onTogglePin: (() -> Unit)? = null, - isHidden: Boolean = false, - onToggleHide: (() -> Unit)? = null, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val statusColors = LocalHermesStatusColors.current - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - if (onTogglePin != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isPinned) { - R.string.sessions_action_unpin - } else { - R.string.sessions_action_pin - }, - ), - ) - }, - leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, - onClick = onTogglePin, - ) - } - if (onToggleHide != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isHidden) { - R.string.sessions_action_unhide - } else { - R.string.sessions_action_hide - }, - ), - ) - }, - leadingIcon = { - Icon( - imageVector = - if (isHidden) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = null, - ) - }, - onClick = onToggleHide, - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_select)) }, - leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, - onClick = onSelect, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_rename)) }, - leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, - onClick = onRename, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete)) }, - leadingIcon = { - if (isDeleting) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + allVisibleSessionsSelected = allVisibleSessionsSelected, + selectedCount = state.selectedIds.size, + isDeletingBulk = state.isDeletingBulk, + spacing = spacing, + statusColors = statusColors, + onSelectAllToggle = { + if (allVisibleSessionsSelected) { + viewModel.clearSelection() } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - tint = statusColors.error, - ) + viewModel.selectAll(visibleSessionIds) } }, - enabled = !isDeleting, - onClick = onDelete, + onDeleteSelected = { viewModel.requestBulkDelete() }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt new file mode 100644 index 00000000..bc49534e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt @@ -0,0 +1,90 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionTreeItem +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.sessions.AutomationSessionGroup + +internal fun LazyListScope.automationGroups( + groups: List, + expandedGroups: Set, + sessionItemsById: Map, + spacing: Spacing, + onToggleGroup: (String) -> Unit, + renderSessionCard: @Composable (SessionTreeItem) -> Unit, +) { + groups.forEach { group -> + item(key = "automation-group:${group.key}") { + val expanded = group.key in expandedGroups + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggleGroup(group.key) } + .padding(horizontal = spacing.sm, vertical = spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (expanded) { + Icons.Filled.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Column { + Text( + text = + group.title + ?: group.jobId?.let { + stringResource( + R.string.sessions_section_automation_job, + it, + ) + } ?: stringResource( + R.string.sessions_section_automation_unknown, + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = + stringResource( + R.string.sessions_section_automation_runs, + group.sessions.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (group.key in expandedGroups) { + items(group.sessions, key = { it.id }) { session -> + sessionItemsById[session.id]?.let { renderSessionCard(it) } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt new file mode 100644 index 00000000..054703d4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet +import com.m57.hermescontrol.ui.sessions.formatPlayedAt + +/** + * Search-result card. The backend search payload has no session title, so this card is + * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus + * source / model / played-at metadata chips. It never presents the snippet as a name. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +fun SearchResultCard( + session: SessionInfo, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + val cleanSnippet = cleanSearchSnippet(snippet) + val playedAt = formatPlayedAt(session.started_at) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.sm), + verticalAlignment = Alignment.Top, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + // Header row: "Match" label + source icon + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + val srcIcon = sourceIcon(session.source) + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = stringResource(R.string.sessions_search_match_label), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + if (!session.id.isNullOrBlank()) { + Text( + text = "· ${session.id.take(8)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + // The matched snippet, highlighted — shown as the body, NOT as a title. + Text( + text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(spacing.xs)) + + // Metadata chips row + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + session.source?.let { src -> + StatusBadge( + text = sourceLabel(src), + status = StatusBadgeType.NEUTRAL, + ) + } + session.model?.let { mdl -> + StatusBadge( + text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, + status = StatusBadgeType.INFO, + ) + } + playedAt?.let { time -> + StatusBadge( + text = stringResource(R.string.sessions_search_played_at, time), + status = StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + // Per-session action menu (hold the card to open) + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt new file mode 100644 index 00000000..23e0d37b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +/** + * Per-session overflow/long-press menu. + */ +@Composable +fun SessionActionMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDeleting: Boolean, + isPinned: Boolean = false, + onTogglePin: (() -> Unit)? = null, + isHidden: Boolean = false, + onToggleHide: (() -> Unit)? = null, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (onTogglePin != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isPinned) { + R.string.sessions_action_unpin + } else { + R.string.sessions_action_pin + }, + ), + ) + }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = onTogglePin, + ) + } + if (onToggleHide != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isHidden) { + R.string.sessions_action_unhide + } else { + R.string.sessions_action_hide + }, + ), + ) + }, + leadingIcon = { + Icon( + imageVector = + if (isHidden) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + }, + onClick = onToggleHide, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_select)) }, + leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, + onClick = onSelect, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_rename)) }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + onClick = onRename, + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.action_delete), + color = statusColors.error, + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = statusColors.error, + ) + }, + enabled = !isDeleting, + onClick = onDelete, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt new file mode 100644 index 00000000..9b574913 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -0,0 +1,285 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType + +fun sourceIcon(source: String?): ImageVector? = + when (source?.lowercase()) { + "telegram", "tg" -> Icons.AutoMirrored.Filled.Send + "web", "dashboard" -> Icons.Filled.Language + "api", "rest" -> Icons.Filled.Code + "cli", "terminal" -> Icons.Filled.Terminal + else -> null + } + +fun sourceLabel(source: String?): String = + when (source?.lowercase()) { + "telegram", "tg" -> "Telegram" + "web", "dashboard" -> "Web" + "api", "rest" -> "API" + "cli", "terminal" -> "CLI" + else -> source ?: "Unknown" + } + +fun highlightText( + text: String, + query: String, + highlightBackground: Color, + highlightForeground: Color, +): AnnotatedString = + buildAnnotatedString { + if (query.isBlank()) { + append(text) + return@buildAnnotatedString + } + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var currentIndex = 0 + while (currentIndex < text.length) { + val matchIndex = lowerText.indexOf(lowerQuery, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + withStyle( + SpanStyle( + background = highlightBackground, + color = highlightForeground, + fontWeight = FontWeight.Bold, + ), + ) { + append(text.substring(matchIndex, matchIndex + query.length)) + } + currentIndex = matchIndex + query.length + } + } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionCard( + session: SessionInfo, + displayTitle: String, + branchStem: String?, + isFork: Boolean = false, + forkDepth: Int = 0, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + isPinned: Boolean, + isHidden: Boolean = false, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onTogglePin: () -> Unit, + onToggleHide: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" + val srcIcon = sourceIcon(session.source) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isActive && !isSelecting) { + BorderStroke(2.dp, statusColors.success) + } else if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (isFork && !isSelecting) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, + contentDescription = stringResource(R.string.sessions_fork_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + if (forkDepth > 0) { + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = forkDepth.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + if (query.isNotBlank()) { + highlightText(displayTitle, query, highlightBackground, highlightForeground) + } else { + AnnotatedString(displayTitle) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isPinned) { + Spacer(modifier = Modifier.width(spacing.xs)) + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.sessions_pinned_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.history_message_count, session.message_count ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (session.hidden == true) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = stringResource(R.string.sessions_hidden_badge), + status = StatusBadgeType.NEUTRAL, + ) + } + if (!session.status.isNullOrBlank()) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = session.status, + status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + isPinned = isPinned, + onTogglePin = { + menuExpanded = false + onTogglePin() + }, + isHidden = isHidden, + onToggleHide = { + menuExpanded = false + onToggleHide() + }, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt new file mode 100644 index 00000000..f97bb25b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt @@ -0,0 +1,127 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing + +@Composable +fun SessionsBulkActionBar( + visible: Boolean, + allVisibleSessionsSelected: Boolean, + selectedCount: Int, + isDeletingBulk: Boolean, + spacing: Spacing, + statusColors: HermesStatusColors, + onSelectAllToggle: () -> Unit, + onDeleteSelected: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 8.dp, + tonalElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onSelectAllToggle) { + Icon( + imageVector = + if (allVisibleSessionsSelected) { + Icons.Filled.Close + } else { + Icons.Filled.SelectAll + }, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + if (allVisibleSessionsSelected) { + stringResource(R.string.sessions_action_deselect_all) + } else { + stringResource(R.string.sessions_action_select_all) + }, + ) + } + + Button( + onClick = onDeleteSelected, + enabled = !isDeletingBulk, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + if (isDeletingBulk) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = statusColors.onError, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + stringResource( + R.string.sessions_action_delete_n, + selectedCount, + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt new file mode 100644 index 00000000..58cd4bc3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.ConfirmDialog +import com.m57.hermescontrol.ui.sessions.SessionsUiState +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet + +@Composable +fun SessionsDialogs( + state: SessionsUiState, + pruneDays: String, + onPruneDaysChange: (String) -> Unit, + statusColors: HermesStatusColors, + onConfirmEmptyCleanup: () -> Unit, + onDismissEmptyCleanup: () -> Unit, + onConfirmPrune: (Int) -> Unit, + onDismissPrune: () -> Unit, + onConfirmDeleteSession: () -> Unit, + onDismissDeleteSession: () -> Unit, + onConfirmBulkDelete: () -> Unit, + onDismissBulkDelete: () -> Unit, + onRenameSession: (String, String) -> Unit, + onRenameDraftChange: (String) -> Unit, + onDismissRename: () -> Unit, +) { + val spacing = LocalSpacing.current + + // Empty-session cleanup dialog + if (state.showEmptyCleanupDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_empty_cleanup_title), + message = stringResource(R.string.sessions_empty_cleanup_message, state.emptyCount), + onConfirm = onConfirmEmptyCleanup, + onDismiss = onDismissEmptyCleanup, + confirmText = stringResource(R.string.sessions_empty_cleanup_confirm), + ) + } + + // Prune dialog + if (state.showPruneDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_prune_title), + message = stringResource(R.string.sessions_prune_desc), + onConfirm = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + onDismiss = onDismissPrune, + confirmText = stringResource(R.string.sessions_prune_confirm), + isDestructive = true, + extraContent = { + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = pruneDays, + onValueChange = onPruneDaysChange, + label = { Text(stringResource(R.string.sessions_prune_days_label)) }, + placeholder = { Text("7") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + ), + ) + }, + ) + } + + // Single-session delete confirmation dialog + if (state.sessionToDeleteConfirm != null) { + val sessionToDelete = state.sessionToDeleteConfirm + val sessionTitle = + state.sessions + .find { it.id == sessionToDelete } + ?.title + ?.takeIf { it.isNotBlank() } + ?: state.searchResults + .find { it.session_id == sessionToDelete } + ?.snippet + ?.let(::cleanSearchSnippet) + ?.take(80) + ?: stringResource(R.string.history_untitled) + + ConfirmDialog( + title = stringResource(R.string.sessions_delete_title), + message = stringResource(R.string.sessions_delete_message, sessionTitle), + onConfirm = onConfirmDeleteSession, + onDismiss = onDismissDeleteSession, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Bulk delete confirmation dialog + if (state.showBulkDeleteConfirm) { + ConfirmDialog( + title = stringResource(R.string.sessions_bulk_delete_title), + message = stringResource(R.string.sessions_bulk_delete_message, state.selectedIds.size), + onConfirm = onConfirmBulkDelete, + onDismiss = onDismissBulkDelete, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Single-session rename dialog + state.renamingSessionId?.let { sessionId -> + AlertDialog( + onDismissRequest = onDismissRename, + title = { Text(stringResource(R.string.sessions_rename_title)) }, + text = { + OutlinedTextField( + value = state.renameDraft, + onValueChange = onRenameDraftChange, + singleLine = true, + label = { Text(stringResource(R.string.sessions_rename_hint)) }, + ) + }, + confirmButton = { + TextButton( + onClick = { onRenameSession(sessionId, state.renameDraft) }, + enabled = state.renameDraft.isNotBlank(), + ) { + Text(stringResource(R.string.sessions_action_rename)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRename) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt new file mode 100644 index 00000000..4558dfd2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.sessions.HistorySection +import com.m57.hermescontrol.ui.sessions.formatCompactCount + +@Composable +fun SessionsStatsRow( + total: Int, + loadedMessageCount: Int, + section: HistorySection, + spacing: Spacing, + statusColors: HermesStatusColors, + onPruneClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatCard( + label = stringResource(R.string.sessions_stat_total), + value = formatCompactCount(total), + icon = Icons.Filled.History, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + StatCard( + label = stringResource(R.string.sessions_stat_messages_loaded), + value = formatCompactCount(loadedMessageCount), + icon = Icons.Filled.Email, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + if (section == HistorySection.CONVERSATIONS) { + Card( + modifier = Modifier.fillMaxHeight(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + onClick = onPruneClick, + ) { + Box( + modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.sessions_action_prune), + style = MaterialTheme.typography.labelMedium, + color = statusColors.warning, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } +} From 1edd5449c9ca8ce7c958731c199aaa1c4934d339 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:38:29 +0300 Subject: [PATCH 13/17] refactor(chat): modularize update section and media launchers Extract ChatAppUpdateSection and rememberChatMediaLaunchers to components to decouple STT and update dialogs from ChatScreen. --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 309 ++---------------- .../chat/components/ChatAppUpdateSection.kt | 103 ++++++ .../ui/chat/components/ChatMediaLaunchers.kt | 257 +++++++++++++++ 3 files changed, 384 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index b31d5774..3223cc33 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1,13 +1,8 @@ package com.m57.hermescontrol.ui.chat -import android.Manifest import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri -import android.provider.OpenableColumns -import android.speech.RecognizerIntent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource -import com.m57.hermescontrol.data.update.AppUpdateCache -import com.m57.hermescontrol.data.update.AppUpdateState -import com.m57.hermescontrol.data.update.UpdateNoticeManager import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects @@ -104,26 +94,21 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip +import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.common.ActionProgressDialog -import com.m57.hermescontrol.ui.common.AppUpdateDialog import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog -import com.m57.hermescontrol.ui.settings.AppUpdateViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale private const val SESSION_SYNC_INTERVAL_MS = 30_000L @@ -304,7 +289,6 @@ fun ChatScreen( viewModel.consumeComposerTextRestore() } } - var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } @@ -322,137 +306,24 @@ fun ChatScreen( ) } - val micListeningPrompt = stringResource(R.string.chat_mic_listening) - val sttNotAvailableMsg = stringResource(R.string.stt_not_available) - val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) - - // Speech-to-text recognition launcher (issue #194) - val speechLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - ExternalActivityLifecycleGuard.externalActivityReturned() - isListening = false - if (result.resultCode == Activity.RESULT_OK) { - val spokenText = - result.data - ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) - ?.firstOrNull() - .orEmpty() - if (spokenText.isNotBlank()) { - val merged = - if (inputFieldValue.text.isBlank()) { - spokenText - } else { - "${inputFieldValue.text} $spokenText" - } - inputFieldValue = ChatInputPolicy.commandFieldValue(merged) - } - } - } - - // Mic permission launcher - val micPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - ExternalActivityLifecycleGuard.externalActivityReturned() - if (granted) { - if (SpeechInputHelper.isSpeechInputAvailable(context)) { - val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) - isListening = true - launchExternalActivity { - try { - speechLauncher.launch(intent) - } catch (_: ActivityNotFoundException) { - isListening = false - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } - } else { - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } else { + val mediaLaunchers = + rememberChatMediaLaunchers( + inputFieldValue = inputFieldValue, + onInputFieldValueChange = { inputFieldValue = it }, + onAddAttachment = { uri, name, mimeType, size -> + viewModel.addAttachment(uri, name, mimeType, size) + }, + onAddAttachments = { attachments -> + viewModel.addAttachments(attachments) + }, + onShowMessage = { msg -> scrollScope.launch { - snackbarHostState.showSnackbar(sttPermissionDeniedMsg) + snackbarHostState.showSnackbar(msg) } - } - } - - // Multi-file picker for attachments (issue #195). - val filePickerLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.GetMultipleContents(), - ) { uris: List -> - ExternalActivityLifecycleGuard.externalActivityReturned() - val attachments = - uris.mapNotNull { uri -> - runCatching { - var name = uri.lastPathSegment ?: "file" - var size = 0L - context.contentResolver - .query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), - null, - null, - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) - if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) - if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) - } - } - Attachment( - uri = uri.toString(), - name = name, - mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream", - size = size, - ) - }.onFailure { error -> - Log.w("ChatScreen", "Skipping unreadable picked attachment", error) - }.getOrNull() - } - viewModel.addAttachments(attachments) - } - - // Camera photo launcher (issue #195) - var pendingCameraUri by remember { mutableStateOf(null) } - val cameraErrorMsg = stringResource(R.string.chat_camera_error) - val cameraLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.TakePicture(), - ) { success -> - ExternalActivityLifecycleGuard.externalActivityReturned() - val uri = pendingCameraUri - pendingCameraUri = null - if (success && uri != null) { - try { - val fileName = - "photo_${ - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( - Date(), - ) - }.jpg" - val inputStream = context.contentResolver.openInputStream(uri) - val size = inputStream?.use { it.available().toLong() } ?: 0L - viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size) - } catch (e: Exception) { - Log.e("ChatScreen", "Camera capture failed", e) - scrollScope.launch { - snackbarHostState.showSnackbar( - cameraErrorMsg, - ) - } - } - } - } + }, + launchExternalActivity = launchExternalActivity, + context = context, + ) // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -592,80 +463,7 @@ fun ChatScreen( // Issue #890: launch update check — non-blocking banner when a // newer release exists. Tapping "Update" opens the in-place dialog. - val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() - val appUpdateViewModel: AppUpdateViewModel = - viewModel { - val app = - this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] - ?: error("Application not available") - AppUpdateViewModel(app) - } - val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() - - val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { - if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { - appUpdateViewModel.resumeInstallAfterPermission() - } - } - } - updateLifecycleOwner.lifecycle.addObserver(observer) - onDispose { - updateLifecycleOwner.lifecycle.removeObserver(observer) - } - } - - if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { - val noticeTag = - (updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag - ?: UpdateNoticeManager.noticeTag() - if (noticeTag != null) { - com.m57.hermescontrol.ui.common.UpdateNoticeBanner( - latestTag = noticeTag, - onUpdate = { AppUpdateCache.showDialog() }, - onDismiss = { AppUpdateCache.dismiss() }, - ) - } - } - - if (AppUpdateCache.isDialogVisible) { - val context = androidx.compose.ui.platform.LocalContext.current - val dialogState = - if (appUpdateState !is AppUpdateState.Idle) { - appUpdateState - } else { - updateNotice - } - AppUpdateDialog( - state = dialogState, - onDismiss = { AppUpdateCache.hideDialog() }, - onStartUpdate = { appUpdateViewModel.startUpdate() }, - onCancelDownload = { appUpdateViewModel.cancelDownload() }, - onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, - onOpenSettings = { - val intent = - android.content - .Intent( - android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - android.net.Uri.parse("package:${context.packageName}"), - ).apply { - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - context.startActivity(intent) - } catch (e: Exception) { - context.startActivity( - android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply { - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - }, - ) - } - }, - ) - } + ChatAppUpdateSection() AnimatedVisibility( visible = searchState.isActive, @@ -808,75 +606,16 @@ fun ChatScreen( // Jump to bottom after send (serialized through the controller). scrollController.jumpToBottom(animated = true) }, - onMicTap = { - if (isListening) { - isListening = false - } else if ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.RECORD_AUDIO, - ) == PackageManager.PERMISSION_GRANTED - ) { - if (SpeechInputHelper.isSpeechInputAvailable(context)) { - val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) - isListening = true - launchExternalActivity { - try { - speechLauncher.launch(intent) - } catch (_: ActivityNotFoundException) { - isListening = false - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } - } else { - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } else { - launchExternalActivity { - micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - }, - isListening = isListening, + onMicTap = mediaLaunchers.onMicTap, + isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, - onCameraTap = { - try { - val timeStamp = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val photoFile = - File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir) - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - photoFile, - ) - pendingCameraUri = uri - launchExternalActivity { - cameraLauncher.launch(uri) - } - } catch (e: Exception) { - Log.e("ChatScreen", "Camera launch failed", e) - } - }, - onImageTap = { - launchExternalActivity { - filePickerLauncher.launch("image/*") - } - }, - onFileTap = { - launchExternalActivity { - filePickerLauncher.launch("*/*") - } - }, + onCameraTap = mediaLaunchers.onCameraTap, + onImageTap = mediaLaunchers.onImageTap, + onFileTap = mediaLaunchers.onFileTap, onRemoveAttachment = viewModel::removeAttachment, onPreviewAttachment = { attachment -> if (attachment.isImage) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt new file mode 100644 index 00000000..2ec2e26b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.data.update.AppUpdateCache +import com.m57.hermescontrol.data.update.AppUpdateState +import com.m57.hermescontrol.data.update.UpdateNoticeManager +import com.m57.hermescontrol.ui.common.AppUpdateDialog +import com.m57.hermescontrol.ui.common.UpdateNoticeBanner +import com.m57.hermescontrol.ui.settings.AppUpdateViewModel + +@Composable +fun ChatAppUpdateSection() { + val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() + val appUpdateViewModel: AppUpdateViewModel = + viewModel { + val app = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + ?: error("Application not available") + AppUpdateViewModel(app) + } + val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() + + val updateLifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(updateLifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { + appUpdateViewModel.resumeInstallAfterPermission() + } + } + } + updateLifecycleOwner.lifecycle.addObserver(observer) + onDispose { + updateLifecycleOwner.lifecycle.removeObserver(observer) + } + } + + if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { + val noticeTag = + (updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag + ?: UpdateNoticeManager.noticeTag() + if (noticeTag != null) { + UpdateNoticeBanner( + latestTag = noticeTag, + onUpdate = { AppUpdateCache.showDialog() }, + onDismiss = { AppUpdateCache.dismiss() }, + ) + } + } + + if (AppUpdateCache.isDialogVisible) { + val context = LocalContext.current + val dialogState = + if (appUpdateState !is AppUpdateState.Idle) { + appUpdateState + } else { + updateNotice + } + AppUpdateDialog( + state = dialogState, + onDismiss = { AppUpdateCache.hideDialog() }, + onStartUpdate = { appUpdateViewModel.startUpdate() }, + onCancelDownload = { appUpdateViewModel.cancelDownload() }, + onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, + onOpenSettings = { + launchUnknownAppSourcesSettings(context) + }, + ) + } +} + +private fun launchUnknownAppSourcesSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (_: Exception) { + context.startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt new file mode 100644 index 00000000..afb68002 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -0,0 +1,257 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.Manifest +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import android.speech.RecognizerIntent +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.ui.chat.ChatInputPolicy +import com.m57.hermescontrol.ui.chat.SpeechInputHelper +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ChatMediaLaunchers( + val isListening: Boolean, + val onMicTap: () -> Unit, + val onCameraTap: () -> Unit, + val onImageTap: () -> Unit, + val onFileTap: () -> Unit, +) + +@Composable +fun rememberChatMediaLaunchers( + inputFieldValue: TextFieldValue, + onInputFieldValueChange: (TextFieldValue) -> Unit, + onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, + onAddAttachments: (List) -> Unit, + onShowMessage: (String) -> Unit, + launchExternalActivity: (() -> Unit) -> Unit, + context: Context = LocalContext.current, +): ChatMediaLaunchers { + var isListening by remember { mutableStateOf(false) } + var pendingCameraUri by remember { mutableStateOf(null) } + + val currentInputFieldValue by rememberUpdatedState(inputFieldValue) + val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange) + val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) + val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) + val currentOnShowMessage by rememberUpdatedState(onShowMessage) + val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity) + + val micListeningPrompt = stringResource(R.string.chat_mic_listening) + val sttNotAvailableMsg = stringResource(R.string.stt_not_available) + val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) + val cameraErrorMsg = stringResource(R.string.chat_camera_error) + + // Speech-to-text recognition launcher (issue #194) + val speechLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + ExternalActivityLifecycleGuard.externalActivityReturned() + isListening = false + if (result.resultCode == Activity.RESULT_OK) { + val spokenText = + result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + .orEmpty() + if (spokenText.isNotBlank()) { + val currentText = currentInputFieldValue.text + val merged = + if (currentText.isBlank()) { + spokenText + } else { + "$currentText $spokenText" + } + currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged)) + } + } + } + + // Mic permission launcher + val micPermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + ExternalActivityLifecycleGuard.externalActivityReturned() + if (granted) { + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) + isListening = true + currentLaunchExternalActivity { + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + currentOnShowMessage(sttNotAvailableMsg) + } + } + } else { + currentOnShowMessage(sttNotAvailableMsg) + } + } else { + currentOnShowMessage(sttPermissionDeniedMsg) + } + } + + // Multi-file picker for attachments (issue #195) + val filePickerLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents(), + ) { uris: List -> + ExternalActivityLifecycleGuard.externalActivityReturned() + val attachments = + uris.mapNotNull { uri -> + runCatching { + var name = uri.lastPathSegment ?: "file" + var size = 0L + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) + } + } + Attachment( + uri = uri.toString(), + name = name, + mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream", + size = size, + ) + }.onFailure { error -> + Log.w("ChatScreen", "Skipping unreadable picked attachment", error) + }.getOrNull() + } + currentOnAddAttachments(attachments) + } + + // Camera photo launcher (issue #195) + val cameraLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture(), + ) { success -> + ExternalActivityLifecycleGuard.externalActivityReturned() + val uri = pendingCameraUri + pendingCameraUri = null + if (success && uri != null) { + try { + val fileName = + "photo_${ + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( + Date(), + ) + }.jpg" + val inputStream = context.contentResolver.openInputStream(uri) + val size = inputStream?.use { it.available().toLong() } ?: 0L + currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size) + } catch (e: Exception) { + Log.e("ChatScreen", "Camera capture failed", e) + currentOnShowMessage(cameraErrorMsg) + } + } + } + + val onMicTap: () -> Unit = { + if (isListening) { + isListening = false + } else if ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) { + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) + isListening = true + currentLaunchExternalActivity { + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + currentOnShowMessage(sttNotAvailableMsg) + } + } + } else { + currentOnShowMessage(sttNotAvailableMsg) + } + } else { + currentLaunchExternalActivity { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + val onCameraTap: () -> Unit = { + try { + val timeStamp = + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val photoFile = + File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir) + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + pendingCameraUri = uri + currentLaunchExternalActivity { + cameraLauncher.launch(uri) + } + } catch (e: Exception) { + Log.e("ChatScreen", "Camera launch failed", e) + } + } + + val onImageTap: () -> Unit = { + currentLaunchExternalActivity { + filePickerLauncher.launch("image/*") + } + } + + val onFileTap: () -> Unit = { + currentLaunchExternalActivity { + filePickerLauncher.launch("*/*") + } + } + + return remember(isListening) { + ChatMediaLaunchers( + isListening = isListening, + onMicTap = onMicTap, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) + } +} From d2e5e8e1aaf2a37cf8fe6564217f496e97dacfb3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:47:26 +0300 Subject: [PATCH 14/17] refactor(group): decompose GroupChatScreen into components Extract GroupChatComposer, GroupChatSettingsDialog, GroupMessageCard, and GroupChatToolChip into components package. --- .../ui/bots/group/GroupChatScreen.kt | 948 +----------------- .../group/components/GroupChatComposer.kt | 296 ++++++ .../components/GroupChatSettingsDialog.kt | 333 ++++++ .../group/components/GroupChatToolChip.kt | 210 ++++ .../bots/group/components/GroupMessageCard.kt | 313 ++++++ 5 files changed, 1167 insertions(+), 933 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 4835b2a9..dc1febd0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -95,6 +95,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale +import com.m57.hermescontrol.ui.bots.group.components.GroupChatComposer +import com.m57.hermescontrol.ui.bots.group.components.GroupChatSettingsDialog +import com.m57.hermescontrol.ui.bots.group.components.GroupMessageCard import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab @@ -324,238 +327,23 @@ fun GroupChatScreen( } } - // ── MENTION SUGGESTION CHIP BAR (Horizontal scrollable chips) ── - val mentionQuery = - remember(inputFieldValue.text, inputFieldValue.selection.end) { - ChatInputPolicy.extractMentionQuery( - inputFieldValue.text, - inputFieldValue.selection.end, - ) - } - + // ── COMPOSER ── val groupBots = remember(state.members) { state.members.distinctBy { it.name.lowercase() } } - val showAllOption = - remember(mentionQuery, groupBots) { - if (mentionQuery == null || groupBots.isEmpty()) { - false - } else { - "all".startsWith(mentionQuery, ignoreCase = true) || - "everyone".startsWith(mentionQuery, ignoreCase = true) - } - } - - val filteredBots = - remember(mentionQuery, groupBots) { - if (mentionQuery == null) { - emptyList() - } else { - groupBots.filter { bot -> - bot.name.startsWith(mentionQuery, ignoreCase = true) || - bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) - } - } - } - - AnimatedVisibility( - visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 2.dp) - .testTag("group_chat_mention_row"), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showAllOption) { - item(key = "mention_all") { - SuggestionChip( - onClick = { - inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, "all") - }, - label = { - Text( - text = "@all", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - }, - icon = { - Icon( - imageVector = Icons.Filled.Groups, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("mention_chip_all"), - ) - } - } - - items(filteredBots, key = { it.name }) { bot -> - SuggestionChip( - onClick = { - inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, bot.name) - }, - label = { - Text( - text = "@${bot.name}", - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.labelMedium, - ) - }, - icon = { - BotAvatar( - name = bot.name, - avatar = bot.botMeta()?.avatar, - size = 18.dp, - showPresence = false, - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("mention_chip_${bot.name}"), - ) - } - } - } - - // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── - val ambientLayoutDirection = LocalLayoutDirection.current - val inputLayoutDirection = - remember(inputFieldValue.text, ambientLayoutDirection) { - BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) - } - val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = { inputFieldValue = it }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .onFocusChanged { isFocused = it.isFocused } - .testTag("group_chat_input"), - textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { handleSend() }), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Surface( - shape = RoundedCornerShape(20.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = - stringResource( - R.string.group_chat_composer_hint, - state.groupName.ifBlank { groupName }, - ), - style = MaterialTheme.typography.bodyMedium, - color = - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.6f, - ), - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - modifier = Modifier.fillMaxWidth(), - ) - } - innerTextField() - } - } - - Spacer(modifier = Modifier.width(4.dp)) - - if (state.activeSpeaker != null) { - IconButton( - onClick = { viewModel.stopGeneration() }, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_composer_stop_button"), - ) { - Icon( - imageVector = Icons.Filled.Stop, - contentDescription = stringResource(R.string.group_chat_action_stop), - modifier = Modifier.size(18.dp), - ) - } - } else { - IconButton( - onClick = handleSend, - enabled = inputFieldValue.text.isNotBlank(), - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - }, - ) - } + GroupChatComposer( + inputFieldValue = inputFieldValue, + onInputValueChange = { inputFieldValue = it }, + groupBots = groupBots, + groupName = state.groupName.ifBlank { groupName }, + activeSpeaker = state.activeSpeaker, + isFocused = isFocused, + onFocusChanged = { isFocused = it }, + onSend = handleSend, + onStop = { viewModel.stopGeneration() }, + ) } } @@ -572,709 +360,3 @@ fun GroupChatScreen( ) } } - -@Composable -private fun GroupChatSettingsDialog( - currentMaxMessages: Int, - currentMaxPasses: Int, - currentSystemPrompt: String?, - onDismiss: () -> Unit, - onSave: (Int, Int, String?) -> Unit, -) { - var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } - var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } - var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } - var showHighLimitsConfirmation by remember { mutableStateOf(false) } - - val isHighLimit = - maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || - maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.group_chat_settings_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Max Bot Messages Slider - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.group_chat_settings_max_messages), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "${maxMessages.roundToInt()}", - style = MaterialTheme.typography.labelLarge, - color = - if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - fontWeight = FontWeight.Bold, - ) - } - Text( - text = - stringResource( - R.string.group_chat_settings_max_messages_desc, - maxMessages.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - value = maxMessages, - onValueChange = { maxMessages = it }, - valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(), - steps = MAX_ALLOWED_BOT_MESSAGES - 2, - modifier = Modifier.testTag("max_messages_slider"), - ) - } - - // Max Continuation Handoffs Slider - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.group_chat_settings_max_handoffs), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "${maxPasses.roundToInt()}", - style = MaterialTheme.typography.labelLarge, - color = - if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - fontWeight = FontWeight.Bold, - ) - } - Text( - text = - stringResource( - R.string.group_chat_settings_max_handoffs_desc, - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - value = maxPasses, - onValueChange = { maxPasses = it }, - valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(), - steps = MAX_ALLOWED_CONTINUATION_PASSES - 1, - modifier = Modifier.testTag("max_handoffs_slider"), - ) - } - - // Live In-Dialog Warning Banner (Option C) - AnimatedVisibility( - visible = isHighLimit, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), - contentColor = MaterialTheme.colorScheme.onErrorContainer, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), - modifier = - Modifier - .fillMaxWidth() - .testTag("high_limits_warning_banner"), - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp), - ) - Text( - text = - stringResource( - R.string.group_chat_settings_high_limits_warning_desc, - maxMessages.roundToInt(), - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - - // Room Instructions / System Prompt - Column { - Text( - text = stringResource(R.string.group_chat_settings_system_prompt), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(R.string.group_chat_settings_system_prompt_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(6.dp)) - OutlinedTextField( - value = systemPrompt, - onValueChange = { systemPrompt = it }, - placeholder = { - Text( - text = stringResource(R.string.group_chat_settings_system_prompt_hint), - style = MaterialTheme.typography.bodySmall, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 80.dp, max = 160.dp) - .testTag("room_system_prompt_input"), - textStyle = MaterialTheme.typography.bodyMedium, - maxLines = 4, - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (isHighLimit) { - showHighLimitsConfirmation = true - } else { - onSave( - maxMessages.roundToInt(), - maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, - ) - } - }, - modifier = Modifier.testTag("save_settings_button"), - ) { - Text(stringResource(R.string.group_chat_settings_save)) - } - }, - dismissButton = { - Row { - TextButton( - onClick = { - maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() - maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() - systemPrompt = "" - }, - ) { - Text(stringResource(R.string.group_chat_settings_reset_default)) - } - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.common_cancel)) - } - } - }, - ) - - if (showHighLimitsConfirmation) { - AlertDialog( - onDismissRequest = { showHighLimitsConfirmation = false }, - icon = { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - title = { - Text( - text = stringResource(R.string.group_chat_settings_high_limits_warning_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - }, - text = { - Text( - text = - stringResource( - R.string.group_chat_settings_high_limits_warning_desc, - maxMessages.roundToInt(), - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - Button( - onClick = { - showHighLimitsConfirmation = false - onSave( - maxMessages.roundToInt(), - maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, - ) - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.testTag("confirm_high_limits_button"), - ) { - Text(stringResource(R.string.group_chat_settings_high_limits_confirm)) - } - }, - dismissButton = { - TextButton( - onClick = { showHighLimitsConfirmation = false }, - modifier = Modifier.testTag("dismiss_high_limits_button"), - ) { - Text(stringResource(R.string.group_chat_settings_high_limits_adjust)) - } - }, - ) - } -} - -@Composable -private fun GroupMessageCard( - message: GroupChatMessage, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - if (message.isSystem) { - val displayText = - when { - message.isPass -> { - val name = message.senderDisplayName.ifBlank { message.senderName } - stringResource(R.string.group_chat_bot_passed, name) - } - - message.text == STOPPED_SYSTEM_TEXT -> { - stringResource(R.string.group_chat_stopped_by_user) - } - - message.text == CAPPED_SYSTEM_TEXT -> { - stringResource(R.string.group_chat_capped_limit) - } - - else -> { - message.text - } - } - val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) } - val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - Box( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - ) { - CompositionLocalProvider(LocalLayoutDirection provides systemDirection) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (message.isPass) { - Icon( - imageVector = Icons.Filled.FastForward, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - Text( - text = displayText, - style = - MaterialTheme.typography.labelSmall.copy( - textDirection = - if (isSystemRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } else if (message.isUser) { - val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) } - val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) { - Column(modifier = Modifier.padding(12.dp)) { - SelectionContainer { - Text( - text = message.text, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = - if (isUserRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = MaterialTheme.colorScheme.onPrimary, - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.text)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - } else { - val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - ) { - BotAvatar( - name = message.senderName, - avatar = message.avatarMeta, - size = 32.dp, - showPresence = false, - ) - Spacer(modifier = Modifier.width(8.dp)) - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.weight(1f, fill = false), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = message.senderDisplayName, - style = - MaterialTheme.typography.labelMedium.copy( - textDirection = - if (isBotNameRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - if (message.toolCalls.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), - ) { - message.toolCalls.forEach { tool -> - GroupChatToolChip(tool = tool) - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.group_chat_typing), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - } else if (message.text.isNotBlank()) { - SelectionContainer { - MarkdownText( - text = message.text, - textColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (!message.isStreaming && message.text.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.text)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - } -} - -@Composable -private fun GroupChatToolChip( - tool: GroupChatToolCall, - modifier: Modifier = Modifier, -) { - var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } - val isTerminal = - tool.name.equals("terminal", ignoreCase = true) || - tool.name.equals("execute_code", ignoreCase = true) - - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - border = - BorderStroke( - 1.dp, - if (tool.isError) { - MaterialTheme.colorScheme.error.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - }, - ), - modifier = - modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - ) { - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth(), - ) { - val icon = - when (tool.name.lowercase()) { - "terminal", "execute_code" -> Icons.Filled.Terminal - "web_search", "search_files" -> Icons.Filled.Search - "read_file", "write_file", "patch" -> Icons.Filled.Description - else -> Icons.Filled.Build - } - Icon( - imageVector = icon, - contentDescription = tool.name, - modifier = Modifier.size(13.dp), - tint = - if (tool.isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - ) - val label = - if (!tool.summary.isNullOrBlank()) { - "${tool.name}: ${tool.summary}" - } else { - tool.name - } - val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) } - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (tool.isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(10.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - } - - AnimatedVisibility( - visible = expanded, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (!tool.command.isNullOrBlank()) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth(), - ) { - SelectionContainer { - Text( - text = if (isTerminal) "$ ${tool.command}" else tool.command, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - textDirection = TextDirection.Ltr, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), - ) - } - } - } - - val outputText = - when { - tool.isRunning -> stringResource(R.string.group_chat_tool_running) - !tool.output.isNullOrBlank() -> tool.output.trim() - tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) - else -> stringResource(R.string.group_chat_tool_no_output) - } - val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) } - - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), - modifier = Modifier.fillMaxWidth(), - ) { - SelectionContainer { - Text( - text = outputText, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - textDirection = - if (isOutputRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = - if (tool.isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 15, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt new file mode 100644 index 00000000..befd213e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt @@ -0,0 +1,296 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.ui.chat.ChatInputPolicy +import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun GroupChatComposer( + inputFieldValue: TextFieldValue, + onInputValueChange: (TextFieldValue) -> Unit, + groupBots: List, + groupName: String, + activeSpeaker: String?, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + onSend: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, +) { + val mentionQuery = + remember(inputFieldValue.text, inputFieldValue.selection) { + ChatInputPolicy.extractMentionQuery( + inputFieldValue.text, + inputFieldValue.selection.end, + ) + } + + val showAllOption = + remember(mentionQuery, groupBots) { + if (mentionQuery == null || groupBots.isEmpty()) { + false + } else { + "all".startsWith(mentionQuery, ignoreCase = true) || + "everyone".startsWith(mentionQuery, ignoreCase = true) + } + } + + val filteredBots = + remember(mentionQuery, groupBots) { + if (mentionQuery == null) { + emptyList() + } else { + groupBots.filter { bot -> + bot.name.startsWith(mentionQuery, ignoreCase = true) || + bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) + } + } + } + + AnimatedVisibility( + visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .testTag("group_chat_mention_row"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showAllOption) { + item(key = "mention_all") { + SuggestionChip( + onClick = { + onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, "all")) + }, + label = { + Text( + text = "@all", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + icon = { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_all"), + ) + } + } + + items(filteredBots, key = { it.name }) { bot -> + SuggestionChip( + onClick = { + onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, bot.name)) + }, + label = { + Text( + text = "@${bot.name}", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelMedium, + ) + }, + icon = { + BotAvatar( + name = bot.name, + avatar = bot.botMeta()?.avatar, + size = 18.dp, + showPresence = false, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_${bot.name}"), + ) + } + } + } + + // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputValueChange, + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .onFocusChanged { onFocusChanged(it.isFocused) } + .testTag("group_chat_input"), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onSend() }), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Surface( + shape = RoundedCornerShape(20.dp), + border = + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + ), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = + stringResource( + R.string.group_chat_composer_hint, + groupName, + ), + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ), + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + if (activeSpeaker != null) { + IconButton( + onClick = onStop, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_composer_stop_button"), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.group_chat_action_stop), + modifier = Modifier.size(18.dp), + ) + } + } else { + IconButton( + onClick = onSend, + enabled = inputFieldValue.text.isNotBlank(), + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt new file mode 100644 index 00000000..5d1d26a2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt @@ -0,0 +1,333 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_CONTINUATION_PASSES +import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_CONTINUATION_PASSES +import com.m57.hermescontrol.ui.bots.group.WARN_MAX_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.WARN_MAX_CONTINUATION_PASSES +import kotlin.math.roundToInt + +@Composable +fun GroupChatSettingsDialog( + currentMaxMessages: Int, + currentMaxPasses: Int, + currentSystemPrompt: String?, + onDismiss: () -> Unit, + onSave: (Int, Int, String?) -> Unit, +) { + var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } + var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } + var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + var showHighLimitsConfirmation by remember { mutableStateOf(false) } + + val isHighLimit = + maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || + maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.group_chat_settings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Max Bot Messages Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_messages), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxMessages.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = + if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_messages_desc, + maxMessages.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxMessages, + onValueChange = { maxMessages = it }, + valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(), + steps = MAX_ALLOWED_BOT_MESSAGES - 2, + modifier = Modifier.testTag("max_messages_slider"), + ) + } + + // Max Continuation Handoffs Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_handoffs), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxPasses.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = + if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_handoffs_desc, + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxPasses, + onValueChange = { maxPasses = it }, + valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(), + steps = MAX_ALLOWED_CONTINUATION_PASSES - 1, + modifier = Modifier.testTag("max_handoffs_slider"), + ) + } + + // Live In-Dialog Warning Banner (Option C) + AnimatedVisibility( + visible = isHighLimit, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), + modifier = + Modifier + .fillMaxWidth() + .testTag("high_limits_warning_banner"), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + // Room Instructions / System Prompt + Column { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = systemPrompt, + onValueChange = { systemPrompt = it }, + placeholder = { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_hint), + style = MaterialTheme.typography.bodySmall, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 80.dp, max = 160.dp) + .testTag("room_system_prompt_input"), + textStyle = MaterialTheme.typography.bodyMedium, + maxLines = 4, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (isHighLimit) { + showHighLimitsConfirmation = true + } else { + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + } + }, + modifier = Modifier.testTag("save_settings_button"), + ) { + Text(stringResource(R.string.group_chat_settings_save)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() + maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() + systemPrompt = "" + }, + ) { + Text(stringResource(R.string.group_chat_settings_reset_default)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + }, + ) + + if (showHighLimitsConfirmation) { + AlertDialog( + onDismissRequest = { showHighLimitsConfirmation = false }, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.group_chat_settings_high_limits_warning_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + Button( + onClick = { + showHighLimitsConfirmation = false + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.testTag("confirm_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showHighLimitsConfirmation = false }, + modifier = Modifier.testTag("dismiss_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_adjust)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt new file mode 100644 index 00000000..c0fc5496 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt @@ -0,0 +1,210 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.GroupChatToolCall +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun GroupChatToolChip( + tool: GroupChatToolCall, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } + val isTerminal = + tool.name.equals("terminal", ignoreCase = true) || + tool.name.equals("execute_code", ignoreCase = true) + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + border = + BorderStroke( + 1.dp, + if (tool.isError) { + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + }, + ), + modifier = + modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal", "execute_code" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) } + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!tool.command.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = if (isTerminal) "$ ${tool.command}" else tool.command, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + + val outputText = + when { + tool.isRunning -> stringResource(R.string.group_chat_tool_running) + !tool.output.isNullOrBlank() -> tool.output.trim() + tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) + else -> stringResource(R.string.group_chat_tool_no_output) + } + val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) } + + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = outputText, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = + if (isOutputRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 15, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt new file mode 100644 index 00000000..bc403a26 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt @@ -0,0 +1,313 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import android.content.ClipData +import android.text.format.DateFormat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT +import com.m57.hermescontrol.ui.bots.group.GroupChatMessage +import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT +import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.formatTimestamp +import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.util.BidiUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun GroupMessageCard( + message: GroupChatMessage, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(1500) + copied = false + } + } + + if (message.isSystem) { + val displayText = + when { + message.isPass -> { + val name = message.senderDisplayName.ifBlank { message.senderName } + stringResource(R.string.group_chat_bot_passed, name) + } + + message.text == STOPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_stopped_by_user) + } + + message.text == CAPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_capped_limit) + } + + else -> { + message.text + } + } + val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) } + val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + Box( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + CompositionLocalProvider(LocalLayoutDirection provides systemDirection) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (message.isPass) { + Icon( + imageVector = Icons.Filled.FastForward, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Text( + text = displayText, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = + if (isSystemRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } else if (message.isUser) { + val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) } + val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) { + Column(modifier = Modifier.padding(12.dp)) { + SelectionContainer { + Text( + text = message.text, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isUserRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + } else { + val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) } + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + BotAvatar( + name = message.senderName, + avatar = message.avatarMeta, + size = 32.dp, + showPresence = false, + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = message.senderDisplayName, + style = + MaterialTheme.typography.labelMedium.copy( + textDirection = + if (isBotNameRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (message.toolCalls.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), + ) { + message.toolCalls.forEach { tool -> + GroupChatToolChip(tool = tool) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.group_chat_typing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } else if (message.text.isNotBlank()) { + SelectionContainer { + MarkdownText( + text = message.text, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!message.isStreaming && message.text.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + } +} From f4904653d1148d2a0d8f62dcb09584b8447fe3b7 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:53:16 +0300 Subject: [PATCH 15/17] refactor(mcp): decompose McpServersScreen into components Extract McpSectionHeader, AddServerSection, ServerCard, CatalogSection, and McpDialogs into ui/mcp/components. --- .../hermescontrol/ui/mcp/McpServersScreen.kt | 849 +----------------- .../ui/mcp/components/AddServerSection.kt | 198 ++++ .../ui/mcp/components/CatalogSection.kt | 235 +++++ .../ui/mcp/components/McpDialogs.kt | 202 +++++ .../ui/mcp/components/McpSectionHeader.kt | 59 ++ .../ui/mcp/components/ServerCard.kt | 365 ++++++++ 6 files changed, 1071 insertions(+), 837 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt 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 8548b5c7..df7e9748 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 @@ -87,6 +87,10 @@ 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 +import com.m57.hermescontrol.ui.mcp.components.AddServerSection +import com.m57.hermescontrol.ui.mcp.components.CatalogSection +import com.m57.hermescontrol.ui.mcp.components.McpDialogs +import com.m57.hermescontrol.ui.mcp.components.ServerCard @Composable fun McpServersScreen( @@ -247,842 +251,13 @@ fun McpServersScreen( } } - showDetail?.let { server -> - DetailDialog( - title = server.name, - rows = server.toDetailRows(), - onDismiss = { showDetail = null }, - ) - } - - state.activeOAuthFlow?.let { flow -> - androidx.compose.material3.AlertDialog( - onDismissRequest = { viewModel.dismissOAuthFlow() }, - confirmButton = { - Button( - onClick = { - flow.authorizationUrl?.let { url -> - try { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ), - ) - } catch (_: Exception) { - } - } - }, - ) { - Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser)) - } - }, - dismissButton = { - OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) { - Text(stringResource(R.string.action_cancel)) - } - }, - title = { - Text(stringResource(R.string.mcp_servers_oauth_dialog_title)) - }, - text = { - Column { - Text(stringResource(R.string.mcp_servers_oauth_dialog_desc)) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - ) - flow.error?.let { err -> - Spacer(modifier = Modifier.height(spacing.xs)) - Text( - text = stringResource(R.string.mcp_servers_oauth_dialog_error, err), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - }, - ) - } - - if (state.showImportDialog) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { viewModel.toggleImportDialog() }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.UploadFile, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text(stringResource(R.string.mcp_servers_import_dialog_title)) - } - }, - text = { - Column { - Text( - text = stringResource(R.string.mcp_servers_import_dialog_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - val clip = clipboardManager.getText()?.text - if (!clip.isNullOrBlank()) { - viewModel.updateImportJsonInput(clip) - } - }, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - ) { - Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = stringResource(R.string.mcp_servers_import_dialog_paste), - style = MaterialTheme.typography.labelSmall, - ) - } - } - Spacer(modifier = Modifier.height(spacing.xs)) - OutlinedTextField( - value = state.importJsonInput, - onValueChange = viewModel::updateImportJsonInput, - placeholder = { - Text( - "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) - }, - textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - modifier = - Modifier - .fillMaxWidth() - .height(180.dp), - maxLines = 10, - ) - } - }, - confirmButton = { - Button( - onClick = { viewModel.submitImportJson() }, - enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(), - ) { - if (state.isImportingJson) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_import_dialog_importing)) - } else { - Text(stringResource(R.string.mcp_servers_action_submit)) - } - } - }, - dismissButton = { - OutlinedButton(onClick = { viewModel.toggleImportDialog() }) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } -} - -// ── Section header (MCP-specific: distinct icon + neutral title so it reads -// as a header, not a button) ────────────────────────────────────────────── -@Composable -private fun McpSectionHeader( - icon: ImageVector, - title: String, - trailing: (@Composable () -> Unit)? = null, -) { - val spacing = LocalSpacing.current - Row( - modifier = - Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(12.dp), - ).padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - } - trailing?.invoke() - } -} - -// ── Sections ────────────────────────────────────────────────────── - -@Composable -private fun AddServerSection( - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - McpSectionHeader( - icon = Icons.Filled.Add, - title = stringResource(R.string.mcp_servers_add_server), - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { - TextButton( - onClick = { viewModel.toggleImportDialog() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_import_json)) - } - TextButton( - onClick = { viewModel.toggleAddForm() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (state.showAddForm) "Hide" else "New") - } - } - }, + McpDialogs( + state = state, + viewModel = viewModel, + spacing = spacing, + selectedServerDetail = showDetail, + onDismissDetail = { showDetail = null }, + context = context, + clipboardManager = clipboardManager, ) - - AnimatedVisibility(visible = state.showAddForm) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) { - Tab( - selected = state.addMode == AddServerMode.HTTP, - onClick = { viewModel.setAddMode(AddServerMode.HTTP) }, - text = { Text(stringResource(R.string.mcp_servers_add_http)) }, - ) - Tab( - selected = state.addMode == AddServerMode.Stdio, - onClick = { viewModel.setAddMode(AddServerMode.Stdio) }, - text = { Text(stringResource(R.string.mcp_servers_add_stdio)) }, - ) - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - OutlinedTextField( - value = state.addServerName, - onValueChange = viewModel::updateAddServerName, - label = { Text(stringResource(R.string.mcp_servers_field_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - if (state.addMode == AddServerMode.HTTP) { - OutlinedTextField( - value = state.addServerUrl, - onValueChange = viewModel::updateAddServerUrl, - label = { Text(stringResource(R.string.mcp_servers_field_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_auth_mode), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.xs)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - verticalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilterChip( - selected = state.addServerAuth == "none", - onClick = { viewModel.updateAddServerAuth("none") }, - label = { Text(stringResource(R.string.mcp_servers_auth_none)) }, - ) - FilterChip( - selected = state.addServerAuth == "header", - onClick = { viewModel.updateAddServerAuth("header") }, - label = { Text(stringResource(R.string.mcp_servers_auth_header)) }, - ) - FilterChip( - selected = state.addServerAuth == "oauth", - onClick = { viewModel.updateAddServerAuth("oauth") }, - label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) }, - ) - } - if (state.addServerAuth == "header") { - Spacer(modifier = Modifier.height(spacing.sm)) - OutlinedTextField( - value = state.addServerBearerToken, - onValueChange = viewModel::updateAddServerBearerToken, - label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) }, - singleLine = true, - visualTransformation = remember { PasswordVisualTransformation() }, - modifier = Modifier.fillMaxWidth(), - ) - } - } else { - OutlinedTextField( - value = state.addServerCommand, - onValueChange = viewModel::updateAddServerCommand, - label = { Text(stringResource(R.string.mcp_servers_field_command)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - OutlinedTextField( - value = state.addServerArgs, - onValueChange = viewModel::updateAddServerArgs, - label = { Text(stringResource(R.string.mcp_servers_field_args)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - - Spacer(modifier = Modifier.height(spacing.md)) - Button( - onClick = { viewModel.submitAddServer() }, - enabled = !state.addingServer, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (state.addingServer) { - stringResource(R.string.mcp_servers_action_adding) - } else { - stringResource(R.string.mcp_servers_action_submit) - }, - ) - } - } - } - } -} - -@Composable -private fun ServerCard( - server: McpServer, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, - onClick: () -> Unit, - onOpenBrowser: (String) -> Unit, -) { - var showEnv by remember { mutableStateOf(false) } - val isTesting = server.name in state.testingServers - val testResult = state.serverTestResults[server.name] - val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error) - - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = - if (server.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) { - // Header row: name + status + toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = server.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - when (healthStatus) { - McpHealthStatus.HEALTHY -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_healthy), - status = StatusBadgeType.SUCCESS, - ) - } - - McpHealthStatus.AUTH_REQUIRED -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_needs_auth), - status = StatusBadgeType.WARNING, - ) - } - - McpHealthStatus.ERROR -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_error), - status = StatusBadgeType.ERROR, - ) - } - - McpHealthStatus.TESTING -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_testing), - status = StatusBadgeType.INFO, - ) - } - - McpHealthStatus.UNKNOWN -> { - ServerStatusBadge(server.status) - } - } - } - Text( - text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Tool count & schema token overhead badge (issue #1029) - val toolInfos = testResult?.tools - val toolCount = toolInfos?.size ?: server.tools?.size - if (toolCount != null && toolCount > 0) { - val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } - val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) - Spacer(modifier = Modifier.height(spacing.xs)) - Surface( - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), - shape = RoundedCornerShape(6.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp), - ) { - Icon( - imageVector = Icons.Filled.Build, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Medium, - ) - } - } - } - } - Switch( - checked = server.enabled, - onCheckedChange = { viewModel.toggleServer(server) }, - ) - } - - // URL or Command - if (server.url != null) { - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = server.url, - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - ) - } else if (server.command != null) { - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_label_command), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${server.command} ${server.args.orEmpty().joinToString(" ")}", - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - ) - } - - // Error diagnostics - server.error?.let { error -> - if (error.isNotBlank()) { - Spacer(modifier = Modifier.height(spacing.sm)) - StatusBadge( - text = error, - status = StatusBadgeType.ERROR, - ) - } - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - if (server.auth == "oauth") { - FilledTonalButton( - onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, - modifier = Modifier.weight(1.2f), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), - ) { - Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1) - } - } - FilledTonalButton( - onClick = { viewModel.testServer(server.name) }, - enabled = !isTesting, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), - ) { - if (isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1) - } else { - Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) - } - } - IconButton(onClick = { viewModel.deleteServer(server.name) }) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) - } - } - - // Env vars toggle - TextButton( - onClick = { showEnv = !showEnv }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_env_vars)) - } - - AnimatedVisibility(visible = showEnv) { - EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing) - } - } - } -} - -@Composable -private fun EnvVarSection( - server: McpServer, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - val isEditing = state.editingEnvFor == server.name - val env = server.env ?: emptyMap() - - if (env.isEmpty() && !isEditing) { - Text( - text = stringResource(R.string.mcp_servers_env_no_vars), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.xs), - ) - } else { - env.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) - Text( - text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { - Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) - } - } - } - } - - if (isEditing) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = state.envKeyInput, - onValueChange = viewModel::updateEnvKey, - label = { Text(stringResource(R.string.mcp_servers_env_key)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - OutlinedTextField( - value = state.envValueInput, - onValueChange = viewModel::updateEnvValue, - label = { Text(stringResource(R.string.mcp_servers_env_value)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Button(onClick = { viewModel.addEnvVar(server.name) }) { - Text(stringResource(R.string.mcp_servers_env_add)) - } - OutlinedButton(onClick = { viewModel.stopEditingEnv() }) { - Text(stringResource(R.string.action_cancel)) - } - } - } else { - TextButton(onClick = { viewModel.startEditingEnv(server) }) { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_env_add)) - } - } -} - -@Composable -private fun ServerStatusBadge(status: String?) { - if (status == null) return - val badgeType = - when (status.lowercase()) { - "running", "ok", "connected" -> StatusBadgeType.SUCCESS - "error", "failed" -> StatusBadgeType.ERROR - "stopped" -> StatusBadgeType.NEUTRAL - else -> StatusBadgeType.NEUTRAL - } - StatusBadge(text = status, status = badgeType) -} - -// ── Catalog ───────────────────────────────────────────────────── - -@Composable -private fun CatalogSection( - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, - filteredCatalog: List, -) { - val catalogExpanded = remember { mutableStateOf(false) } - - McpSectionHeader( - icon = Icons.Filled.Storage, - title = stringResource(R.string.mcp_servers_section_catalog), - trailing = { - TextButton( - onClick = { - catalogExpanded.value = !catalogExpanded.value - if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { - viewModel.loadCatalog() - } - }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (catalogExpanded.value) "Hide" else "Browse") - } - }, - ) - - AnimatedVisibility(visible = catalogExpanded.value) { - Column(modifier = Modifier.fillMaxWidth()) { - // Search - OutlinedTextField( - value = state.catalogQuery, - onValueChange = viewModel::updateCatalogQuery, - label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), - ) - - // Loading / error / content - when { - state.catalogLoading -> { - Spacer(modifier = Modifier.height(spacing.sm)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.height(spacing.sm)) - } - - state.catalogError != null -> { - ErrorState( - message = state.catalogError, - onRetry = { viewModel.loadCatalog() }, - ) - } - - filteredCatalog.isEmpty() -> { - Text( - text = stringResource(R.string.mcp_servers_catalog_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.md), - ) - } - - else -> { - filteredCatalog.forEach { entry -> - CatalogEntryCard( - entry = entry, - state = state, - viewModel = viewModel, - spacing = spacing, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } - } - } - } -} - -@Composable -private fun CatalogEntryCard( - entry: McpCatalogEntry, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - var showInstallForm by remember { mutableStateOf(false) } - val isInstalling = state.installingCatalogEntry == entry.name - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = entry.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - entry.source?.let { - Text( - text = stringResource(R.string.mcp_servers_catalog_source, it), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Button( - onClick = { - if (entry.env?.isNotEmpty() == true) { - showInstallForm = !showInstallForm - } else { - viewModel.installCatalogEntry(entry) - } - }, - enabled = !isInstalling, - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) - }, - ) - } - } - - entry.description?.let { - Spacer(modifier = Modifier.height(spacing.xs)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Install form with env vars - AnimatedVisibility(visible = showInstallForm) { - Column(modifier = Modifier.padding(top = spacing.sm)) { - entry.env?.let { envVars -> - if (envVars.isNotEmpty()) { - Text( - text = stringResource(R.string.mcp_servers_catalog_required_env), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - envVars.forEach { envVar -> - val label = envVar.label ?: envVar.key - val currentValue = state.catalogInstallEnv[envVar.key] ?: "" - OutlinedTextField( - value = currentValue, - onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) }, - label = { Text(label) }, - placeholder = envVar.description?.let { { Text(it) } }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } - } - Button( - onClick = { viewModel.installCatalogEntry(entry) }, - enabled = !isInstalling, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) - }, - ) - } - } - } - } - } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt new file mode 100644 index 00000000..4bae7bf1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.mcp.AddServerMode +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddServerSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + McpSectionHeader( + icon = Icons.Filled.Add, + title = stringResource(R.string.mcp_servers_add_server), + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + TextButton( + onClick = { viewModel.toggleImportDialog() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_import_json)) + } + TextButton( + onClick = { viewModel.toggleAddForm() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text(if (state.showAddForm) "Hide" else "New") + } + } + }, + ) + + AnimatedVisibility(visible = state.showAddForm) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) { + Tab( + selected = state.addMode == AddServerMode.HTTP, + onClick = { viewModel.setAddMode(AddServerMode.HTTP) }, + text = { Text(stringResource(R.string.mcp_servers_add_http)) }, + ) + Tab( + selected = state.addMode == AddServerMode.Stdio, + onClick = { viewModel.setAddMode(AddServerMode.Stdio) }, + text = { Text(stringResource(R.string.mcp_servers_add_stdio)) }, + ) + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedTextField( + value = state.addServerName, + onValueChange = viewModel::updateAddServerName, + label = { Text(stringResource(R.string.mcp_servers_field_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + if (state.addMode == AddServerMode.HTTP) { + OutlinedTextField( + value = state.addServerUrl, + onValueChange = viewModel::updateAddServerUrl, + label = { Text(stringResource(R.string.mcp_servers_field_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_auth_mode), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.xs)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilterChip( + selected = state.addServerAuth == "none", + onClick = { viewModel.updateAddServerAuth("none") }, + label = { Text(stringResource(R.string.mcp_servers_auth_none)) }, + ) + FilterChip( + selected = state.addServerAuth == "header", + onClick = { viewModel.updateAddServerAuth("header") }, + label = { Text(stringResource(R.string.mcp_servers_auth_header)) }, + ) + FilterChip( + selected = state.addServerAuth == "oauth", + onClick = { viewModel.updateAddServerAuth("oauth") }, + label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) }, + ) + } + if (state.addServerAuth == "header") { + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = state.addServerBearerToken, + onValueChange = viewModel::updateAddServerBearerToken, + label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) }, + singleLine = true, + visualTransformation = remember { PasswordVisualTransformation() }, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + OutlinedTextField( + value = state.addServerCommand, + onValueChange = viewModel::updateAddServerCommand, + label = { Text(stringResource(R.string.mcp_servers_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = state.addServerArgs, + onValueChange = viewModel::updateAddServerArgs, + label = { Text(stringResource(R.string.mcp_servers_field_args)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(spacing.md)) + Button( + onClick = { viewModel.submitAddServer() }, + enabled = !state.addingServer, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (state.addingServer) { + stringResource(R.string.mcp_servers_action_adding) + } else { + stringResource(R.string.mcp_servers_action_submit) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt new file mode 100644 index 00000000..533123d2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -0,0 +1,235 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpCatalogEntry +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@Composable +fun CatalogSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + filteredCatalog: List, + modifier: Modifier = Modifier, +) { + val catalogExpanded = remember { mutableStateOf(false) } + + Column(modifier = modifier) { + McpSectionHeader( + icon = Icons.Filled.Storage, + title = stringResource(R.string.mcp_servers_section_catalog), + trailing = { + TextButton( + onClick = { + catalogExpanded.value = !catalogExpanded.value + if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { + viewModel.loadCatalog() + } + }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text(if (catalogExpanded.value) "Hide" else "Browse") + } + }, + ) + + AnimatedVisibility(visible = catalogExpanded.value) { + Column(modifier = Modifier.fillMaxWidth()) { + // Search + OutlinedTextField( + value = state.catalogQuery, + onValueChange = viewModel::updateCatalogQuery, + label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), + ) + + // Loading / error / content + when { + state.catalogLoading -> { + Spacer(modifier = Modifier.height(spacing.sm)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.height(spacing.sm)) + } + + state.catalogError != null -> { + ErrorState( + message = state.catalogError, + onRetry = { viewModel.loadCatalog() }, + ) + } + + filteredCatalog.isEmpty() -> { + Text( + text = stringResource(R.string.mcp_servers_catalog_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.md), + ) + } + + else -> { + filteredCatalog.forEach { entry -> + CatalogEntryCard( + entry = entry, + state = state, + viewModel = viewModel, + spacing = spacing, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + } + } + } +} + +@Composable +fun CatalogEntryCard( + entry: McpCatalogEntry, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + var showInstallForm by remember { mutableStateOf(false) } + val isInstalling = state.installingCatalogEntry == entry.name + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + entry.source?.let { + Text( + text = stringResource(R.string.mcp_servers_catalog_source, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Button( + onClick = { + if (entry.env?.isNotEmpty() == true) { + showInstallForm = !showInstallForm + } else { + viewModel.installCatalogEntry(entry) + } + }, + enabled = !isInstalling, + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } + } + + entry.description?.let { + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Install form with env vars + AnimatedVisibility(visible = showInstallForm) { + Column(modifier = Modifier.padding(top = spacing.sm)) { + entry.env?.let { envVars -> + if (envVars.isNotEmpty()) { + Text( + text = stringResource(R.string.mcp_servers_catalog_required_env), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + envVars.forEach { envVar -> + val label = envVar.label ?: envVar.key + val currentValue = state.catalogInstallEnv[envVar.key] ?: "" + OutlinedTextField( + value = currentValue, + onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) }, + label = { Text(label) }, + placeholder = envVar.description?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + Button( + onClick = { viewModel.installCatalogEntry(entry) }, + enabled = !isInstalling, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt new file mode 100644 index 00000000..039c5ac0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt @@ -0,0 +1,202 @@ +package com.m57.hermescontrol.ui.mcp.components + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@Composable +fun McpDialogs( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + selectedServerDetail: McpServer?, + onDismissDetail: () -> Unit, + context: Context = LocalContext.current, + clipboardManager: ClipboardManager = LocalClipboardManager.current, +) { + selectedServerDetail?.let { server -> + DetailDialog( + title = server.name, + rows = server.toDetailRows(), + onDismiss = onDismissDetail, + ) + } + + state.activeOAuthFlow?.let { flow -> + AlertDialog( + onDismissRequest = { viewModel.dismissOAuthFlow() }, + confirmButton = { + Button( + onClick = { + flow.authorizationUrl?.let { url -> + try { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(url), + ), + ) + } catch (_: Exception) { + } + } + }, + ) { + Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser)) + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + title = { + Text(stringResource(R.string.mcp_servers_oauth_dialog_title)) + }, + text = { + Column { + Text(stringResource(R.string.mcp_servers_oauth_dialog_desc)) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + ) + flow.error?.let { err -> + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = stringResource(R.string.mcp_servers_oauth_dialog_error, err), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + ) + } + + if (state.showImportDialog) { + AlertDialog( + onDismissRequest = { viewModel.toggleImportDialog() }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.UploadFile, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text(stringResource(R.string.mcp_servers_import_dialog_title)) + } + }, + text = { + Column { + Text( + text = stringResource(R.string.mcp_servers_import_dialog_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + val clip = clipboardManager.getText()?.text + if (!clip.isNullOrBlank()) { + viewModel.updateImportJsonInput(clip) + } + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = stringResource(R.string.mcp_servers_import_dialog_paste), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Spacer(modifier = Modifier.height(spacing.xs)) + OutlinedTextField( + value = state.importJsonInput, + onValueChange = viewModel::updateImportJsonInput, + placeholder = { + Text( + "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + }, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = + Modifier + .fillMaxWidth() + .height(180.dp), + maxLines = 10, + ) + } + }, + confirmButton = { + Button( + onClick = { viewModel.submitImportJson() }, + enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(), + ) { + if (state.isImportingJson) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_import_dialog_importing)) + } else { + Text(stringResource(R.string.mcp_servers_action_submit)) + } + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.toggleImportDialog() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt new file mode 100644 index 00000000..9dd01b89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun McpSectionHeader( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, +) { + val spacing = LocalSpacing.current + Row( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(12.dp), + ).padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + } + trailing?.invoke() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt new file mode 100644 index 00000000..155f6c32 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -0,0 +1,365 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.mcp.McpHealthStatus +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel +import com.m57.hermescontrol.ui.mcp.McpTokenEstimator + +@Composable +fun ServerCard( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + onClick: () -> Unit, + onOpenBrowser: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var showEnv by remember { mutableStateOf(false) } + val isTesting = server.name in state.testingServers + val testResult = state.serverTestResults[server.name] + val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error) + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = + if (server.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) { + // Header row: name + status + toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = server.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + when (healthStatus) { + McpHealthStatus.HEALTHY -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_healthy), + status = StatusBadgeType.SUCCESS, + ) + } + + McpHealthStatus.AUTH_REQUIRED -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_needs_auth), + status = StatusBadgeType.WARNING, + ) + } + + McpHealthStatus.ERROR -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_error), + status = StatusBadgeType.ERROR, + ) + } + + McpHealthStatus.TESTING -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_testing), + status = StatusBadgeType.INFO, + ) + } + + McpHealthStatus.UNKNOWN -> { + ServerStatusBadge(server.status) + } + } + } + Text( + text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Tool count & schema token overhead badge (issue #1029) + val toolInfos = testResult?.tools + val toolCount = toolInfos?.size ?: server.tools?.size + if (toolCount != null && toolCount > 0) { + val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } + val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) + Spacer(modifier = Modifier.height(spacing.xs)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + shape = RoundedCornerShape(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Filled.Build, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + Switch( + checked = server.enabled, + onCheckedChange = { viewModel.toggleServer(server) }, + ) + } + + // URL or Command + if (server.url != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } else if (server.command != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_label_command), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${server.command} ${server.args.orEmpty().joinToString(" ")}", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } + + // Error diagnostics + server.error?.let { error -> + if (error.isNotBlank()) { + Spacer(modifier = Modifier.height(spacing.sm)) + StatusBadge( + text = error, + status = StatusBadgeType.ERROR, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + if (server.auth == "oauth") { + FilledTonalButton( + onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, + modifier = Modifier.weight(1.2f), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + ) { + Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1) + } + } + FilledTonalButton( + onClick = { viewModel.testServer(server.name) }, + enabled = !isTesting, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1) + } else { + Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) + } + } + IconButton(onClick = { viewModel.deleteServer(server.name) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + + // Env vars toggle + TextButton( + onClick = { showEnv = !showEnv }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_vars)) + } + + AnimatedVisibility(visible = showEnv) { + EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing) + } + } + } +} + +@Composable +fun EnvVarSection( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + val isEditing = state.editingEnvFor == server.name + val env = server.env ?: emptyMap() + + Column(modifier = modifier) { + if (env.isEmpty() && !isEditing) { + Text( + text = stringResource(R.string.mcp_servers_env_no_vars), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.xs), + ) + } else { + env.forEach { (key, value) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) + Text( + text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { + Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + } + } + } + } + + if (isEditing) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = state.envKeyInput, + onValueChange = viewModel::updateEnvKey, + label = { Text(stringResource(R.string.mcp_servers_env_key)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.envValueInput, + onValueChange = viewModel::updateEnvValue, + label = { Text(stringResource(R.string.mcp_servers_env_value)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Button(onClick = { viewModel.addEnvVar(server.name) }) { + Text(stringResource(R.string.mcp_servers_env_add)) + } + OutlinedButton(onClick = { viewModel.stopEditingEnv() }) { + Text(stringResource(R.string.action_cancel)) + } + } + } else { + TextButton(onClick = { viewModel.startEditingEnv(server) }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_add)) + } + } + } +} + +@Composable +fun ServerStatusBadge(status: String?) { + if (status == null) return + val badgeType = + when (status.lowercase()) { + "running", "ok", "connected" -> StatusBadgeType.SUCCESS + "error", "failed" -> StatusBadgeType.ERROR + "stopped" -> StatusBadgeType.NEUTRAL + else -> StatusBadgeType.NEUTRAL + } + StatusBadge(text = status, status = badgeType) +} From 5f79bdee54461d8dff7038fb149e744bf442a6a3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 20:02:26 +0300 Subject: [PATCH 16/17] refactor(markdown): decompose MarkdownText UI and styling Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable, and MarkdownMediaResolver into ui/chat/markdown package. --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 506 +----------------- .../ui/chat/markdown/MarkdownInlineStyler.kt | 342 ++++++++++++ .../ui/chat/markdown/MarkdownInlineText.kt | 113 ++++ .../ui/chat/markdown/MarkdownMediaResolver.kt | 29 + .../ui/chat/markdown/MarkdownTableView.kt | 103 ++++ 5 files changed, 595 insertions(+), 498 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index ffb7b3a4..6a1feeb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -63,6 +63,10 @@ import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.ui.chat.markdown.FnNote import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineText +import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver +import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable import com.m57.hermescontrol.ui.chat.markdown.MdBlock import com.m57.hermescontrol.ui.chat.markdown.TableAlign import com.m57.hermescontrol.ui.chat.markdown.parseBlocks @@ -347,7 +351,7 @@ fun MarkdownText( } is MdBlock.Video -> { - val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) } + val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } var showVideoDialog by remember { mutableStateOf(false) } androidx.compose.foundation.layout.Box( modifier = @@ -369,7 +373,7 @@ fun MarkdownText( } is MdBlock.Image -> { - val model: Any = remember(block.uri) { resolveImageUrl(block.uri) } + val model: Any = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } val isGif = remember(block.uri) { block.uri.contains(".gif", ignoreCase = true) || @@ -509,179 +513,6 @@ fun MarkdownText( } } -@Composable -private fun MarkdownInlineText( - text: String, - textColor: Color, - latexMeasurer: LatexMeasurerState, - style: TextStyle, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - modifier: Modifier = Modifier, -) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } - val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val resolvedStyle = - style.copy( - textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, - ) - val processedText = - remember(text, isRtl) { - if (isRtl) BidiUtils.anchorTrailingRtl(text) else text - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - val segments = remember(processedText) { splitInlineMath(processedText) } - if (segments.none { it is InlineMathSegment.Math }) { - Text( - text = - remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { - parseInlineSource( - text = processedText, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ) - }, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - return@CompositionLocalProvider - } - - val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } - } - } - val annotated = - buildAnnotatedString { - segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } - - is InlineMathSegment.Text -> { - append( - parseInlineSource( - text = segment.value, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ), - ) - } - } - } - } - Text( - text = annotated, - inlineContent = inlineContent, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - } -} - -@Composable -private fun MarkdownTable( - block: MdBlock.Table, - textColor: Color, -) { - val isRtl = - remember(block) { - block.header.any { BidiUtils.isRtlText(it) } || - block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } - } - val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val headerBg = textColor.copy(alpha = 0.08f) - val alignments = block.alignments - CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { - Column( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 4.dp), - ) { - // Header row - Row(modifier = Modifier.background(headerBg)) { - block.header.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Bold, - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.25f)) - // Body rows - block.rows.forEach { row -> - Row { - row.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.1f)) - } - } - } -} - -private fun tableTextAlign(align: TableAlign?): TextAlign? = - when (align) { - TableAlign.CENTER -> TextAlign.Center - TableAlign.RIGHT -> TextAlign.End - else -> null - } - -/** - * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, - * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and - * bare URLs, plus search-query highlighting. - */ internal fun parseInline( text: String, textColor: Color, @@ -691,14 +522,8 @@ internal fun parseInline( highlights: SearchHighlightColors, isRtl: Boolean = BidiUtils.isRtlText(text), ): AnnotatedString = - parseInlineSource( - text = - splitInlineMath(text).joinToString(separator = "") { segment -> - when (segment) { - is InlineMathSegment.Math -> segment.latex - is InlineMathSegment.Text -> segment.value - } - }, + MarkdownInlineStyler.parseInline( + text = text, textColor = textColor, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, @@ -706,318 +531,3 @@ internal fun parseInline( highlights = highlights, isRtl = isRtl, ) - -private fun parseInlineSource( - text: String, - textColor: Color, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - isRtl: Boolean = BidiUtils.isRtlText(text), -): AnnotatedString { - val searchHighlightColor = - if (isCurrentMatch) { - highlights.currentSearchBackground to highlights.currentSearchForeground - } else { - highlights.searchBackground to highlights.searchForeground - } - - return buildAnnotatedString { - var i = 0 - val src = text - while (i < src.length) { - when { - // ***bold italic*** - src.startsWith("***", i) -> { - val end = src.indexOf("***", i + 3) - if (end != -1) { - val raw = src.substring(i + 3, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 3 - } else { - append(src[i]) - i++ - } - } - - // **bold** - src.startsWith("**", i) -> { - val end = src.indexOf("**", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ~~strike~~ - src.startsWith("~~", i) -> { - val end = src.indexOf("~~", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // *italic* - src.startsWith("*", i) -> { - val end = src.indexOf('*', i + 1) - if (end != -1 && end > i + 1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ==highlight== - src.startsWith("==", i) -> { - val end = src.indexOf("==", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ^superscript^ - src.startsWith("^", i) -> { - val end = src.indexOf('^', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ~subscript~ (single tilde; ~~ handled above) - src.startsWith("~", i) -> { - val end = src.indexOf('~', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // key - src.startsWith("", i) -> { - val end = src.indexOf("", i) - if (end != -1) { - val raw = src.substring(i + 5, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = textColor.copy(alpha = 0.12f), - ), - ) { - append(toAppend) - } - i = end + 6 - } else { - append(src[i]) - i++ - } - } - - // [^ref] footnote marker -> superscript - src.startsWith("[^", i) -> { - val close = src.indexOf(']', i) - if (close != -1) { - val id = src.substring(i + 2, close) - withStyle( - SpanStyle( - baselineShift = BaselineShift.Superscript, - color = linkColor, - fontWeight = FontWeight.Bold, - ), - ) { - append("[$id]") - } - i = close + 1 - } else { - append(src[i]) - i++ - } - } - - // [text](url) - src.startsWith("[", i) -> { - val close = src.indexOf(']', i) - if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { - val urlEnd = src.indexOf(')', close + 2) - if (urlEnd != -1) { - val label = src.substring(i + 1, close) - val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label - val url = src.substring(close + 2, urlEnd) - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(labelToAppend) - } - pop() - i = urlEnd + 1 - } else { - append(src[i]) - i++ - } - } else { - append(src[i]) - i++ - } - } - - // `inline code` - src.startsWith("`", i) -> { - val end = src.indexOf('`', i + 1) - if (end != -1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // bare URL - URL_PATTERN.matchAt(src, i) != null -> { - val match = URL_PATTERN.matchAt(src, i)!! - val url = match.value - val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(urlToAppend) - } - pop() - i = match.range.last + 1 - } - - // Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate - // so an English word at the start/middle of an Arabic line doesn't flip the layout direction. - isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && - Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { - var end = i + Character.charCount(src.codePointAt(i)) - while (end < src.length) { - val cp = src.codePointAt(end) - val dir = Character.getDirectionality(cp) - if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || - ( - dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && - Character.getDirectionality(src.codePointAt(end + 1)) == - Character.DIRECTIONALITY_LEFT_TO_RIGHT - ) - ) { - end += Character.charCount(cp) - } else { - break - } - } - val ltrSnippet = src.substring(i, end) - append(BidiUtils.wrapLtrIsolate(ltrSnippet)) - i = end - } - - // search highlight - searchQuery.isNotEmpty() && - src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { - withStyle( - SpanStyle( - background = searchHighlightColor.first, - color = searchHighlightColor.second, - ), - ) { - append(src.substring(i, i + searchQuery.length)) - } - i += searchQuery.length - } - - else -> { - append(src[i]) - i++ - } - } - } - } -} - -private fun resolveImageUrl(uri: String): String { - val trimmed = uri.trim() - if (trimmed.isBlank()) return uri - if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { - return trimmed - } - val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") - val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() - val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) - if (mediaUrl != null) return mediaUrl - - if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { - val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" - val sep = if (cleanPath.contains("?")) "&" else "?" - return if (token.isNotBlank() && !cleanPath.contains("token=")) { - "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" - } else { - "${baseUrl.trimEnd('/')}$cleanPath" - } - } - return uri -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt new file mode 100644 index 00000000..2aed606e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -0,0 +1,342 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +object MarkdownInlineStyler { + private val URL_PATTERN = + Regex( + """https?://[^\s<>'"\)\],;]+(? + when (segment) { + is InlineMathSegment.Math -> segment.latex + is InlineMathSegment.Text -> segment.value + } + }, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + + fun parseInlineSource( + text: String, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + isRtl: Boolean = BidiUtils.isRtlText(text), + ): AnnotatedString { + val searchHighlightColor = + if (isCurrentMatch) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + + return buildAnnotatedString { + var i = 0 + val src = text + while (i < src.length) { + when { + // ***bold italic*** + src.startsWith("***", i) -> { + val end = src.indexOf("***", i + 3) + if (end != -1) { + val raw = src.substring(i + 3, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 3 + } else { + append(src[i]) + i++ + } + } + + // **bold** + src.startsWith("**", i) -> { + val end = src.indexOf("**", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ~~strike~~ + src.startsWith("~~", i) -> { + val end = src.indexOf("~~", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // *italic* + src.startsWith("*", i) -> { + val end = src.indexOf('*', i + 1) + if (end != -1 && end > i + 1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ==highlight== + src.startsWith("==", i) -> { + val end = src.indexOf("==", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(background = highlights.markupBackground)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ^superscript^ + src.startsWith("^", i) -> { + val end = src.indexOf('^', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ~subscript~ (single tilde; ~~ handled above) + src.startsWith("~", i) -> { + val end = src.indexOf('~', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // key + src.startsWith("", i) -> { + val end = src.indexOf("", i) + if (end != -1) { + val raw = src.substring(i + 5, end) + val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = textColor.copy(alpha = 0.12f), + ), + ) { + append(toAppend) + } + i = end + 6 + } else { + append(src[i]) + i++ + } + } + + // [^ref] footnote marker -> superscript + src.startsWith("[^", i) -> { + val close = src.indexOf(']', i) + if (close != -1) { + val id = src.substring(i + 2, close) + withStyle( + SpanStyle( + baselineShift = BaselineShift.Superscript, + color = linkColor, + fontWeight = FontWeight.Bold, + ), + ) { + append("[$id]") + } + i = close + 1 + } else { + append(src[i]) + i++ + } + } + + // [text](url) + src.startsWith("[", i) -> { + val close = src.indexOf(']', i) + if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { + val urlEnd = src.indexOf(')', close + 2) + if (urlEnd != -1) { + val label = src.substring(i + 1, close) + val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label + val url = src.substring(close + 2, urlEnd) + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(labelToAppend) + } + pop() + i = urlEnd + 1 + } else { + append(src[i]) + i++ + } + } else { + append(src[i]) + i++ + } + } + + // `inline code` + src.startsWith("`", i) -> { + val end = src.indexOf('`', i + 1) + if (end != -1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = textColor.copy(alpha = 0.08f), + ), + ) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // bare URL + URL_PATTERN.matchAt(src, i) != null -> { + val match = URL_PATTERN.matchAt(src, i)!! + val url = match.value + val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(urlToAppend) + } + pop() + i = match.range.last + 1 + } + + // Plain text / words in RTL + isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && + Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + var end = i + Character.charCount(src.codePointAt(i)) + while (end < src.length) { + val cp = src.codePointAt(end) + val dir = Character.getDirectionality(cp) + if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || + ( + dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && + Character.getDirectionality(src.codePointAt(end + 1)) == + Character.DIRECTIONALITY_LEFT_TO_RIGHT + ) + ) { + end += Character.charCount(cp) + } else { + break + } + } + val ltrSnippet = src.substring(i, end) + append(BidiUtils.wrapLtrIsolate(ltrSnippet)) + i = end + } + + // search highlight + searchQuery.isNotEmpty() && + src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { + withStyle( + SpanStyle( + background = searchHighlightColor.first, + color = searchHighlightColor.second, + ), + ) { + append(src.substring(i, i + searchQuery.length)) + } + i += searchQuery.length + } + + else -> { + append(src[i]) + i++ + } + } + } + } + } + + private fun isRtr(isRtl: Boolean): Boolean = isRtl +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt new file mode 100644 index 00000000..167ca1e1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import com.hrm.latex.renderer.measure.LatexMeasurerState +import com.hrm.latex.renderer.model.LatexConfig +import com.hrm.latex.renderer.model.LatexTheme +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun MarkdownInlineText( + text: String, + textColor: Color, + latexMeasurer: LatexMeasurerState, + style: TextStyle, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + modifier: Modifier = Modifier, +) { + val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val resolvedStyle = + style.copy( + textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, + ) + val processedText = + remember(text, isRtl) { + if (isRtl) BidiUtils.anchorTrailingRtl(text) else text + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + val segments = remember(processedText) { splitInlineMath(processedText) } + if (segments.none { it is InlineMathSegment.Math }) { + Text( + text = + remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { + MarkdownInlineStyler.parseInlineSource( + text = processedText, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + }, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + return@CompositionLocalProvider + } + + val config = + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) + val inlineContent = mutableMapOf() + segments.forEachIndexed { index, segment -> + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + } + } + val annotated = + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) + } + + is InlineMathSegment.Text -> { + append( + MarkdownInlineStyler.parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } + } + } + } + Text( + text = annotated, + inlineContent = inlineContent, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt new file mode 100644 index 00000000..13d8bda6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.GatewayFileClient + +object MarkdownMediaResolver { + fun resolveImageUrl(uri: String): String { + val trimmed = uri.trim() + if (trimmed.isBlank()) return uri + if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { + return trimmed + } + val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") + val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() + val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) + if (mediaUrl != null) return mediaUrl + + if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { + val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" + val sep = if (cleanPath.contains("?")) "&" else "?" + return if (token.isNotBlank() && !cleanPath.contains("token=")) { + "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" + } else { + "${baseUrl.trimEnd('/')}$cleanPath" + } + } + return uri + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt new file mode 100644 index 00000000..9d6ef28c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.util.BidiUtils + +private val TABLE_COL_WIDTH = 140.dp + +@Composable +fun MarkdownTable( + block: MdBlock.Table, + textColor: Color, + modifier: Modifier = Modifier, +) { + val isRtl = + remember(block) { + block.header.any { BidiUtils.isRtlText(it) } || + block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } + } + val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val headerBg = textColor.copy(alpha = 0.08f) + val alignments = block.alignments + CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { + Column( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) { + // Header row + Row(modifier = Modifier.background(headerBg)) { + block.header.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.25f)) + // Body rows + block.rows.forEach { row -> + Row { + row.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + } + } + } +} + +private fun tableTextAlign(align: TableAlign?): TextAlign? = + when (align) { + TableAlign.CENTER -> TextAlign.Center + TableAlign.RIGHT -> TextAlign.End + else -> null + } From 02106c3781b0469c3db570a34c0068774b032922 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 20:13:00 +0300 Subject: [PATCH 17/17] refactor(core): execute Phase 3 state and delegate extractions - Extract ChatClarifyDelegate from ChatViewModel for clarify prompt flow. - Harden GroupChatViewModel maps with ConcurrentHashMap. - Add failure circuit breaker to MCP OAuth polling loop. --- .../ui/bots/group/GroupChatViewModel.kt | 13 +- .../ui/chat/ChatClarifyDelegate.kt | 123 ++++++++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 112 ++-------------- .../ui/mcp/McpServersViewModel.kt | 12 +- 4 files changed, 153 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 65bc2243..eb10325e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -33,6 +33,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import java.util.UUID +import java.util.concurrent.ConcurrentHashMap data class GroupChatUiState( val groupName: String = "", @@ -74,22 +75,22 @@ class GroupChatViewModel( private var activeEpoch: String = "" // Map of bot.name -> seen count of non-system messages - private val memberWatermarks = mutableMapOf() + private val memberWatermarks = ConcurrentHashMap() // Map of bot.name -> active session info in this group - private val memberSessions = mutableMapOf() + private val memberSessions = ConcurrentHashMap() // In-flight turn completion deferreds keyed by session_id - private val inFlightTurns = mutableMapOf>() + private val inFlightTurns = ConcurrentHashMap>() // Map of session_id -> active streaming message ID - private val activeStreamMsgId = mutableMapOf() + private val activeStreamMsgId = ConcurrentHashMap() // Map of session_id -> bot profile - private val sessionToBot = mutableMapOf() + private val sessionToBot = ConcurrentHashMap() // Map of session_id -> last activity epoch ms (streaming token, tool call, thinking) - private val sessionLastActivity = mutableMapOf() + private val sessionLastActivity = ConcurrentHashMap() init { if (groupName.isNotBlank()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt new file mode 100644 index 00000000..e24f6b6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -0,0 +1,123 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ChatClarifyDelegate( + private val uiState: MutableStateFlow, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val persistMessage: suspend (ChatMessage, String) -> Unit, + private val wsClient: HermesWsClient = HermesWsClient, + private val trackRequest: (String, String) -> Unit = { _, _ -> }, +) { + fun respondToClarify(option: String) { + val clarify = uiState.value.clarifyRequest + // Only use synthesized qid when this is a true batch or legacy with explicit qid. + val qid = + if (clarify != null && + (clarify.questionId != null || clarify.questions.isNotEmpty()) + ) { + clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId + } else { + null + } + if (qid != null && clarify?.resolvedQuestions?.size == 1) { + respondToClarifyBatch(mapOf(qid to option)) + } else { + respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option) + } + } + + fun respondToClarifyBatch( + answers: Map, + singleFallbackAnswer: String? = null, + ) { + val sessionId = uiState.value.currentSessionId ?: return + val clarify = uiState.value.clarifyRequest + val clarifyId = clarify?.clarifyId + val isBatch = !clarify?.questions.isNullOrEmpty() + val questions = clarify?.resolvedQuestions.orEmpty() + uiState.update { it.copy(clarifyRequest = null) } + + val displayContent = + if (questions.size > 1) { + questions + .mapIndexed { index, q -> + val ans = answers[q.qid]?.trim().orEmpty() + "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" + }.joinToString("\n") + } else { + val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty() + loneAns + } + + val userMessage = + ChatMessage( + role = MessageRole.USER, + content = displayContent, + ) + + uiState.update { state -> + state.copy( + messages = state.messages + userMessage, + isAgentTyping = true, + ) + } + + scope.launch(ioDispatcher) { + persistMessage(userMessage, sessionId) + } + + scope.launch(ioDispatcher) { + if (isBatch) { + for (q in questions) { + val ans = answers[q.qid]?.trim().orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + "question_id" to q.qid, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } else { + // Legacy single: only include question_id when explicitly present. + val qid = clarify?.questionId + val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + if (qid != null) { + params["question_id"] = qid + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 551faf95..7282b615 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -435,6 +435,16 @@ class ChatViewModel( addSystemMessage = { text -> addSystemMessage(text) }, ) + private val clarifyDelegate = + ChatClarifyDelegate( + uiState = _uiState, + scope = viewModelScope, + ioDispatcher = ioDispatcher, + persistMessage = { msg, sid -> repo.persistMessage(msg, sid) }, + wsClient = wsClient, + trackRequest = { id, method -> trackRequest(id, method) }, + ) + private val streamingController = ChatStreamingController( scope = viewModelScope, @@ -3172,110 +3182,12 @@ class ChatViewModel( } } - fun respondToClarify(option: String) { - val clarify = _uiState.value.clarifyRequest - // Only use synthesized qid when this is a true batch or legacy with explicit qid. - val qid = - if (clarify != null && - (clarify.questionId != null || clarify.questions.isNotEmpty()) - ) { - clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId - } else { - null - } - if (qid != null && clarify?.resolvedQuestions?.size == 1) { - respondToClarifyBatch(mapOf(qid to option)) - } else { - respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option) - } - } + fun respondToClarify(option: String) = clarifyDelegate.respondToClarify(option) fun respondToClarifyBatch( answers: Map, singleFallbackAnswer: String? = null, - ) { - val sessionId = _uiState.value.currentSessionId ?: return - val clarify = _uiState.value.clarifyRequest - val clarifyId = clarify?.clarifyId - val isBatch = !clarify?.questions.isNullOrEmpty() - val questions = clarify?.resolvedQuestions.orEmpty() - _uiState.update { it.copy(clarifyRequest = null) } - - val displayContent = - if (questions.size > 1) { - questions - .mapIndexed { index, q -> - val ans = answers[q.qid]?.trim().orEmpty() - "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" - }.joinToString("\n") - } else { - val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty() - loneAns - } - - val userMessage = - ChatMessage( - role = MessageRole.USER, - content = displayContent, - ) - - _uiState.update { state -> - state.copy( - messages = state.messages + userMessage, - isAgentTyping = true, - ) - } - - viewModelScope.launch(ioDispatcher) { - repo.persistMessage(userMessage, sessionId) - } - - viewModelScope.launch(ioDispatcher) { - if (isBatch) { - for (q in questions) { - val ans = answers[q.qid]?.trim().orEmpty() - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to ans, - "answer" to ans, - "question_id" to q.qid, - ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId - } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) - } - } else { - // Legacy single: only include question_id when explicitly present. - val qid = clarify?.questionId - val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty() - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to ans, - "answer" to ans, - ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId - } - if (qid != null) { - params["question_id"] = qid - } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) - } - } - } + ) = clarifyDelegate.respondToClarifyBatch(answers, singleFallbackAnswer) fun clearError() { _uiState.update { it.copy(errorMessage = null) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 4e9ead31..3c64bacd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -585,6 +585,7 @@ class McpServersViewModel : oauthPollJob = viewModelScope.launch { var polling = true + var consecutiveFailures = 0 while (polling) { kotlinx.coroutines.delay(2000) val result = @@ -629,7 +630,16 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - // Keep polling or stop after too many failures? Let's just log/toast n retry a few times + consecutiveFailures++ + if (consecutiveFailures >= 10) { + polling = false + _uiState.update { + it.copy( + activeOAuthFlow = null, + toastMessage = "OAuth polling timed out after repeated network failures", + ) + } + } } } }