Merge pull request #1084 from Hy4ri/refactor/phase1-code-cleanup
refactor(core): comprehensive architectural cleanup (all phases)
This commit is contained in:
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.decodeFromJsonElement
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class GroupChatUiState(
|
||||
val groupName: String = "",
|
||||
@@ -74,22 +75,22 @@ class GroupChatViewModel(
|
||||
private var activeEpoch: String = ""
|
||||
|
||||
// Map of bot.name -> seen count of non-system messages
|
||||
private val memberWatermarks = mutableMapOf<String, Int>()
|
||||
private val memberWatermarks = ConcurrentHashMap<String, Int>()
|
||||
|
||||
// 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
|
||||
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
|
||||
private val inFlightTurns = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||
|
||||
// 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
|
||||
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)
|
||||
private val sessionLastActivity = mutableMapOf<String, Long>()
|
||||
private val sessionLastActivity = ConcurrentHashMap<String, Long>()
|
||||
|
||||
init {
|
||||
if (groupName.isNotBlank()) {
|
||||
|
||||
+296
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+333
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+210
@@ -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
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
@@ -104,26 +94,21 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
||||
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
||||
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||
|
||||
@@ -304,7 +289,6 @@ fun ChatScreen(
|
||||
viewModel.consumeComposerTextRestore()
|
||||
}
|
||||
}
|
||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -322,137 +306,24 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||
|
||||
// Speech-to-text recognition launcher (issue #194)
|
||||
val speechLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
isListening = false
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val spokenText =
|
||||
result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val merged =
|
||||
if (inputFieldValue.text.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"${inputFieldValue.text} $spokenText"
|
||||
}
|
||||
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mic permission launcher
|
||||
val micPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
if (granted) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
launchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val mediaLaunchers =
|
||||
rememberChatMediaLaunchers(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputFieldValueChange = { inputFieldValue = it },
|
||||
onAddAttachment = { uri, name, mimeType, size ->
|
||||
viewModel.addAttachment(uri, name, mimeType, size)
|
||||
},
|
||||
onAddAttachments = { attachments ->
|
||||
viewModel.addAttachments(attachments)
|
||||
},
|
||||
onShowMessage = { msg ->
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttPermissionDeniedMsg)
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file picker for attachments (issue #195).
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetMultipleContents(),
|
||||
) { uris: List<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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
launchExternalActivity = launchExternalActivity,
|
||||
context = context,
|
||||
)
|
||||
|
||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||
ChatLifecycleEffects(
|
||||
@@ -592,80 +463,7 @@ fun ChatScreen(
|
||||
|
||||
// Issue #890: launch update check — non-blocking banner when a
|
||||
// newer release exists. Tapping "Update" opens the in-place dialog.
|
||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
||||
val appUpdateViewModel: AppUpdateViewModel =
|
||||
viewModel {
|
||||
val app =
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
?: error("Application not available")
|
||||
AppUpdateViewModel(app)
|
||||
}
|
||||
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) {
|
||||
val observer =
|
||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
||||
appUpdateViewModel.resumeInstallAfterPermission()
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
updateLifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||
val noticeTag =
|
||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||
?: UpdateNoticeManager.noticeTag()
|
||||
if (noticeTag != null) {
|
||||
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
|
||||
latestTag = noticeTag,
|
||||
onUpdate = { AppUpdateCache.showDialog() },
|
||||
onDismiss = { AppUpdateCache.dismiss() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (AppUpdateCache.isDialogVisible) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val dialogState =
|
||||
if (appUpdateState !is AppUpdateState.Idle) {
|
||||
appUpdateState
|
||||
} else {
|
||||
updateNotice
|
||||
}
|
||||
AppUpdateDialog(
|
||||
state = dialogState,
|
||||
onDismiss = { AppUpdateCache.hideDialog() },
|
||||
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
||||
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||
onOpenSettings = {
|
||||
val intent =
|
||||
android.content
|
||||
.Intent(
|
||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
android.net.Uri.parse("package:${context.packageName}"),
|
||||
).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
context.startActivity(
|
||||
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ChatAppUpdateSection()
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = searchState.isActive,
|
||||
@@ -808,75 +606,16 @@ fun ChatScreen(
|
||||
// Jump to bottom after send (serialized through the controller).
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
},
|
||||
onMicTap = {
|
||||
if (isListening) {
|
||||
isListening = false
|
||||
} else if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
launchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchExternalActivity {
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
},
|
||||
isListening = isListening,
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
isListening = mediaLaunchers.isListening,
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isConnected = state.isConnected,
|
||||
commandCatalog = state.commandCatalog,
|
||||
slashUsageCounts = state.slashUsageCounts,
|
||||
pendingAttachments = state.pendingAttachments,
|
||||
onCameraTap = {
|
||||
try {
|
||||
val timeStamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val photoFile =
|
||||
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile,
|
||||
)
|
||||
pendingCameraUri = uri
|
||||
launchExternalActivity {
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Camera launch failed", e)
|
||||
}
|
||||
},
|
||||
onImageTap = {
|
||||
launchExternalActivity {
|
||||
filePickerLauncher.launch("image/*")
|
||||
}
|
||||
},
|
||||
onFileTap = {
|
||||
launchExternalActivity {
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
},
|
||||
onCameraTap = mediaLaunchers.onCameraTap,
|
||||
onImageTap = mediaLaunchers.onImageTap,
|
||||
onFileTap = mediaLaunchers.onFileTap,
|
||||
onRemoveAttachment = viewModel::removeAttachment,
|
||||
onPreviewAttachment = { attachment ->
|
||||
if (attachment.isImage) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
@@ -646,6 +435,16 @@ class ChatViewModel(
|
||||
addSystemMessage = { text -> addSystemMessage(text) },
|
||||
)
|
||||
|
||||
private val clarifyDelegate =
|
||||
ChatClarifyDelegate(
|
||||
uiState = _uiState,
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
persistMessage = { msg, sid -> repo.persistMessage(msg, sid) },
|
||||
wsClient = wsClient,
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
)
|
||||
|
||||
private val streamingController =
|
||||
ChatStreamingController(
|
||||
scope = viewModelScope,
|
||||
@@ -3383,110 +3182,12 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToClarify(option: String) {
|
||||
val clarify = _uiState.value.clarifyRequest
|
||||
// Only use synthesized qid when this is a true batch or legacy with explicit qid.
|
||||
val qid =
|
||||
if (clarify != null &&
|
||||
(clarify.questionId != null || clarify.questions.isNotEmpty())
|
||||
) {
|
||||
clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (qid != null && clarify?.resolvedQuestions?.size == 1) {
|
||||
respondToClarifyBatch(mapOf(qid to option))
|
||||
} else {
|
||||
respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option)
|
||||
}
|
||||
}
|
||||
fun respondToClarify(option: String) = clarifyDelegate.respondToClarify(option)
|
||||
|
||||
fun respondToClarifyBatch(
|
||||
answers: Map<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,
|
||||
)
|
||||
}
|
||||
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) = clarifyDelegate.respondToClarifyBatch(answers, singleFallbackAnswer)
|
||||
|
||||
fun clearError() {
|
||||
_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:  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])
|
||||
* 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.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.mcp.components.AddServerSection
|
||||
import com.m57.hermescontrol.ui.mcp.components.CatalogSection
|
||||
import com.m57.hermescontrol.ui.mcp.components.McpDialogs
|
||||
import com.m57.hermescontrol.ui.mcp.components.ServerCard
|
||||
|
||||
@Composable
|
||||
fun McpServersScreen(
|
||||
@@ -247,842 +251,13 @@ fun McpServersScreen(
|
||||
}
|
||||
}
|
||||
|
||||
showDetail?.let { server ->
|
||||
DetailDialog(
|
||||
title = server.name,
|
||||
rows = server.toDetailRows(),
|
||||
onDismiss = { showDetail = null },
|
||||
)
|
||||
}
|
||||
|
||||
state.activeOAuthFlow?.let { flow ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissOAuthFlow() },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
flow.authorizationUrl?.let { url ->
|
||||
try {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
flow.error?.let { err ->
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showImportDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.UploadFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val clip = clipboardManager.getText()?.text
|
||||
if (!clip.isNullOrBlank()) {
|
||||
viewModel.updateImportJsonInput(clip)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
OutlinedTextField(
|
||||
value = state.importJsonInput,
|
||||
onValueChange = viewModel::updateImportJsonInput,
|
||||
placeholder = {
|
||||
Text(
|
||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.submitImportJson() },
|
||||
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||
) {
|
||||
if (state.isImportingJson) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||
} else {
|
||||
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||
// as a header, not a button) ──────────────────────────────────────────────
|
||||
@Composable
|
||||
private fun McpSectionHeader(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
).padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sections ──────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AddServerSection(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
) {
|
||||
McpSectionHeader(
|
||||
icon = Icons.Filled.Add,
|
||||
title = stringResource(R.string.mcp_servers_add_server),
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleImportDialog() },
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_import_json))
|
||||
}
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleAddForm() },
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(if (state.showAddForm) "Hide" else "New")
|
||||
}
|
||||
}
|
||||
},
|
||||
McpDialogs(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
spacing = spacing,
|
||||
selectedServerDetail = showDetail,
|
||||
onDismissDetail = { showDetail = null },
|
||||
context = context,
|
||||
clipboardManager = clipboardManager,
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = state.showAddForm) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.HTTP,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
|
||||
)
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.Stdio,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.addServerName,
|
||||
onValueChange = viewModel::updateAddServerName,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
if (state.addMode == AddServerMode.HTTP) {
|
||||
OutlinedTextField(
|
||||
value = state.addServerUrl,
|
||||
onValueChange = viewModel::updateAddServerUrl,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_auth_mode),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "none",
|
||||
onClick = { viewModel.updateAddServerAuth("none") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "header",
|
||||
onClick = { viewModel.updateAddServerAuth("header") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "oauth",
|
||||
onClick = { viewModel.updateAddServerAuth("oauth") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
|
||||
)
|
||||
}
|
||||
if (state.addServerAuth == "header") {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerBearerToken,
|
||||
onValueChange = viewModel::updateAddServerBearerToken,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
|
||||
singleLine = true,
|
||||
visualTransformation = remember { PasswordVisualTransformation() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.addServerCommand,
|
||||
onValueChange = viewModel::updateAddServerCommand,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerArgs,
|
||||
onValueChange = viewModel::updateAddServerArgs,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.md))
|
||||
Button(
|
||||
onClick = { viewModel.submitAddServer() },
|
||||
enabled = !state.addingServer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (state.addingServer) {
|
||||
stringResource(R.string.mcp_servers_action_adding)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_action_submit)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
onClick: () -> Unit,
|
||||
onOpenBrowser: (String) -> Unit,
|
||||
) {
|
||||
var showEnv by remember { mutableStateOf(false) }
|
||||
val isTesting = server.name in state.testingServers
|
||||
val testResult = state.serverTestResults[server.name]
|
||||
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (server.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
|
||||
// Header row: name + status + toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = server.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
when (healthStatus) {
|
||||
McpHealthStatus.HEALTHY -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||
status = StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.AUTH_REQUIRED -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.ERROR -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_error),
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.TESTING -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_testing),
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.UNKNOWN -> {
|
||||
ServerStatusBadge(server.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Tool count & schema token overhead badge (issue #1029)
|
||||
val toolInfos = testResult?.tools
|
||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||
if (toolCount != null && toolCount > 0) {
|
||||
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
onCheckedChange = { viewModel.toggleServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
// URL or Command
|
||||
if (server.url != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = server.url,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
} else if (server.command != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_command),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
|
||||
// Error diagnostics
|
||||
server.error?.let { error ->
|
||||
if (error.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
StatusBadge(
|
||||
text = error,
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
if (server.auth == "oauth") {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
||||
modifier = Modifier.weight(1.2f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
|
||||
}
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.testServer(server.name) },
|
||||
enabled = !isTesting,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||
} else {
|
||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars toggle
|
||||
TextButton(
|
||||
onClick = { showEnv = !showEnv },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_vars))
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showEnv) {
|
||||
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvVarSection(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
) {
|
||||
val isEditing = state.editingEnvFor == server.name
|
||||
val env = server.env ?: emptyMap()
|
||||
|
||||
if (env.isEmpty() && !isEditing) {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_env_no_vars),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = spacing.xs),
|
||||
)
|
||||
} else {
|
||||
env.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.envKeyInput,
|
||||
onValueChange = viewModel::updateEnvKey,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.envValueInput,
|
||||
onValueChange = viewModel::updateEnvValue,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Button(onClick = { viewModel.addEnvVar(server.name) }) {
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerStatusBadge(status: String?) {
|
||||
if (status == null) return
|
||||
val badgeType =
|
||||
when (status.lowercase()) {
|
||||
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
|
||||
"error", "failed" -> StatusBadgeType.ERROR
|
||||
"stopped" -> StatusBadgeType.NEUTRAL
|
||||
else -> StatusBadgeType.NEUTRAL
|
||||
}
|
||||
StatusBadge(text = status, status = badgeType)
|
||||
}
|
||||
|
||||
// ── Catalog ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CatalogSection(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
filteredCatalog: List<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 ->
|
||||
val errSummary = errors.joinToString("; ")
|
||||
val msg =
|
||||
when {
|
||||
errors.isEmpty() -> "Successfully imported $successCount server(s)"
|
||||
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
|
||||
else -> "Failed to import: $errSummary"
|
||||
}
|
||||
val msg = McpImportSummary.formatSummary(successCount, errors)
|
||||
state.copy(
|
||||
isImportingJson = false,
|
||||
showImportDialog = false,
|
||||
@@ -357,38 +351,7 @@ class McpServersViewModel :
|
||||
}
|
||||
_uiState.update { it.copy(addingServer = true) }
|
||||
viewModelScope.launch {
|
||||
val request =
|
||||
AddMcpServerRequest(
|
||||
name = state.addServerName.trim(),
|
||||
url = if (state.addMode == AddServerMode.HTTP) state.addServerUrl.trim().ifBlank { null } else null,
|
||||
command =
|
||||
if (state.addMode == AddServerMode.Stdio) {
|
||||
state.addServerCommand.trim().ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
args =
|
||||
if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) {
|
||||
state.addServerArgs
|
||||
.trim()
|
||||
.split("\\s+".toRegex())
|
||||
.filter { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
auth =
|
||||
if (state.addMode == AddServerMode.HTTP && state.addServerAuth != "none") {
|
||||
state.addServerAuth
|
||||
} else {
|
||||
null
|
||||
},
|
||||
bearerToken =
|
||||
if (state.addMode == AddServerMode.HTTP && state.addServerAuth == "header") {
|
||||
state.addServerBearerToken.trim().ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
val request = AddServerRequestBuilder.build(state)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
|
||||
@@ -622,6 +585,7 @@ class McpServersViewModel :
|
||||
oauthPollJob =
|
||||
viewModelScope.launch {
|
||||
var polling = true
|
||||
var consecutiveFailures = 0
|
||||
while (polling) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
val result =
|
||||
@@ -666,7 +630,16 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= 10) {
|
||||
polling = false
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeOAuthFlow = null,
|
||||
toastMessage = "OAuth polling timed out after repeated network failures",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.plugins.memoryProviderStatusLabel
|
||||
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType
|
||||
import com.m57.hermescontrol.util.formatBytes
|
||||
|
||||
/**
|
||||
* Memory management home — owns the memory surface that used to live in the
|
||||
@@ -425,10 +426,3 @@ private fun MemoryProviderRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
@@ -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.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.abs
|
||||
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
||||
import com.m57.hermescontrol.ui.sessions.components.automationGroups
|
||||
|
||||
/**
|
||||
* Auto-load the next history page when the user scrolls to within this many
|
||||
@@ -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> =
|
||||
if (state.isSearchMode) {
|
||||
state.searchResults.map { searchResult ->
|
||||
@@ -774,65 +686,14 @@ fun SessionsScreen(
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_total),
|
||||
value = formatCompactCount(state.total),
|
||||
icon = Icons.Filled.History,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_messages_loaded),
|
||||
value = formatCompactCount(loadedMessageCount),
|
||||
icon = Icons.Filled.Email,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
// Prune button — compact card sized to its label, so the
|
||||
// stat cards keep enough width for large counts.
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Box(
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionsStatsRow(
|
||||
total = state.total,
|
||||
loadedMessageCount = loadedMessageCount,
|
||||
section = state.section,
|
||||
spacing = spacing,
|
||||
statusColors = statusColors,
|
||||
onPruneClick = { viewModel.showPruneDialog() },
|
||||
)
|
||||
|
||||
// ── Session list ────────────────────────────────────
|
||||
val listState = rememberLazyListState()
|
||||
@@ -916,66 +777,21 @@ fun SessionsScreen(
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
automationSessionGroups.forEach { group ->
|
||||
item(key = "automation-group:${group.key}") {
|
||||
val expanded = group.key in expandedAutomationGroups
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
expandedAutomationGroups =
|
||||
if (expanded) {
|
||||
expandedAutomationGroups - group.key
|
||||
} else {
|
||||
expandedAutomationGroups + group.key
|
||||
}
|
||||
}.padding(horizontal = spacing.sm, vertical = spacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (expanded) {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
} else {
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Column {
|
||||
Text(
|
||||
text =
|
||||
group.title
|
||||
?: group.jobId?.let {
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_job,
|
||||
it,
|
||||
)
|
||||
} ?: stringResource(
|
||||
R.string.sessions_section_automation_unknown,
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_runs,
|
||||
group.sessions.size,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
automationGroups(
|
||||
groups = automationSessionGroups,
|
||||
expandedGroups = expandedAutomationGroups,
|
||||
sessionItemsById = sessionItemsById,
|
||||
spacing = spacing,
|
||||
onToggleGroup = { groupKey ->
|
||||
expandedAutomationGroups =
|
||||
if (groupKey in expandedAutomationGroups) {
|
||||
expandedAutomationGroups - groupKey
|
||||
} else {
|
||||
expandedAutomationGroups + groupKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group.key in expandedAutomationGroups) {
|
||||
items(group.sessions, key = { it.id }) { session ->
|
||||
sessionItemsById[session.id]?.let { sessionCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderSessionCard = sessionCard,
|
||||
)
|
||||
} else {
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
sessionCard(item)
|
||||
@@ -1021,533 +837,21 @@ fun SessionsScreen(
|
||||
}
|
||||
|
||||
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
||||
AnimatedVisibility(
|
||||
SessionsBulkActionBar(
|
||||
visible = state.isSelecting && hasSelection,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 4.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Select all / deselect
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (allVisibleSessionsSelected) {
|
||||
viewModel.clearSelection()
|
||||
} else {
|
||||
viewModel.selectAll(visibleSessionIds)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (allVisibleSessionsSelected) {
|
||||
Icons.Filled.Close
|
||||
} else {
|
||||
Icons.Filled.SelectAll
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
if (allVisibleSessionsSelected) {
|
||||
stringResource(R.string.sessions_action_deselect_all)
|
||||
} else {
|
||||
stringResource(R.string.sessions_action_select_all)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Delete selected
|
||||
Button(
|
||||
onClick = { viewModel.requestBulkDelete() },
|
||||
enabled = !state.isDeletingBulk,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = statusColors.error,
|
||||
),
|
||||
) {
|
||||
if (state.isDeletingBulk) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = statusColors.onError,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.sessions_action_delete_n,
|
||||
state.selectedIds.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
||||
displayTitle: String,
|
||||
branchStem: String?,
|
||||
isFork: Boolean = false,
|
||||
forkDepth: Int = 0,
|
||||
query: String,
|
||||
isSelecting: Boolean,
|
||||
isSelected: Boolean,
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean,
|
||||
isHidden: Boolean = false,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
onToggleSelection: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
onToggleHide: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||
val srcIcon = sourceIcon(session.source)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("session_card_${session.id}")
|
||||
.combinedClickable(
|
||||
onClick = onCardClick,
|
||||
// Long-press opens the per-session action menu (issue #785):
|
||||
// Select / Rename / Delete. The old long-press behavior
|
||||
// (jump straight into selection mode) is now the menu's
|
||||
// "Select" item.
|
||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border =
|
||||
if (isActive && !isSelecting) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Checkbox in select mode
|
||||
if (isSelecting) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onToggleSelection() },
|
||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
}
|
||||
|
||||
// Fork marker: a fork icon + its nesting depth, so a chain reads
|
||||
// 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the
|
||||
// number carries the structure so names never get squished.
|
||||
if (isFork && !isSelecting) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.CallSplit,
|
||||
contentDescription = stringResource(R.string.sessions_fork_indicator),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
if (forkDepth > 0) {
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = forkDepth.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
}
|
||||
|
||||
// Source icon
|
||||
if (srcIcon != null && !isSelecting) {
|
||||
Icon(
|
||||
imageVector = srcIcon,
|
||||
contentDescription = sourceLabel(session.source),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
}
|
||||
|
||||
// Main content
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text =
|
||||
if (query.isNotBlank()) {
|
||||
highlightText(displayTitle, query, highlightBackground, highlightForeground)
|
||||
} else {
|
||||
AnnotatedString(displayTitle)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isPinned) {
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = stringResource(R.string.sessions_pinned_indicator),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (session.hidden == true) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.sessions_hidden_badge),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
if (!session.status.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
text = session.status,
|
||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-session action menu (hold the card to open)
|
||||
SessionActionMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismiss = { menuExpanded = false },
|
||||
isDeleting = isDeleting,
|
||||
isPinned = isPinned,
|
||||
onTogglePin = {
|
||||
menuExpanded = false
|
||||
onTogglePin()
|
||||
},
|
||||
isHidden = isHidden,
|
||||
onToggleHide = {
|
||||
menuExpanded = false
|
||||
onToggleHide()
|
||||
},
|
||||
onSelect = {
|
||||
menuExpanded = false
|
||||
onSelect()
|
||||
},
|
||||
onRename = {
|
||||
menuExpanded = false
|
||||
onRename()
|
||||
},
|
||||
onDelete = {
|
||||
menuExpanded = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-result card. The backend search payload has no session title, so this card is
|
||||
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
|
||||
* source / model / played-at metadata chips. It never presents the snippet as a name.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SearchResultCard(
|
||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
||||
query: String,
|
||||
isSelecting: Boolean,
|
||||
isSelected: Boolean,
|
||||
isDeleting: Boolean,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
onToggleSelection: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||
val playedAt = formatPlayedAt(session.started_at)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("session_card_${session.id}")
|
||||
.combinedClickable(
|
||||
onClick = onCardClick,
|
||||
// Long-press opens the per-session action menu (issue #785)
|
||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border =
|
||||
if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(spacing.sm),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
// Checkbox in select mode
|
||||
if (isSelecting) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onToggleSelection() },
|
||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Header row: "Match" label + source icon
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
val srcIcon = sourceIcon(session.source)
|
||||
if (srcIcon != null && !isSelecting) {
|
||||
Icon(
|
||||
imageVector = srcIcon,
|
||||
contentDescription = sourceLabel(session.source),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_search_match_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 0.5.sp,
|
||||
)
|
||||
if (!session.id.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "· ${session.id.take(8)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
|
||||
// The matched snippet, highlighted — shown as the body, NOT as a title.
|
||||
Text(
|
||||
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
|
||||
// Metadata chips row
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
session.source?.let { src ->
|
||||
StatusBadge(
|
||||
text = sourceLabel(src),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
session.model?.let { mdl ->
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
playedAt?.let { time ->
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.sessions_search_played_at, time),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-session action menu (hold the card to open)
|
||||
SessionActionMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismiss = { menuExpanded = false },
|
||||
isDeleting = isDeleting,
|
||||
onSelect = {
|
||||
menuExpanded = false
|
||||
onSelect()
|
||||
},
|
||||
onRename = {
|
||||
menuExpanded = false
|
||||
onRename()
|
||||
},
|
||||
onDelete = {
|
||||
menuExpanded = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete.
|
||||
* Opened by long-pressing a session card. The pin item is only shown when
|
||||
* [onTogglePin] is provided (history cards; search-result cards omit it).
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionActionMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean = false,
|
||||
onTogglePin: (() -> Unit)? = null,
|
||||
isHidden: Boolean = false,
|
||||
onToggleHide: (() -> Unit)? = null,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
if (onTogglePin != null) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isPinned) {
|
||||
R.string.sessions_action_unpin
|
||||
} else {
|
||||
R.string.sessions_action_pin
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
|
||||
onClick = onTogglePin,
|
||||
)
|
||||
}
|
||||
if (onToggleHide != null) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isHidden) {
|
||||
R.string.sessions_action_unhide
|
||||
} else {
|
||||
R.string.sessions_action_hide
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isHidden) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = onToggleHide,
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sessions_action_select)) },
|
||||
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
||||
onClick = onSelect,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sessions_action_rename)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
|
||||
onClick = onRename,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_delete)) },
|
||||
leadingIcon = {
|
||||
if (isDeleting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
allVisibleSessionsSelected = allVisibleSessionsSelected,
|
||||
selectedCount = state.selectedIds.size,
|
||||
isDeletingBulk = state.isDeletingBulk,
|
||||
spacing = spacing,
|
||||
statusColors = statusColors,
|
||||
onSelectAllToggle = {
|
||||
if (allVisibleSessionsSelected) {
|
||||
viewModel.clearSelection()
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = statusColors.error,
|
||||
)
|
||||
viewModel.selectAll(visibleSessionIds)
|
||||
}
|
||||
},
|
||||
enabled = !isDeleting,
|
||||
onClick = onDelete,
|
||||
onDeleteSelected = { viewModel.requestBulkDelete() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +147,17 @@ class SessionsViewModel :
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = newSessions.size,
|
||||
)
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -229,14 +235,20 @@ class SessionsViewModel :
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = sessionsList.size,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = sessionsList,
|
||||
total = if (hasMore) data.total else sessionsList.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
selectedIds = emptySet(),
|
||||
)
|
||||
}
|
||||
@@ -284,14 +296,20 @@ class SessionsViewModel :
|
||||
(it.sessions + data.sessions)
|
||||
.distinctBy { s -> s.id }
|
||||
.pinnedFirst()
|
||||
val receivedFullPage = data.sessions.size >= PAGE_SIZE
|
||||
val addedNewItems = newSessions.size > it.sessions.size
|
||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
||||
val paging =
|
||||
SessionsPaging.resolveLoadMorePaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
addedNewItems = addedNewItems,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = newSessions.size,
|
||||
)
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -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),
|
||||
}
|
||||
|
||||
enum class CategoryFilter {
|
||||
ALL,
|
||||
}
|
||||
|
||||
data class SkillsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
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
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
import org.junit.Test
|
||||
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.StatusYellowContainer
|
||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||
import com.m57.hermescontrol.ui.chat.markdown.FnNote
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
||||
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
||||
@@ -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
|
||||
fun `sections request conversations excluding cron and automations from cron`() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user