Merge pull request #1084 from Hy4ri/refactor/phase1-code-cleanup

refactor(core): comprehensive architectural cleanup (all phases)
This commit is contained in:
M57
2026-09-11 23:09:52 +03:00
committed by GitHub
77 changed files with 9446 additions and 7094 deletions
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
data class GroupChatUiState( data class GroupChatUiState(
val groupName: String = "", val groupName: String = "",
@@ -74,22 +75,22 @@ class GroupChatViewModel(
private var activeEpoch: String = "" private var activeEpoch: String = ""
// Map of bot.name -> seen count of non-system messages // Map of bot.name -> seen count of non-system messages
private val memberWatermarks = mutableMapOf<String, Int>() private val memberWatermarks = ConcurrentHashMap<String, Int>()
// Map of bot.name -> active session info in this group // Map of bot.name -> active session info in this group
private val memberSessions = mutableMapOf<String, MemberSession>() private val memberSessions = ConcurrentHashMap<String, MemberSession>()
// In-flight turn completion deferreds keyed by session_id // In-flight turn completion deferreds keyed by session_id
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>() private val inFlightTurns = ConcurrentHashMap<String, CompletableDeferred<String>>()
// Map of session_id -> active streaming message ID // Map of session_id -> active streaming message ID
private val activeStreamMsgId = mutableMapOf<String, String>() private val activeStreamMsgId = ConcurrentHashMap<String, String>()
// Map of session_id -> bot profile // Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>() private val sessionToBot = ConcurrentHashMap<String, ProfileInfo>()
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking) // Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
private val sessionLastActivity = mutableMapOf<String, Long>() private val sessionLastActivity = ConcurrentHashMap<String, Long>()
init { init {
if (groupName.isNotBlank()) { if (groupName.isNotBlank()) {
@@ -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<ProfileInfo>,
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),
)
}
}
}
}
}
},
)
}
}
@@ -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))
}
},
)
}
}
@@ -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),
)
}
}
}
}
}
}
}
@@ -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,
)
}
}
}
}
}
}
}
@@ -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<ChatUiState>,
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<String, String>,
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<String, Any>(
"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<String, Any>(
"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) },
)
}
}
}
}
@@ -1,13 +1,8 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource 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.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatInputBar
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects 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.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip 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.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.common.ActionProgressDialog 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.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch 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 private const val SESSION_SYNC_INTERVAL_MS = 30_000L
@@ -304,7 +289,6 @@ fun ChatScreen(
viewModel.consumeComposerTextRestore() viewModel.consumeComposerTextRestore()
} }
} }
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var showSubagentInspectionSheet 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 mediaLaunchers =
val sttNotAvailableMsg = stringResource(R.string.stt_not_available) rememberChatMediaLaunchers(
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) inputFieldValue = inputFieldValue,
onInputFieldValueChange = { inputFieldValue = it },
// Speech-to-text recognition launcher (issue #194) onAddAttachment = { uri, name, mimeType, size ->
val speechLauncher = viewModel.addAttachment(uri, name, mimeType, size)
rememberLauncherForActivityResult( },
ActivityResultContracts.StartActivityForResult(), onAddAttachments = { attachments ->
) { result -> viewModel.addAttachments(attachments)
ExternalActivityLifecycleGuard.externalActivityReturned() },
isListening = false onShowMessage = { msg ->
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 {
scrollScope.launch { scrollScope.launch {
snackbarHostState.showSnackbar(sttPermissionDeniedMsg) snackbarHostState.showSnackbar(msg)
} }
} },
} launchExternalActivity = launchExternalActivity,
context = context,
// Multi-file picker for attachments (issue #195). )
val filePickerLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents(),
) { uris: List<Uri> ->
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<Uri?>(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,
)
}
}
}
}
// Lifecycle effects, permissions, session switching, auto-scroll, errors // Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects( ChatLifecycleEffects(
@@ -592,80 +463,7 @@ fun ChatScreen(
// Issue #890: launch update check — non-blocking banner when a // Issue #890: launch update check — non-blocking banner when a
// newer release exists. Tapping "Update" opens the in-place dialog. // newer release exists. Tapping "Update" opens the in-place dialog.
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() ChatAppUpdateSection()
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)
},
)
}
},
)
}
AnimatedVisibility( AnimatedVisibility(
visible = searchState.isActive, visible = searchState.isActive,
@@ -808,75 +606,16 @@ fun ChatScreen(
// Jump to bottom after send (serialized through the controller). // Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true) scrollController.jumpToBottom(animated = true)
}, },
onMicTap = { onMicTap = mediaLaunchers.onMicTap,
if (isListening) { isListening = mediaLaunchers.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,
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected, isConnected = state.isConnected,
commandCatalog = state.commandCatalog, commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts, slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments, pendingAttachments = state.pendingAttachments,
onCameraTap = { onCameraTap = mediaLaunchers.onCameraTap,
try { onImageTap = mediaLaunchers.onImageTap,
val timeStamp = onFileTap = mediaLaunchers.onFileTap,
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("*/*")
}
},
onRemoveAttachment = viewModel::removeAttachment, onRemoveAttachment = viewModel::removeAttachment,
onPreviewAttachment = { attachment -> onPreviewAttachment = { attachment ->
if (attachment.isImage) { if (attachment.isImage) {
@@ -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
// `<untrusted_tool_result>` 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:<path>`,
* `@file:<ref>`, 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<ChatMessage>): List<ChatMessage> {
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<ChatMessage>,
currentMessages: List<ChatMessage>,
): List<ChatMessage> {
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<ChatMessage>,
currentMessages: List<ChatMessage>,
sessionId: String,
offset: Int,
): List<ChatMessage> {
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<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
@@ -70,217 +70,6 @@ private sealed interface PrepareAttachmentResult {
data object Unreadable : 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
// `<untrusted_tool_result>` 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:<path>`,
* `@file:<ref>`, 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<ChatMessage>): List<ChatMessage> {
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<ChatMessage>,
currentMessages: List<ChatMessage>,
): List<ChatMessage> {
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<ChatMessage>,
currentMessages: List<ChatMessage>,
sessionId: String,
offset: Int,
): List<ChatMessage> {
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<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
data class ChatUiState( data class ChatUiState(
val messages: List<ChatMessage> = emptyList(), val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null, val currentSessionId: String? = null,
@@ -646,6 +435,16 @@ class ChatViewModel(
addSystemMessage = { text -> addSystemMessage(text) }, 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 = private val streamingController =
ChatStreamingController( ChatStreamingController(
scope = viewModelScope, scope = viewModelScope,
@@ -3383,110 +3182,12 @@ class ChatViewModel(
} }
} }
fun respondToClarify(option: String) { fun respondToClarify(option: String) = clarifyDelegate.respondToClarify(option)
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( fun respondToClarifyBatch(
answers: Map<String, String>, answers: Map<String, String>,
singleFallbackAnswer: String? = null, singleFallbackAnswer: String? = null,
) { ) = clarifyDelegate.respondToClarifyBatch(answers, singleFallbackAnswer)
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<String, Any>(
"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<String, Any>(
"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) },
)
}
}
}
fun clearError() { fun clearError() {
_uiState.update { it.copy(errorMessage = null) } _uiState.update { it.copy(errorMessage = null) }
File diff suppressed because it is too large Load Diff
@@ -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)
},
)
}
}
@@ -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<Attachment>) -> Unit,
onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit,
context: Context = LocalContext.current,
): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(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<Uri> ->
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,
)
}
}
@@ -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<>'"\)\],;]+(?<![.\),;])""",
RegexOption.IGNORE_CASE,
)
fun parseInline(
text: String,
textColor: Color,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
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
}
},
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++
}
}
// <kbd>key</kbd>
src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", 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
}
@@ -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<String, InlineTextContent>()
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,
)
}
}
@@ -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
}
}
@@ -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<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
data class DefItem(
val term: String,
val definitions: List<String>,
)
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
}
@@ -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<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
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<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
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<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
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<String>()
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<String>()
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<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = 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<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
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<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // 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<String>,
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<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
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<List<String>>()
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<String> {
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<String>,
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<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
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
}
@@ -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
}
@@ -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)
}
},
)
}
@@ -34,6 +34,33 @@ inline fun <T> 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 <T> ViewModel.safeLaunchAction(
currentJob: Job? = null,
crossinline onStart: () -> Unit = {},
crossinline apiCall: suspend () -> NetworkResult<T>,
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]) * Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no * from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
@@ -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,
)
}
@@ -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>,
): 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"
}
}
}
@@ -87,6 +87,10 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows 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 @Composable
fun McpServersScreen( fun McpServersScreen(
@@ -247,842 +251,13 @@ fun McpServersScreen(
} }
} }
showDetail?.let { server -> McpDialogs(
DetailDialog( state = state,
title = server.name, viewModel = viewModel,
rows = server.toDetailRows(), spacing = spacing,
onDismiss = { showDetail = null }, selectedServerDetail = showDetail,
) onDismissDetail = { showDetail = null },
} context = context,
clipboardManager = clipboardManager,
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")
}
}
},
) )
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<McpCatalogEntry>,
) {
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)
},
)
}
}
}
}
}
} }
@@ -297,13 +297,7 @@ class McpServersViewModel :
} }
_uiState.update { state -> _uiState.update { state ->
val errSummary = errors.joinToString("; ") val msg = McpImportSummary.formatSummary(successCount, errors)
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"
}
state.copy( state.copy(
isImportingJson = false, isImportingJson = false,
showImportDialog = false, showImportDialog = false,
@@ -357,38 +351,7 @@ class McpServersViewModel :
} }
_uiState.update { it.copy(addingServer = true) } _uiState.update { it.copy(addingServer = true) }
viewModelScope.launch { viewModelScope.launch {
val request = val request = AddServerRequestBuilder.build(state)
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 result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.addMcpServer(request) } safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
@@ -622,6 +585,7 @@ class McpServersViewModel :
oauthPollJob = oauthPollJob =
viewModelScope.launch { viewModelScope.launch {
var polling = true var polling = true
var consecutiveFailures = 0
while (polling) { while (polling) {
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
val result = val result =
@@ -666,7 +630,16 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { 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",
)
}
}
} }
} }
} }
@@ -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)
},
)
}
}
}
}
}
}
@@ -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<McpCatalogEntry>,
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)
},
)
}
}
}
}
}
}
@@ -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))
}
},
)
}
}
@@ -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()
}
}
@@ -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)
}
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType 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 * 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"
}
@@ -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() }
}
}
@@ -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,
)
}
}
@@ -116,17 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
import kotlinx.serialization.json.Json import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
import kotlinx.serialization.json.JsonArray import com.m57.hermescontrol.ui.sessions.components.SessionCard
import kotlinx.serialization.json.JsonElement import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
import kotlinx.serialization.json.JsonObject import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
import kotlinx.serialization.json.JsonPrimitive import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
import kotlinx.serialization.json.contentOrNull import com.m57.hermescontrol.ui.sessions.components.automationGroups
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 * Auto-load the next history page when the user scrolls to within this many
@@ -197,89 +192,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<SessionTreeItem> = private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) { if (state.isSearchMode) {
state.searchResults.map { searchResult -> state.searchResults.map { searchResult ->
@@ -774,65 +686,14 @@ fun SessionsScreen(
else -> { else -> {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ─────────────────────────────────────── // ── Stats row ───────────────────────────────────────
Row( SessionsStatsRow(
modifier = total = state.total,
Modifier loadedMessageCount = loadedMessageCount,
.fillMaxWidth() section = state.section,
.height(IntrinsicSize.Max) spacing = spacing,
.padding(horizontal = spacing.md, vertical = spacing.sm), statusColors = statusColors,
horizontalArrangement = Arrangement.spacedBy(spacing.sm), onPruneClick = { viewModel.showPruneDialog() },
) { )
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,
)
}
}
}
}
}
// ── Session list ──────────────────────────────────── // ── Session list ────────────────────────────────────
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -916,66 +777,21 @@ fun SessionsScreen(
verticalArrangement = listItemSpacing, verticalArrangement = listItemSpacing,
) { ) {
if (state.section == HistorySection.AUTOMATIONS) { if (state.section == HistorySection.AUTOMATIONS) {
automationSessionGroups.forEach { group -> automationGroups(
item(key = "automation-group:${group.key}") { groups = automationSessionGroups,
val expanded = group.key in expandedAutomationGroups expandedGroups = expandedAutomationGroups,
Row( sessionItemsById = sessionItemsById,
modifier = spacing = spacing,
Modifier onToggleGroup = { groupKey ->
.fillMaxWidth() expandedAutomationGroups =
.clickable { if (groupKey in expandedAutomationGroups) {
expandedAutomationGroups = expandedAutomationGroups - groupKey
if (expanded) { } else {
expandedAutomationGroups - group.key expandedAutomationGroups + groupKey
} 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,
)
} }
} },
} renderSessionCard = sessionCard,
if (group.key in expandedAutomationGroups) { )
items(group.sessions, key = { it.id }) { session ->
sessionItemsById[session.id]?.let { sessionCard(it) }
}
}
}
} else { } else {
items(sessionsToDisplay, key = { it.session.id }) { item -> items(sessionsToDisplay, key = { it.session.id }) { item ->
sessionCard(item) sessionCard(item)
@@ -1021,533 +837,21 @@ fun SessionsScreen(
} }
// ── Bulk action toolbar (animated) ────────────────────────────────── // ── Bulk action toolbar (animated) ──────────────────────────────────
AnimatedVisibility( SessionsBulkActionBar(
visible = state.isSelecting && hasSelection, visible = state.isSelecting && hasSelection,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), allVisibleSessionsSelected = allVisibleSessionsSelected,
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), selectedCount = state.selectedIds.size,
modifier = Modifier.fillMaxSize(), isDeletingBulk = state.isDeletingBulk,
) { spacing = spacing,
Box( statusColors = statusColors,
modifier = Modifier.fillMaxSize(), onSelectAllToggle = {
contentAlignment = Alignment.BottomCenter, if (allVisibleSessionsSelected) {
) { viewModel.clearSelection()
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)
} else { } else {
Icon( viewModel.selectAll(visibleSessionIds)
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = statusColors.error,
)
} }
}, },
enabled = !isDeleting, onDeleteSelected = { viewModel.requestBulkDelete() },
onClick = onDelete,
) )
} }
} }
@@ -147,11 +147,17 @@ class SessionsViewModel :
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
_uiState.update { _uiState.update {
val newSessions = data.sessions.orEmpty().pinnedFirst() 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( it.copy(
sessions = newSessions, sessions = newSessions,
total = if (hasMore) data.total else newSessions.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
) )
} }
} }
@@ -229,14 +235,20 @@ class SessionsViewModel :
if (requestGeneration != generation) return@safeLaunchLoad if (requestGeneration != generation) return@safeLaunchLoad
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty().pinnedFirst() 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 { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
sessions = sessionsList, sessions = sessionsList,
total = if (hasMore) data.total else sessionsList.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
selectedIds = emptySet(), selectedIds = emptySet(),
) )
} }
@@ -284,14 +296,20 @@ class SessionsViewModel :
(it.sessions + data.sessions) (it.sessions + data.sessions)
.distinctBy { s -> s.id } .distinctBy { s -> s.id }
.pinnedFirst() .pinnedFirst()
val receivedFullPage = data.sessions.size >= PAGE_SIZE
val addedNewItems = newSessions.size > it.sessions.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( it.copy(
isLoadingMore = false, isLoadingMore = false,
sessions = newSessions, sessions = newSessions,
total = if (hasMore) data.total else newSessions.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
) )
} }
} }
@@ -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<AutomationSessionGroup>,
expandedGroups: Set<String>,
sessionItemsById: Map<String, SessionTreeItem>,
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) }
}
}
}
}
@@ -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()
},
)
}
}
}
@@ -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,
)
}
}
@@ -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()
},
)
}
}
}
@@ -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,
),
)
}
}
}
}
}
}
@@ -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))
}
},
)
}
}
@@ -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,
)
}
}
}
}
}
}
@@ -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<Skill>,
query: String,
selectedStatus: SkillFilter,
selectedCategory: String?,
sourceFilter: String?,
): List<Skill> =
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<Skill>): List<String> = skills.mapNotNull { it.category }.distinct().sorted()
fun extractSources(skills: List<Skill>): List<String> = skills.mapNotNull { it.source }.distinct().sorted()
}
@@ -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
}
}
File diff suppressed because it is too large Load Diff
@@ -39,10 +39,6 @@ enum class SkillFilter(
DISABLED(R.string.skills_status_disabled), DISABLED(R.string.skills_status_disabled),
} }
enum class CategoryFilter {
ALL,
}
data class SkillsUiState( data class SkillsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(), val skills: List<Skill> = emptyList(),
@@ -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<HubSkill?>(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) },
)
}
}
}
}
}
}
@@ -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))
}
}
}
}
}
}
@@ -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<Skill?>(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<String?>(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 },
)
}
}
@@ -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,
)
}
}
}
}
}
}
}
@@ -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),
)
}
}
}
}
}
}
}
@@ -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,
)
}
}
}
@@ -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),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -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,
)
}
}
@@ -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,
)
}
}
}
}
}
}
}
}
}
@@ -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)
}
}
}
}
}
}
@@ -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<String, Int>) -> 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,
)
}
}
}
}
}
}
@@ -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),
)
}
}
}
}
}
}
}
}
@@ -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,
)
}
}
}
}
}
}
}
}
@@ -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))
}
},
)
}
@@ -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,
)
}
}
}
}
}
}
@@ -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),
)
}
}
}
}
}
}
}
}
@@ -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 ?: "",
)
}
}
}
}
}
}
}
}
@@ -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 ?: "")
})
}
}
@@ -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")
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test import org.junit.Test
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.StatusYellowContainer
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.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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 @Test
fun `sections request conversations excluding cron and automations from cron`() { fun `sections request conversations excluding cron and automations from cron`() {
val vm = createViewModel() val vm = createViewModel()
@@ -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)
}
}
@@ -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))
}
}
@@ -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))
}
}