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.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
data class GroupChatUiState(
|
data class GroupChatUiState(
|
||||||
val groupName: String = "",
|
val groupName: String = "",
|
||||||
@@ -74,22 +75,22 @@ class GroupChatViewModel(
|
|||||||
private var activeEpoch: String = ""
|
private var activeEpoch: String = ""
|
||||||
|
|
||||||
// Map of bot.name -> seen count of non-system messages
|
// Map of bot.name -> seen count of non-system messages
|
||||||
private val memberWatermarks = mutableMapOf<String, Int>()
|
private val memberWatermarks = ConcurrentHashMap<String, Int>()
|
||||||
|
|
||||||
// Map of bot.name -> active session info in this group
|
// Map of bot.name -> active session info in this group
|
||||||
private val memberSessions = mutableMapOf<String, MemberSession>()
|
private val memberSessions = ConcurrentHashMap<String, MemberSession>()
|
||||||
|
|
||||||
// In-flight turn completion deferreds keyed by session_id
|
// In-flight turn completion deferreds keyed by session_id
|
||||||
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
|
private val inFlightTurns = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||||
|
|
||||||
// Map of session_id -> active streaming message ID
|
// Map of session_id -> active streaming message ID
|
||||||
private val activeStreamMsgId = mutableMapOf<String, String>()
|
private val activeStreamMsgId = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
// Map of session_id -> bot profile
|
// Map of session_id -> bot profile
|
||||||
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
|
private val sessionToBot = ConcurrentHashMap<String, ProfileInfo>()
|
||||||
|
|
||||||
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
|
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
|
||||||
private val sessionLastActivity = mutableMapOf<String, Long>()
|
private val sessionLastActivity = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (groupName.isNotBlank()) {
|
if (groupName.isNotBlank()) {
|
||||||
|
|||||||
+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
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.speech.RecognizerIntent
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
|
||||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||||
@@ -104,26 +94,21 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
|||||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
||||||
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
|
||||||
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
@@ -304,7 +289,6 @@ fun ChatScreen(
|
|||||||
viewModel.consumeComposerTextRestore()
|
viewModel.consumeComposerTextRestore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -322,137 +306,24 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
val mediaLaunchers =
|
||||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
rememberChatMediaLaunchers(
|
||||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
inputFieldValue = inputFieldValue,
|
||||||
|
onInputFieldValueChange = { inputFieldValue = it },
|
||||||
// Speech-to-text recognition launcher (issue #194)
|
onAddAttachment = { uri, name, mimeType, size ->
|
||||||
val speechLauncher =
|
viewModel.addAttachment(uri, name, mimeType, size)
|
||||||
rememberLauncherForActivityResult(
|
},
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
onAddAttachments = { attachments ->
|
||||||
) { result ->
|
viewModel.addAttachments(attachments)
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
},
|
||||||
isListening = false
|
onShowMessage = { msg ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
|
||||||
val spokenText =
|
|
||||||
result.data
|
|
||||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
|
||||||
?.firstOrNull()
|
|
||||||
.orEmpty()
|
|
||||||
if (spokenText.isNotBlank()) {
|
|
||||||
val merged =
|
|
||||||
if (inputFieldValue.text.isBlank()) {
|
|
||||||
spokenText
|
|
||||||
} else {
|
|
||||||
"${inputFieldValue.text} $spokenText"
|
|
||||||
}
|
|
||||||
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mic permission launcher
|
|
||||||
val micPermissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
|
||||||
if (granted) {
|
|
||||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
|
||||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
|
||||||
isListening = true
|
|
||||||
launchExternalActivity {
|
|
||||||
try {
|
|
||||||
speechLauncher.launch(intent)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
isListening = false
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
snackbarHostState.showSnackbar(sttPermissionDeniedMsg)
|
snackbarHostState.showSnackbar(msg)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
launchExternalActivity = launchExternalActivity,
|
||||||
|
context = context,
|
||||||
// Multi-file picker for attachments (issue #195).
|
)
|
||||||
val filePickerLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.GetMultipleContents(),
|
|
||||||
) { uris: List<Uri> ->
|
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
|
||||||
val attachments =
|
|
||||||
uris.mapNotNull { uri ->
|
|
||||||
runCatching {
|
|
||||||
var name = uri.lastPathSegment ?: "file"
|
|
||||||
var size = 0L
|
|
||||||
context.contentResolver
|
|
||||||
.query(
|
|
||||||
uri,
|
|
||||||
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
||||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
||||||
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
|
|
||||||
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Attachment(
|
|
||||||
uri = uri.toString(),
|
|
||||||
name = name,
|
|
||||||
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
}.onFailure { error ->
|
|
||||||
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
viewModel.addAttachments(attachments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera photo launcher (issue #195)
|
|
||||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
|
||||||
val cameraLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.TakePicture(),
|
|
||||||
) { success ->
|
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
|
||||||
val uri = pendingCameraUri
|
|
||||||
pendingCameraUri = null
|
|
||||||
if (success && uri != null) {
|
|
||||||
try {
|
|
||||||
val fileName =
|
|
||||||
"photo_${
|
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
|
|
||||||
Date(),
|
|
||||||
)
|
|
||||||
}.jpg"
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
val size = inputStream?.use { it.available().toLong() } ?: 0L
|
|
||||||
viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("ChatScreen", "Camera capture failed", e)
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
cameraErrorMsg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
ChatLifecycleEffects(
|
||||||
@@ -592,80 +463,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Issue #890: launch update check — non-blocking banner when a
|
// Issue #890: launch update check — non-blocking banner when a
|
||||||
// newer release exists. Tapping "Update" opens the in-place dialog.
|
// newer release exists. Tapping "Update" opens the in-place dialog.
|
||||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
ChatAppUpdateSection()
|
||||||
val appUpdateViewModel: AppUpdateViewModel =
|
|
||||||
viewModel {
|
|
||||||
val app =
|
|
||||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
|
||||||
?: error("Application not available")
|
|
||||||
AppUpdateViewModel(app)
|
|
||||||
}
|
|
||||||
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
|
||||||
androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) {
|
|
||||||
val observer =
|
|
||||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
|
||||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
|
||||||
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
|
||||||
appUpdateViewModel.resumeInstallAfterPermission()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateLifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
onDispose {
|
|
||||||
updateLifecycleOwner.lifecycle.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
|
||||||
val noticeTag =
|
|
||||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
|
||||||
?: UpdateNoticeManager.noticeTag()
|
|
||||||
if (noticeTag != null) {
|
|
||||||
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
|
|
||||||
latestTag = noticeTag,
|
|
||||||
onUpdate = { AppUpdateCache.showDialog() },
|
|
||||||
onDismiss = { AppUpdateCache.dismiss() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppUpdateCache.isDialogVisible) {
|
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
|
||||||
val dialogState =
|
|
||||||
if (appUpdateState !is AppUpdateState.Idle) {
|
|
||||||
appUpdateState
|
|
||||||
} else {
|
|
||||||
updateNotice
|
|
||||||
}
|
|
||||||
AppUpdateDialog(
|
|
||||||
state = dialogState,
|
|
||||||
onDismiss = { AppUpdateCache.hideDialog() },
|
|
||||||
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
|
||||||
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
|
||||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
|
||||||
onOpenSettings = {
|
|
||||||
val intent =
|
|
||||||
android.content
|
|
||||||
.Intent(
|
|
||||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
|
||||||
android.net.Uri.parse("package:${context.packageName}"),
|
|
||||||
).apply {
|
|
||||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
context.startActivity(
|
|
||||||
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
|
|
||||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = searchState.isActive,
|
visible = searchState.isActive,
|
||||||
@@ -808,75 +606,16 @@ fun ChatScreen(
|
|||||||
// Jump to bottom after send (serialized through the controller).
|
// Jump to bottom after send (serialized through the controller).
|
||||||
scrollController.jumpToBottom(animated = true)
|
scrollController.jumpToBottom(animated = true)
|
||||||
},
|
},
|
||||||
onMicTap = {
|
onMicTap = mediaLaunchers.onMicTap,
|
||||||
if (isListening) {
|
isListening = mediaLaunchers.isListening,
|
||||||
isListening = false
|
|
||||||
} else if (
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.RECORD_AUDIO,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
|
||||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
|
||||||
isListening = true
|
|
||||||
launchExternalActivity {
|
|
||||||
try {
|
|
||||||
speechLauncher.launch(intent)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
isListening = false
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
launchExternalActivity {
|
|
||||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isListening = isListening,
|
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
pendingAttachments = state.pendingAttachments,
|
pendingAttachments = state.pendingAttachments,
|
||||||
onCameraTap = {
|
onCameraTap = mediaLaunchers.onCameraTap,
|
||||||
try {
|
onImageTap = mediaLaunchers.onImageTap,
|
||||||
val timeStamp =
|
onFileTap = mediaLaunchers.onFileTap,
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
|
||||||
val photoFile =
|
|
||||||
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
|
|
||||||
val uri =
|
|
||||||
FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${context.packageName}.fileprovider",
|
|
||||||
photoFile,
|
|
||||||
)
|
|
||||||
pendingCameraUri = uri
|
|
||||||
launchExternalActivity {
|
|
||||||
cameraLauncher.launch(uri)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("ChatScreen", "Camera launch failed", e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onImageTap = {
|
|
||||||
launchExternalActivity {
|
|
||||||
filePickerLauncher.launch("image/*")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFileTap = {
|
|
||||||
launchExternalActivity {
|
|
||||||
filePickerLauncher.launch("*/*")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemoveAttachment = viewModel::removeAttachment,
|
onRemoveAttachment = viewModel::removeAttachment,
|
||||||
onPreviewAttachment = { attachment ->
|
onPreviewAttachment = { attachment ->
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical comparison key for a tool message's result payload.
|
||||||
|
*
|
||||||
|
* WS tool messages store the full tool.complete payload
|
||||||
|
* (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST
|
||||||
|
* transcript rows store just the result object (`{"output":..., "exit_code":...}`).
|
||||||
|
* This key normalizes both sides — preferring the `result` field when present,
|
||||||
|
* and treating int/float JSON numbers as equal — so the two representations of
|
||||||
|
* the SAME tool call can be matched regardless of position or pagination.
|
||||||
|
* Returns null for unparseable content (no match possible).
|
||||||
|
*/
|
||||||
|
internal fun canonicalToolResultKey(content: String): String? {
|
||||||
|
val element =
|
||||||
|
try {
|
||||||
|
OkHttpProvider.json.parseToJsonElement(content)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canon(e: JsonElement): String =
|
||||||
|
when (e) {
|
||||||
|
is JsonObject -> {
|
||||||
|
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonArray -> {
|
||||||
|
e.joinToString(",") { canon(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
// Canonicalize ALL numbers through double, collapsing int/float
|
||||||
|
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
||||||
|
val s = e.content
|
||||||
|
val d = s.toDoubleOrNull()
|
||||||
|
if (d != null) {
|
||||||
|
if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d"
|
||||||
|
} else {
|
||||||
|
"s$s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (element) {
|
||||||
|
is JsonObject -> {
|
||||||
|
element["result"]?.let { canon(it) } ?: canon(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
canon(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [a] and [b] are the same logical message (the WS-persisted and
|
||||||
|
* REST-persisted copies of one row — they carry different ids, see #771).
|
||||||
|
* Tool messages match on their normalized result payload; other roles on
|
||||||
|
* exact content, IGNORING leading/trailing whitespace.
|
||||||
|
*
|
||||||
|
* Issue #842: the app seals the RAW streamed text (which can carry leading
|
||||||
|
* blank lines the model emits before its narration), while the backend
|
||||||
|
* persists a CLEANED copy (leading whitespace stripped). An exact-content
|
||||||
|
* compare made the reload merge treat them as different messages and add
|
||||||
|
* the REST copy on top — the commentary duplicated ~10s after the stream
|
||||||
|
* ended. Trim closes the drift: the sealed live bubble is covered by the
|
||||||
|
* REST row and the duplicate never renders.
|
||||||
|
*/
|
||||||
|
internal fun sameLogicalMessage(
|
||||||
|
a: ChatMessage,
|
||||||
|
b: ChatMessage,
|
||||||
|
): Boolean {
|
||||||
|
if (a.role != b.role) return false
|
||||||
|
if (a.role == MessageRole.TOOL) {
|
||||||
|
// Issue #842: prefer the gateway's tool call id when both sides carry
|
||||||
|
// it — the REST transcript stores `tool_call_id` and the live WS
|
||||||
|
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
||||||
|
// cover MCP/web tools: the REST side stores the payload as raw
|
||||||
|
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
|
||||||
|
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
|
||||||
|
return a.toolCallId == b.toolCallId
|
||||||
|
}
|
||||||
|
val ka = canonicalToolResultKey(a.content)
|
||||||
|
val kb = canonicalToolResultKey(b.content)
|
||||||
|
return ka != null && ka == kb
|
||||||
|
}
|
||||||
|
val ta = a.content.trim()
|
||||||
|
val tb = b.content.trim()
|
||||||
|
if (ta == tb) return true
|
||||||
|
// Attachment dedupe: when the user sends an image/file, the backend
|
||||||
|
// persists an ENRICHED copy of the prompt — the real caption plus
|
||||||
|
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
||||||
|
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
||||||
|
// optimistic bubble carries the raw caption only, so the exact compare
|
||||||
|
// above fails and the REST sync renders the same message twice. Compare
|
||||||
|
// USER rows on the caption with injection lines stripped (both sides —
|
||||||
|
// stripping a clean string is a no-op). USER-only so assistant text that
|
||||||
|
// legitimately mentions these tokens is never collapsed.
|
||||||
|
if (a.role == MessageRole.USER) {
|
||||||
|
val ca = stripAttachmentRefLines(ta)
|
||||||
|
val cb = stripAttachmentRefLines(tb)
|
||||||
|
if (ca == cb) return true
|
||||||
|
}
|
||||||
|
// Issue #842: a seal race can leave the live orphan a few trailing tokens
|
||||||
|
// short of the streamed narration (the last delta was still in the
|
||||||
|
// throttled buffer when tool.start sealed the message). The backend
|
||||||
|
// persists the COMPLETE copy, so the orphan is a strict prefix of the
|
||||||
|
// REST row. Accept prefix-covering only for substantial texts (>=40
|
||||||
|
// chars) so a short reply can never be swallowed by a longer message
|
||||||
|
// that merely starts with it.
|
||||||
|
return ta.length >= 40 &&
|
||||||
|
tb.length >= 40 &&
|
||||||
|
(tb.startsWith(ta) || ta.startsWith(tb))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove backend attachment-injection lines from a user message so the
|
||||||
|
* optimistic bubble and the server-enriched REST copy compare equal.
|
||||||
|
* Lines the gateway adds on top of the user's caption: `@image:<path>`,
|
||||||
|
* `@file:<ref>`, and `[screenshot]` (multipart image placeholder written by
|
||||||
|
* run_agent). Blank lines left behind are dropped too.
|
||||||
|
*/
|
||||||
|
internal fun stripAttachmentRefLines(content: String): String =
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filterNot { line ->
|
||||||
|
line.startsWith("@image:") ||
|
||||||
|
line.startsWith("@file:") ||
|
||||||
|
line == "[screenshot]"
|
||||||
|
}.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
|
||||||
|
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
||||||
|
* no tool name) of every message. Painting the cache verbatim renders the
|
||||||
|
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
||||||
|
* logical message exists (issue #771).
|
||||||
|
*/
|
||||||
|
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||||
|
val rest = messages.filter { it.id.startsWith("rest-") }
|
||||||
|
if (rest.isEmpty()) return messages
|
||||||
|
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
||||||
|
if (nonRest.isEmpty()) return messages
|
||||||
|
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
||||||
|
return (nonRest + keepRest).sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transcript reload must NOT yank live WS bubbles the server has not
|
||||||
|
* persisted yet. The gateway stores a tool row only once the tool
|
||||||
|
* COMPLETES server-side, so a reload that lands while a tool is running
|
||||||
|
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
||||||
|
* returns a page without the tool row — replacing the list outright made
|
||||||
|
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
||||||
|
* message to update (issue #771).
|
||||||
|
*
|
||||||
|
* Merge instead of replace: append any current message the REST page does
|
||||||
|
* not already cover (checked by id AND logical content via
|
||||||
|
* [sameLogicalMessage]) and keep chronological order.
|
||||||
|
*/
|
||||||
|
internal fun mergeTranscriptWithLive(
|
||||||
|
restMessages: List<ChatMessage>,
|
||||||
|
currentMessages: List<ChatMessage>,
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val dedupedRest = restMessages.dedupeById()
|
||||||
|
val dedupedCurrent = currentMessages.dedupeById()
|
||||||
|
// User rows can carry live/cache-only metadata (for example whether the
|
||||||
|
// bubble continues the active turn). Keep that richer copy when REST
|
||||||
|
// returns the same logical row.
|
||||||
|
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
||||||
|
val mergedRest =
|
||||||
|
dedupedRest.map { rest ->
|
||||||
|
if (rest.role != MessageRole.USER) return@map rest
|
||||||
|
val matchIndex =
|
||||||
|
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
||||||
|
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
||||||
|
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
||||||
|
}
|
||||||
|
val restIds = dedupedRest.map { it.id }.toSet()
|
||||||
|
val liveTail =
|
||||||
|
dedupedCurrent.filter { old ->
|
||||||
|
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
||||||
|
}
|
||||||
|
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
||||||
|
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a REST page without matching it against already-settled transcript rows. */
|
||||||
|
internal fun mergeIncrementalTranscriptPage(
|
||||||
|
restMessages: List<ChatMessage>,
|
||||||
|
currentMessages: List<ChatMessage>,
|
||||||
|
sessionId: String,
|
||||||
|
offset: Int,
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val settledEnd =
|
||||||
|
currentMessages.indexOfLast { message ->
|
||||||
|
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
currentMessages.take(settledEnd + 1) +
|
||||||
|
mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1))
|
||||||
|
).dedupeById()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun serverMessageIndex(
|
||||||
|
id: String,
|
||||||
|
sessionId: String,
|
||||||
|
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
|
||||||
|
|
||||||
|
internal fun List<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
|
||||||
@@ -70,217 +70,6 @@ private sealed interface PrepareAttachmentResult {
|
|||||||
data object Unreadable : PrepareAttachmentResult
|
data object Unreadable : PrepareAttachmentResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Canonical comparison key for a tool message's result payload.
|
|
||||||
*
|
|
||||||
* WS tool messages store the full tool.complete payload
|
|
||||||
* (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST
|
|
||||||
* transcript rows store just the result object (`{"output":..., "exit_code":...}`).
|
|
||||||
* This key normalizes both sides — preferring the `result` field when present,
|
|
||||||
* and treating int/float JSON numbers as equal — so the two representations of
|
|
||||||
* the SAME tool call can be matched regardless of position or pagination.
|
|
||||||
* Returns null for unparseable content (no match possible).
|
|
||||||
*/
|
|
||||||
internal fun canonicalToolResultKey(content: String): String? {
|
|
||||||
val element =
|
|
||||||
try {
|
|
||||||
OkHttpProvider.json.parseToJsonElement(content)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canon(e: kotlinx.serialization.json.JsonElement): String =
|
|
||||||
when (e) {
|
|
||||||
is kotlinx.serialization.json.JsonObject -> {
|
|
||||||
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
is kotlinx.serialization.json.JsonArray -> {
|
|
||||||
e.joinToString(",") { canon(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
is kotlinx.serialization.json.JsonPrimitive -> {
|
|
||||||
// Canonicalize ALL numbers through double, collapsing int/float
|
|
||||||
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
|
||||||
val s = e.content
|
|
||||||
val d = s.toDoubleOrNull()
|
|
||||||
if (d != null) {
|
|
||||||
if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d"
|
|
||||||
} else {
|
|
||||||
"s$s"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return when (element) {
|
|
||||||
is kotlinx.serialization.json.JsonObject -> {
|
|
||||||
element["result"]?.let { canon(it) } ?: canon(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
canon(element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [a] and [b] are the same logical message (the WS-persisted and
|
|
||||||
* REST-persisted copies of one row — they carry different ids, see #771).
|
|
||||||
* Tool messages match on their normalized result payload; other roles on
|
|
||||||
* exact content, IGNORING leading/trailing whitespace.
|
|
||||||
*
|
|
||||||
* Issue #842: the app seals the RAW streamed text (which can carry leading
|
|
||||||
* blank lines the model emits before its narration), while the backend
|
|
||||||
* persists a CLEANED copy (leading whitespace stripped). An exact-content
|
|
||||||
* compare made the reload merge treat them as different messages and add
|
|
||||||
* the REST copy on top — the commentary duplicated ~10s after the stream
|
|
||||||
* ended. Trim closes the drift: the sealed live bubble is covered by the
|
|
||||||
* REST row and the duplicate never renders.
|
|
||||||
*/
|
|
||||||
internal fun sameLogicalMessage(
|
|
||||||
a: ChatMessage,
|
|
||||||
b: ChatMessage,
|
|
||||||
): Boolean {
|
|
||||||
if (a.role != b.role) return false
|
|
||||||
if (a.role == MessageRole.TOOL) {
|
|
||||||
// Issue #842: prefer the gateway's tool call id when both sides carry
|
|
||||||
// it — the REST transcript stores `tool_call_id` and the live WS
|
|
||||||
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
|
||||||
// cover MCP/web tools: the REST side stores the payload as raw
|
|
||||||
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
|
|
||||||
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
|
|
||||||
return a.toolCallId == b.toolCallId
|
|
||||||
}
|
|
||||||
val ka = canonicalToolResultKey(a.content)
|
|
||||||
val kb = canonicalToolResultKey(b.content)
|
|
||||||
return ka != null && ka == kb
|
|
||||||
}
|
|
||||||
val ta = a.content.trim()
|
|
||||||
val tb = b.content.trim()
|
|
||||||
if (ta == tb) return true
|
|
||||||
// Attachment dedupe: when the user sends an image/file, the backend
|
|
||||||
// persists an ENRICHED copy of the prompt — the real caption plus
|
|
||||||
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
|
||||||
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
|
||||||
// optimistic bubble carries the raw caption only, so the exact compare
|
|
||||||
// above fails and the REST sync renders the same message twice. Compare
|
|
||||||
// USER rows on the caption with injection lines stripped (both sides —
|
|
||||||
// stripping a clean string is a no-op). USER-only so assistant text that
|
|
||||||
// legitimately mentions these tokens is never collapsed.
|
|
||||||
if (a.role == MessageRole.USER) {
|
|
||||||
val ca = stripAttachmentRefLines(ta)
|
|
||||||
val cb = stripAttachmentRefLines(tb)
|
|
||||||
if (ca == cb) return true
|
|
||||||
}
|
|
||||||
// Issue #842: a seal race can leave the live orphan a few trailing tokens
|
|
||||||
// short of the streamed narration (the last delta was still in the
|
|
||||||
// throttled buffer when tool.start sealed the message). The backend
|
|
||||||
// persists the COMPLETE copy, so the orphan is a strict prefix of the
|
|
||||||
// REST row. Accept prefix-covering only for substantial texts (>=40
|
|
||||||
// chars) so a short reply can never be swallowed by a longer message
|
|
||||||
// that merely starts with it.
|
|
||||||
return ta.length >= 40 &&
|
|
||||||
tb.length >= 40 &&
|
|
||||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove backend attachment-injection lines from a user message so the
|
|
||||||
* optimistic bubble and the server-enriched REST copy compare equal.
|
|
||||||
* Lines the gateway adds on top of the user's caption: `@image:<path>`,
|
|
||||||
* `@file:<ref>`, and `[screenshot]` (multipart image placeholder written by
|
|
||||||
* run_agent). Blank lines left behind are dropped too.
|
|
||||||
*/
|
|
||||||
internal fun stripAttachmentRefLines(content: String): String =
|
|
||||||
content
|
|
||||||
.lines()
|
|
||||||
.map { it.trim() }
|
|
||||||
.filterNot { line ->
|
|
||||||
line.startsWith("@image:") ||
|
|
||||||
line.startsWith("@file:") ||
|
|
||||||
line == "[screenshot]"
|
|
||||||
}.joinToString("\n")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
|
|
||||||
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
|
||||||
* no tool name) of every message. Painting the cache verbatim renders the
|
|
||||||
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
|
||||||
* logical message exists (issue #771).
|
|
||||||
*/
|
|
||||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
|
||||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
|
||||||
if (rest.isEmpty()) return messages
|
|
||||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
|
||||||
if (nonRest.isEmpty()) return messages
|
|
||||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
|
||||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
|
||||||
* persisted yet. The gateway stores a tool row only once the tool
|
|
||||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
|
||||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
|
||||||
* returns a page without the tool row — replacing the list outright made
|
|
||||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
|
||||||
* message to update (issue #771).
|
|
||||||
*
|
|
||||||
* Merge instead of replace: append any current message the REST page does
|
|
||||||
* not already cover (checked by id AND logical content via
|
|
||||||
* [sameLogicalMessage]) and keep chronological order.
|
|
||||||
*/
|
|
||||||
internal fun mergeTranscriptWithLive(
|
|
||||||
restMessages: List<ChatMessage>,
|
|
||||||
currentMessages: List<ChatMessage>,
|
|
||||||
): List<ChatMessage> {
|
|
||||||
val dedupedRest = restMessages.dedupeById()
|
|
||||||
val dedupedCurrent = currentMessages.dedupeById()
|
|
||||||
// User rows can carry live/cache-only metadata (for example whether the
|
|
||||||
// bubble continues the active turn). Keep that richer copy when REST
|
|
||||||
// returns the same logical row.
|
|
||||||
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
|
||||||
val mergedRest =
|
|
||||||
dedupedRest.map { rest ->
|
|
||||||
if (rest.role != MessageRole.USER) return@map rest
|
|
||||||
val matchIndex =
|
|
||||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
|
||||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
|
||||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
|
||||||
}
|
|
||||||
val restIds = dedupedRest.map { it.id }.toSet()
|
|
||||||
val liveTail =
|
|
||||||
dedupedCurrent.filter { old ->
|
|
||||||
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
|
||||||
}
|
|
||||||
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
|
||||||
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Merge a REST page without matching it against already-settled transcript rows. */
|
|
||||||
internal fun mergeIncrementalTranscriptPage(
|
|
||||||
restMessages: List<ChatMessage>,
|
|
||||||
currentMessages: List<ChatMessage>,
|
|
||||||
sessionId: String,
|
|
||||||
offset: Int,
|
|
||||||
): List<ChatMessage> {
|
|
||||||
val settledEnd =
|
|
||||||
currentMessages.indexOfLast { message ->
|
|
||||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
currentMessages.take(settledEnd + 1) +
|
|
||||||
mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1))
|
|
||||||
).dedupeById()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serverMessageIndex(
|
|
||||||
id: String,
|
|
||||||
sessionId: String,
|
|
||||||
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
|
|
||||||
|
|
||||||
internal fun List<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
|
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
val messages: List<ChatMessage> = emptyList(),
|
val messages: List<ChatMessage> = emptyList(),
|
||||||
val currentSessionId: String? = null,
|
val currentSessionId: String? = null,
|
||||||
@@ -646,6 +435,16 @@ class ChatViewModel(
|
|||||||
addSystemMessage = { text -> addSystemMessage(text) },
|
addSystemMessage = { text -> addSystemMessage(text) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val clarifyDelegate =
|
||||||
|
ChatClarifyDelegate(
|
||||||
|
uiState = _uiState,
|
||||||
|
scope = viewModelScope,
|
||||||
|
ioDispatcher = ioDispatcher,
|
||||||
|
persistMessage = { msg, sid -> repo.persistMessage(msg, sid) },
|
||||||
|
wsClient = wsClient,
|
||||||
|
trackRequest = { id, method -> trackRequest(id, method) },
|
||||||
|
)
|
||||||
|
|
||||||
private val streamingController =
|
private val streamingController =
|
||||||
ChatStreamingController(
|
ChatStreamingController(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -3383,110 +3182,12 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun respondToClarify(option: String) {
|
fun respondToClarify(option: String) = clarifyDelegate.respondToClarify(option)
|
||||||
val clarify = _uiState.value.clarifyRequest
|
|
||||||
// Only use synthesized qid when this is a true batch or legacy with explicit qid.
|
|
||||||
val qid =
|
|
||||||
if (clarify != null &&
|
|
||||||
(clarify.questionId != null || clarify.questions.isNotEmpty())
|
|
||||||
) {
|
|
||||||
clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (qid != null && clarify?.resolvedQuestions?.size == 1) {
|
|
||||||
respondToClarifyBatch(mapOf(qid to option))
|
|
||||||
} else {
|
|
||||||
respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun respondToClarifyBatch(
|
fun respondToClarifyBatch(
|
||||||
answers: Map<String, String>,
|
answers: Map<String, String>,
|
||||||
singleFallbackAnswer: String? = null,
|
singleFallbackAnswer: String? = null,
|
||||||
) {
|
) = clarifyDelegate.respondToClarifyBatch(answers, singleFallbackAnswer)
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
|
||||||
val clarify = _uiState.value.clarifyRequest
|
|
||||||
val clarifyId = clarify?.clarifyId
|
|
||||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
|
||||||
val questions = clarify?.resolvedQuestions.orEmpty()
|
|
||||||
_uiState.update { it.copy(clarifyRequest = null) }
|
|
||||||
|
|
||||||
val displayContent =
|
|
||||||
if (questions.size > 1) {
|
|
||||||
questions
|
|
||||||
.mapIndexed { index, q ->
|
|
||||||
val ans = answers[q.qid]?.trim().orEmpty()
|
|
||||||
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
|
|
||||||
}.joinToString("\n")
|
|
||||||
} else {
|
|
||||||
val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty()
|
|
||||||
loneAns
|
|
||||||
}
|
|
||||||
|
|
||||||
val userMessage =
|
|
||||||
ChatMessage(
|
|
||||||
role = MessageRole.USER,
|
|
||||||
content = displayContent,
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
messages = state.messages + userMessage,
|
|
||||||
isAgentTyping = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
|
||||||
repo.persistMessage(userMessage, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
|
||||||
if (isBatch) {
|
|
||||||
for (q in questions) {
|
|
||||||
val ans = answers[q.qid]?.trim().orEmpty()
|
|
||||||
val params =
|
|
||||||
mutableMapOf<String, Any>(
|
|
||||||
"session_id" to sessionId,
|
|
||||||
"response" to ans,
|
|
||||||
"answer" to ans,
|
|
||||||
"question_id" to q.qid,
|
|
||||||
)
|
|
||||||
if (clarifyId != null) {
|
|
||||||
params["clarify_id"] = clarifyId
|
|
||||||
params["request_id"] = clarifyId
|
|
||||||
}
|
|
||||||
wsClient.send(
|
|
||||||
method = WsMethods.CLARIFY_RESPOND,
|
|
||||||
params = params,
|
|
||||||
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Legacy single: only include question_id when explicitly present.
|
|
||||||
val qid = clarify?.questionId
|
|
||||||
val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty()
|
|
||||||
val params =
|
|
||||||
mutableMapOf<String, Any>(
|
|
||||||
"session_id" to sessionId,
|
|
||||||
"response" to ans,
|
|
||||||
"answer" to ans,
|
|
||||||
)
|
|
||||||
if (clarifyId != null) {
|
|
||||||
params["clarify_id"] = clarifyId
|
|
||||||
params["request_id"] = clarifyId
|
|
||||||
}
|
|
||||||
if (qid != null) {
|
|
||||||
params["question_id"] = qid
|
|
||||||
}
|
|
||||||
wsClient.send(
|
|
||||||
method = WsMethods.CLARIFY_RESPOND,
|
|
||||||
params = params,
|
|
||||||
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_uiState.update { it.copy(errorMessage = null) }
|
_uiState.update { it.copy(errorMessage = null) }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
|
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
||||||
|
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
|
||||||
|
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatAppUpdateSection() {
|
||||||
|
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
||||||
|
val appUpdateViewModel: AppUpdateViewModel =
|
||||||
|
viewModel {
|
||||||
|
val app =
|
||||||
|
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||||
|
?: error("Application not available")
|
||||||
|
AppUpdateViewModel(app)
|
||||||
|
}
|
||||||
|
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val updateLifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(updateLifecycleOwner) {
|
||||||
|
val observer =
|
||||||
|
LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
||||||
|
appUpdateViewModel.resumeInstallAfterPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateLifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
updateLifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||||
|
val noticeTag =
|
||||||
|
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||||
|
?: UpdateNoticeManager.noticeTag()
|
||||||
|
if (noticeTag != null) {
|
||||||
|
UpdateNoticeBanner(
|
||||||
|
latestTag = noticeTag,
|
||||||
|
onUpdate = { AppUpdateCache.showDialog() },
|
||||||
|
onDismiss = { AppUpdateCache.dismiss() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AppUpdateCache.isDialogVisible) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val dialogState =
|
||||||
|
if (appUpdateState !is AppUpdateState.Idle) {
|
||||||
|
appUpdateState
|
||||||
|
} else {
|
||||||
|
updateNotice
|
||||||
|
}
|
||||||
|
AppUpdateDialog(
|
||||||
|
state = dialogState,
|
||||||
|
onDismiss = { AppUpdateCache.hideDialog() },
|
||||||
|
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
||||||
|
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
||||||
|
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||||
|
onOpenSettings = {
|
||||||
|
launchUnknownAppSourcesSettings(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchUnknownAppSourcesSettings(context: Context) {
|
||||||
|
val intent =
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ChatMediaLaunchers(
|
||||||
|
val isListening: Boolean,
|
||||||
|
val onMicTap: () -> Unit,
|
||||||
|
val onCameraTap: () -> Unit,
|
||||||
|
val onImageTap: () -> Unit,
|
||||||
|
val onFileTap: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberChatMediaLaunchers(
|
||||||
|
inputFieldValue: TextFieldValue,
|
||||||
|
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||||
|
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||||
|
onAddAttachments: (List<Attachment>) -> Unit,
|
||||||
|
onShowMessage: (String) -> Unit,
|
||||||
|
launchExternalActivity: (() -> Unit) -> Unit,
|
||||||
|
context: Context = LocalContext.current,
|
||||||
|
): ChatMediaLaunchers {
|
||||||
|
var isListening by remember { mutableStateOf(false) }
|
||||||
|
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
||||||
|
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||||
|
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||||
|
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||||
|
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
|
||||||
|
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
|
||||||
|
|
||||||
|
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||||
|
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||||
|
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||||
|
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
||||||
|
|
||||||
|
// Speech-to-text recognition launcher (issue #194)
|
||||||
|
val speechLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
isListening = false
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
val spokenText =
|
||||||
|
result.data
|
||||||
|
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
|
?.firstOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
if (spokenText.isNotBlank()) {
|
||||||
|
val currentText = currentInputFieldValue.text
|
||||||
|
val merged =
|
||||||
|
if (currentText.isBlank()) {
|
||||||
|
spokenText
|
||||||
|
} else {
|
||||||
|
"$currentText $spokenText"
|
||||||
|
}
|
||||||
|
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic permission launcher
|
||||||
|
val micPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
if (granted) {
|
||||||
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
|
isListening = true
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
try {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
currentOnShowMessage(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentOnShowMessage(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentOnShowMessage(sttPermissionDeniedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-file picker for attachments (issue #195)
|
||||||
|
val filePickerLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetMultipleContents(),
|
||||||
|
) { uris: List<Uri> ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
val attachments =
|
||||||
|
uris.mapNotNull { uri ->
|
||||||
|
runCatching {
|
||||||
|
var name = uri.lastPathSegment ?: "file"
|
||||||
|
var size = 0L
|
||||||
|
context.contentResolver
|
||||||
|
.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
|
||||||
|
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Attachment(
|
||||||
|
uri = uri.toString(),
|
||||||
|
name = name,
|
||||||
|
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
|
||||||
|
size = size,
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
currentOnAddAttachments(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera photo launcher (issue #195)
|
||||||
|
val cameraLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.TakePicture(),
|
||||||
|
) { success ->
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
val uri = pendingCameraUri
|
||||||
|
pendingCameraUri = null
|
||||||
|
if (success && uri != null) {
|
||||||
|
try {
|
||||||
|
val fileName =
|
||||||
|
"photo_${
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
|
||||||
|
Date(),
|
||||||
|
)
|
||||||
|
}.jpg"
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
val size = inputStream?.use { it.available().toLong() } ?: 0L
|
||||||
|
currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatScreen", "Camera capture failed", e)
|
||||||
|
currentOnShowMessage(cameraErrorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onMicTap: () -> Unit = {
|
||||||
|
if (isListening) {
|
||||||
|
isListening = false
|
||||||
|
} else if (
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
|
isListening = true
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
try {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
currentOnShowMessage(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentOnShowMessage(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onCameraTap: () -> Unit = {
|
||||||
|
try {
|
||||||
|
val timeStamp =
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val photoFile =
|
||||||
|
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
|
||||||
|
val uri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
photoFile,
|
||||||
|
)
|
||||||
|
pendingCameraUri = uri
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatScreen", "Camera launch failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onImageTap: () -> Unit = {
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
filePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onFileTap: () -> Unit = {
|
||||||
|
currentLaunchExternalActivity {
|
||||||
|
filePickerLauncher.launch("*/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remember(isListening) {
|
||||||
|
ChatMediaLaunchers(
|
||||||
|
isListening = isListening,
|
||||||
|
onMicTap = onMicTap,
|
||||||
|
onCameraTap = onCameraTap,
|
||||||
|
onImageTap = onImageTap,
|
||||||
|
onFileTap = onFileTap,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.BaselineShift
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
|
|
||||||
|
object MarkdownInlineStyler {
|
||||||
|
private val URL_PATTERN =
|
||||||
|
Regex(
|
||||||
|
"""https?://[^\s<>'"\)\],;]+(?<![.\),;])""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseInline(
|
||||||
|
text: String,
|
||||||
|
textColor: Color,
|
||||||
|
searchQuery: String,
|
||||||
|
isCurrentMatch: Boolean,
|
||||||
|
linkColor: Color,
|
||||||
|
highlights: SearchHighlightColors,
|
||||||
|
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||||
|
): AnnotatedString =
|
||||||
|
parseInlineSource(
|
||||||
|
text =
|
||||||
|
splitInlineMath(text).joinToString(separator = "") { segment ->
|
||||||
|
when (segment) {
|
||||||
|
is InlineMathSegment.Math -> segment.latex
|
||||||
|
is InlineMathSegment.Text -> segment.value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseInlineSource(
|
||||||
|
text: String,
|
||||||
|
textColor: Color,
|
||||||
|
searchQuery: String,
|
||||||
|
isCurrentMatch: Boolean,
|
||||||
|
linkColor: Color,
|
||||||
|
highlights: SearchHighlightColors,
|
||||||
|
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||||
|
): AnnotatedString {
|
||||||
|
val searchHighlightColor =
|
||||||
|
if (isCurrentMatch) {
|
||||||
|
highlights.currentSearchBackground to highlights.currentSearchForeground
|
||||||
|
} else {
|
||||||
|
highlights.searchBackground to highlights.searchForeground
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var i = 0
|
||||||
|
val src = text
|
||||||
|
while (i < src.length) {
|
||||||
|
when {
|
||||||
|
// ***bold italic***
|
||||||
|
src.startsWith("***", i) -> {
|
||||||
|
val end = src.indexOf("***", i + 3)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 3, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 3
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **bold**
|
||||||
|
src.startsWith("**", i) -> {
|
||||||
|
val end = src.indexOf("**", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~~strike~~
|
||||||
|
src.startsWith("~~", i) -> {
|
||||||
|
val end = src.indexOf("~~", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *italic*
|
||||||
|
src.startsWith("*", i) -> {
|
||||||
|
val end = src.indexOf('*', i + 1)
|
||||||
|
if (end != -1 && end > i + 1) {
|
||||||
|
val raw = src.substring(i + 1, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==highlight==
|
||||||
|
src.startsWith("==", i) -> {
|
||||||
|
val end = src.indexOf("==", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ^superscript^
|
||||||
|
src.startsWith("^", i) -> {
|
||||||
|
val end = src.indexOf('^', i + 1)
|
||||||
|
if (end != -1 && end > i + 1) {
|
||||||
|
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
|
||||||
|
append(src.substring(i + 1, end))
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~subscript~ (single tilde; ~~ handled above)
|
||||||
|
src.startsWith("~", i) -> {
|
||||||
|
val end = src.indexOf('~', i + 1)
|
||||||
|
if (end != -1 && end > i + 1) {
|
||||||
|
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
|
||||||
|
append(src.substring(i + 1, end))
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <kbd>key</kbd>
|
||||||
|
src.startsWith("<kbd>", i) -> {
|
||||||
|
val end = src.indexOf("</kbd>", i)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 5, end)
|
||||||
|
val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
background = textColor.copy(alpha = 0.12f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 6
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [^ref] footnote marker -> superscript
|
||||||
|
src.startsWith("[^", i) -> {
|
||||||
|
val close = src.indexOf(']', i)
|
||||||
|
if (close != -1) {
|
||||||
|
val id = src.substring(i + 2, close)
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
baselineShift = BaselineShift.Superscript,
|
||||||
|
color = linkColor,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append("[$id]")
|
||||||
|
}
|
||||||
|
i = close + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [text](url)
|
||||||
|
src.startsWith("[", i) -> {
|
||||||
|
val close = src.indexOf(']', i)
|
||||||
|
if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
|
||||||
|
val urlEnd = src.indexOf(')', close + 2)
|
||||||
|
if (urlEnd != -1) {
|
||||||
|
val label = src.substring(i + 1, close)
|
||||||
|
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
|
||||||
|
val url = src.substring(close + 2, urlEnd)
|
||||||
|
pushLink(LinkAnnotation.Url(url))
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = linkColor,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(labelToAppend)
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
i = urlEnd + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `inline code`
|
||||||
|
src.startsWith("`", i) -> {
|
||||||
|
val end = src.indexOf('`', i + 1)
|
||||||
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 1, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
background = textColor.copy(alpha = 0.08f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(toAppend)
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bare URL
|
||||||
|
URL_PATTERN.matchAt(src, i) != null -> {
|
||||||
|
val match = URL_PATTERN.matchAt(src, i)!!
|
||||||
|
val url = match.value
|
||||||
|
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
|
||||||
|
pushLink(LinkAnnotation.Url(url))
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = linkColor,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(urlToAppend)
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
i = match.range.last + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text / words in RTL
|
||||||
|
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
|
||||||
|
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||||
|
var end = i + Character.charCount(src.codePointAt(i))
|
||||||
|
while (end < src.length) {
|
||||||
|
val cp = src.codePointAt(end)
|
||||||
|
val dir = Character.getDirectionality(cp)
|
||||||
|
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
|
||||||
|
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
|
||||||
|
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
|
||||||
|
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
|
||||||
|
(
|
||||||
|
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
|
||||||
|
Character.getDirectionality(src.codePointAt(end + 1)) ==
|
||||||
|
Character.DIRECTIONALITY_LEFT_TO_RIGHT
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
end += Character.charCount(cp)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ltrSnippet = src.substring(i, end)
|
||||||
|
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// search highlight
|
||||||
|
searchQuery.isNotEmpty() &&
|
||||||
|
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
background = searchHighlightColor.first,
|
||||||
|
color = searchHighlightColor.second,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(src.substring(i, i + searchQuery.length))
|
||||||
|
}
|
||||||
|
i += searchQuery.length
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRtr(isRtl: Boolean): Boolean = isRtl
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.hrm.latex.renderer.measure.LatexMeasurerState
|
||||||
|
import com.hrm.latex.renderer.model.LatexConfig
|
||||||
|
import com.hrm.latex.renderer.model.LatexTheme
|
||||||
|
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkdownInlineText(
|
||||||
|
text: String,
|
||||||
|
textColor: Color,
|
||||||
|
latexMeasurer: LatexMeasurerState,
|
||||||
|
style: TextStyle,
|
||||||
|
searchQuery: String,
|
||||||
|
isCurrentMatch: Boolean,
|
||||||
|
linkColor: Color,
|
||||||
|
highlights: SearchHighlightColors,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||||
|
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
|
val resolvedStyle =
|
||||||
|
style.copy(
|
||||||
|
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
|
)
|
||||||
|
val processedText =
|
||||||
|
remember(text, isRtl) {
|
||||||
|
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||||
|
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||||
|
if (segments.none { it is InlineMathSegment.Math }) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||||
|
MarkdownInlineStyler.parseInlineSource(
|
||||||
|
text = processedText,
|
||||||
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = textColor,
|
||||||
|
style = resolvedStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
return@CompositionLocalProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
val config =
|
||||||
|
LatexConfig(
|
||||||
|
fontSize = style.fontSize,
|
||||||
|
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
||||||
|
accessibilityEnabled = true,
|
||||||
|
)
|
||||||
|
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
||||||
|
segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment is InlineMathSegment.Math) {
|
||||||
|
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val annotated =
|
||||||
|
buildAnnotatedString {
|
||||||
|
segments.forEachIndexed { index, segment ->
|
||||||
|
when (segment) {
|
||||||
|
is InlineMathSegment.Math -> {
|
||||||
|
val id = "latex-$index"
|
||||||
|
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
||||||
|
}
|
||||||
|
|
||||||
|
is InlineMathSegment.Text -> {
|
||||||
|
append(
|
||||||
|
MarkdownInlineStyler.parseInlineSource(
|
||||||
|
text = segment.value,
|
||||||
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = annotated,
|
||||||
|
inlineContent = inlineContent,
|
||||||
|
color = textColor,
|
||||||
|
style = resolvedStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||||
|
|
||||||
|
object MarkdownMediaResolver {
|
||||||
|
fun resolveImageUrl(uri: String): String {
|
||||||
|
val trimmed = uri.trim()
|
||||||
|
if (trimmed.isBlank()) return uri
|
||||||
|
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
|
||||||
|
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
|
||||||
|
val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
|
||||||
|
if (mediaUrl != null) return mediaUrl
|
||||||
|
|
||||||
|
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
|
||||||
|
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
||||||
|
val sep = if (cleanPath.contains("?")) "&" else "?"
|
||||||
|
return if (token.isNotBlank() && !cleanPath.contains("token=")) {
|
||||||
|
"${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token"
|
||||||
|
} else {
|
||||||
|
"${baseUrl.trimEnd('/')}$cleanPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
sealed interface MdBlock {
|
||||||
|
data class Code(
|
||||||
|
val code: String,
|
||||||
|
val language: String? = null,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Math(
|
||||||
|
val latex: String,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Heading(
|
||||||
|
val level: Int,
|
||||||
|
val text: String,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Bullet(
|
||||||
|
val text: String,
|
||||||
|
val level: Int = 0,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Task(
|
||||||
|
val checked: Boolean,
|
||||||
|
val text: String,
|
||||||
|
val level: Int = 0,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Ordered(
|
||||||
|
val index: Int,
|
||||||
|
val text: String,
|
||||||
|
val level: Int = 0,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Image(
|
||||||
|
val uri: String,
|
||||||
|
val alt: String = "",
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
val uri: String,
|
||||||
|
val alt: String = "",
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Quote(
|
||||||
|
val text: String,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Paragraph(
|
||||||
|
val text: String,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Table(
|
||||||
|
val header: List<String>,
|
||||||
|
val alignments: List<TableAlign>,
|
||||||
|
val rows: List<List<String>>,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
object Hr : MdBlock
|
||||||
|
|
||||||
|
data class DefList(
|
||||||
|
val items: List<DefItem>,
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Footnotes(
|
||||||
|
val notes: List<FnNote>,
|
||||||
|
) : MdBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DefItem(
|
||||||
|
val term: String,
|
||||||
|
val definitions: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Footnote(
|
||||||
|
val id: String,
|
||||||
|
val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FnNote(
|
||||||
|
val id: String,
|
||||||
|
val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class TableAlign {
|
||||||
|
LEFT,
|
||||||
|
CENTER,
|
||||||
|
RIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface InlineMathSegment {
|
||||||
|
data class Text(
|
||||||
|
val value: String,
|
||||||
|
) : InlineMathSegment
|
||||||
|
|
||||||
|
data class Math(
|
||||||
|
val latex: String,
|
||||||
|
) : InlineMathSegment
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
|
||||||
|
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
|
||||||
|
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
|
||||||
|
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
|
||||||
|
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
|
||||||
|
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
|
||||||
|
|
||||||
|
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
|
||||||
|
fun splitInlineMath(text: String): List<InlineMathSegment> {
|
||||||
|
val segments = mutableListOf<InlineMathSegment>()
|
||||||
|
var plainStart = 0
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
if (text[i] == '`' && !text.isEscaped(i)) {
|
||||||
|
val codeEnd = text.indexOf('`', i + 1)
|
||||||
|
i = if (codeEnd == -1) text.length else codeEnd + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith("\\(", i)) {
|
||||||
|
val end = text.indexOf("\\)", i + 2)
|
||||||
|
if (end != -1 && end > i + 2) {
|
||||||
|
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
|
||||||
|
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
|
||||||
|
i = end + 2
|
||||||
|
plainStart = i
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = i + 1
|
||||||
|
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
|
||||||
|
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
|
||||||
|
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
|
||||||
|
i = end + 1
|
||||||
|
plainStart = i
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
|
||||||
|
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isEscaped(index: Int): Boolean {
|
||||||
|
var slashes = 0
|
||||||
|
var i = index - 1
|
||||||
|
while (i >= 0 && this[i] == '\\') {
|
||||||
|
slashes++
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return slashes % 2 == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseDisplayMath(
|
||||||
|
lines: List<String>,
|
||||||
|
start: Int,
|
||||||
|
): Pair<MdBlock.Math, Int>? {
|
||||||
|
val first = lines[start].trim()
|
||||||
|
if (first.startsWith("\\[")) {
|
||||||
|
if (first.length > 4 && first.endsWith("\\]")) {
|
||||||
|
val latex = first.substring(2, first.length - 2).trim()
|
||||||
|
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
|
||||||
|
}
|
||||||
|
if (first != "\\[") return null
|
||||||
|
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
|
||||||
|
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
|
||||||
|
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
|
||||||
|
}
|
||||||
|
if (!first.startsWith("$$")) return null
|
||||||
|
if (first.length > 4 && first.endsWith("$$")) {
|
||||||
|
val latex = first.substring(2, first.length - 2).trim()
|
||||||
|
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
|
||||||
|
}
|
||||||
|
if (first != "$$") return null
|
||||||
|
|
||||||
|
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
|
||||||
|
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
|
||||||
|
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
|
||||||
|
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
|
||||||
|
*/
|
||||||
|
fun parseBlocks(src: String): List<MdBlock> {
|
||||||
|
val lines = src.lines()
|
||||||
|
val blocks = mutableListOf<MdBlock>()
|
||||||
|
val footnotes = mutableListOf<Footnote>()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = lines[i]
|
||||||
|
|
||||||
|
// Footnote definition: [^id]: text (collected, not rendered inline)
|
||||||
|
val fnMatch = FN_DEF_RE.matchAt(line, 0)
|
||||||
|
if (fnMatch != null) {
|
||||||
|
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayMath = tryParseDisplayMath(lines, i)
|
||||||
|
if (displayMath != null) {
|
||||||
|
blocks.add(displayMath.first)
|
||||||
|
i = displayMath.second
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val codeFence = parseCodeFenceStart(line)
|
||||||
|
when {
|
||||||
|
codeFence != null -> {
|
||||||
|
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
|
||||||
|
if (end != null) {
|
||||||
|
blocks.add(
|
||||||
|
MdBlock.Code(
|
||||||
|
code = lines.subList(i + 1, end).joinToString("\n"),
|
||||||
|
language = codeFence.language,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
blocks.add(
|
||||||
|
MdBlock.Code(
|
||||||
|
code = lines.subList(i + 1, lines.size).joinToString("\n"),
|
||||||
|
language = codeFence.language,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
i = lines.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line.isBlank() -> {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
isHorizontalRule(line) -> {
|
||||||
|
blocks.add(MdBlock.Hr)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading: requires a space after the '#' run so "#572" stays a paragraph
|
||||||
|
isValidHeading(line) -> {
|
||||||
|
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
|
||||||
|
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
isTableStart(lines, i) -> {
|
||||||
|
val (header, alignments, body) = parseTable(lines, i)
|
||||||
|
blocks.add(MdBlock.Table(header, alignments, body))
|
||||||
|
i += body.size + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith(">") -> {
|
||||||
|
val quote = mutableListOf<String>()
|
||||||
|
while (i < lines.size && lines[i].startsWith(">")) {
|
||||||
|
quote.add(lines[i].removePrefix(">").trim())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition list: term line followed by one+ ": definition" lines
|
||||||
|
isDefListStart(lines, i) -> {
|
||||||
|
val term = line.trim()
|
||||||
|
val defs = mutableListOf<String>()
|
||||||
|
i++
|
||||||
|
while (i < lines.size && lines[i].trim().startsWith(":")) {
|
||||||
|
defs.add(lines[i].trim().removePrefix(":").trim())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
|
||||||
|
}
|
||||||
|
|
||||||
|
LIST_ITEM_LINE_RE.matches(line) -> {
|
||||||
|
val (listBlocks, nextIndex) = parseList(lines, i)
|
||||||
|
blocks.addAll(listBlocks)
|
||||||
|
i = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone Markdown image:  on its own line.
|
||||||
|
line.isNotBlank() && tryParseVideo(line) != null -> {
|
||||||
|
blocks.add(tryParseVideo(line)!!)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
line.isNotBlank() && tryParseImage(line) != null -> {
|
||||||
|
blocks.add(tryParseImage(line)!!)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
i = fallthroughToParagraph(lines, i, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footnotes.isNotEmpty()) {
|
||||||
|
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ParsedItem {
|
||||||
|
val indent: Int
|
||||||
|
val continuationLines: MutableList<String>
|
||||||
|
|
||||||
|
data class Bullet(
|
||||||
|
override val indent: Int,
|
||||||
|
val text: String,
|
||||||
|
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||||
|
) : ParsedItem
|
||||||
|
|
||||||
|
data class Task(
|
||||||
|
override val indent: Int,
|
||||||
|
val checked: Boolean,
|
||||||
|
val text: String,
|
||||||
|
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||||
|
) : ParsedItem
|
||||||
|
|
||||||
|
data class Ordered(
|
||||||
|
override val indent: Int,
|
||||||
|
val rawNumber: Int,
|
||||||
|
val text: String,
|
||||||
|
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||||
|
) : ParsedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeIndent(prefix: String): Int {
|
||||||
|
var count = 0
|
||||||
|
for (ch in prefix) {
|
||||||
|
if (ch == '\t') {
|
||||||
|
count += 4 - (count % 4)
|
||||||
|
} else {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseList(
|
||||||
|
lines: List<String>,
|
||||||
|
startIndex: Int,
|
||||||
|
): Pair<List<MdBlock>, Int> {
|
||||||
|
val items = mutableListOf<ParsedItem>()
|
||||||
|
var j = startIndex
|
||||||
|
|
||||||
|
while (j < lines.size) {
|
||||||
|
val line = lines[j]
|
||||||
|
|
||||||
|
val taskMatch = TASK_LINE_RE.matchEntire(line)
|
||||||
|
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
|
||||||
|
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
|
||||||
|
|
||||||
|
if (taskMatch != null) {
|
||||||
|
val indent = computeIndent(taskMatch.groupValues[1])
|
||||||
|
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
|
||||||
|
val text = taskMatch.groupValues[3]
|
||||||
|
items.add(ParsedItem.Task(indent, checked, text))
|
||||||
|
j++
|
||||||
|
} else if (bulletMatch != null) {
|
||||||
|
val indent = computeIndent(bulletMatch.groupValues[1])
|
||||||
|
val text = bulletMatch.groupValues[2]
|
||||||
|
items.add(ParsedItem.Bullet(indent, text))
|
||||||
|
j++
|
||||||
|
} else if (orderedMatch != null) {
|
||||||
|
val indent = computeIndent(orderedMatch.groupValues[1])
|
||||||
|
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
|
||||||
|
val text = orderedMatch.groupValues[3]
|
||||||
|
items.add(ParsedItem.Ordered(indent, num, text))
|
||||||
|
j++
|
||||||
|
} else if (line.isBlank()) {
|
||||||
|
// Check if there is a following list item
|
||||||
|
var lookahead = j + 1
|
||||||
|
while (lookahead < lines.size && lines[lookahead].isBlank()) {
|
||||||
|
lookahead++
|
||||||
|
}
|
||||||
|
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
|
||||||
|
j = lookahead
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if this is a continuation line for the previous list item
|
||||||
|
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
|
||||||
|
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
|
||||||
|
items.last().continuationLines.add(line.trim())
|
||||||
|
j++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
return emptyList<MdBlock>() to j
|
||||||
|
}
|
||||||
|
|
||||||
|
val blocks = mutableListOf<MdBlock>()
|
||||||
|
|
||||||
|
// Track state of list items and their relative nesting depth using an indent stack
|
||||||
|
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
|
||||||
|
var lastItemWasOrdered = false
|
||||||
|
var currentSequenceNumber = 1
|
||||||
|
var lastLevel = -1
|
||||||
|
|
||||||
|
for (item in items) {
|
||||||
|
// Resolve level using the indent stack
|
||||||
|
val level: Int
|
||||||
|
if (indentStack.isEmpty() || item.indent == 0) {
|
||||||
|
indentStack.clear()
|
||||||
|
indentStack.add(item.indent)
|
||||||
|
level = 0
|
||||||
|
} else if (item.indent > indentStack.last()) {
|
||||||
|
indentStack.add(item.indent)
|
||||||
|
level = indentStack.size - 1
|
||||||
|
} else if (item.indent == indentStack.last()) {
|
||||||
|
level = indentStack.size - 1
|
||||||
|
} else {
|
||||||
|
// Unwind stack to the matching or nearest smaller indent
|
||||||
|
while (indentStack.size > 1 && indentStack.last() > item.indent) {
|
||||||
|
indentStack.removeAt(indentStack.size - 1)
|
||||||
|
}
|
||||||
|
level = indentStack.size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullText =
|
||||||
|
if (item.continuationLines.isEmpty()) {
|
||||||
|
when (item) {
|
||||||
|
is ParsedItem.Bullet -> item.text
|
||||||
|
is ParsedItem.Task -> item.text
|
||||||
|
is ParsedItem.Ordered -> item.text
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val base =
|
||||||
|
when (item) {
|
||||||
|
is ParsedItem.Bullet -> item.text
|
||||||
|
is ParsedItem.Task -> item.text
|
||||||
|
is ParsedItem.Ordered -> item.text
|
||||||
|
}
|
||||||
|
base + " " + item.continuationLines.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
when (item) {
|
||||||
|
is ParsedItem.Bullet -> {
|
||||||
|
blocks.add(MdBlock.Bullet(fullText, level = level))
|
||||||
|
lastItemWasOrdered = false
|
||||||
|
lastLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
is ParsedItem.Task -> {
|
||||||
|
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
|
||||||
|
lastItemWasOrdered = false
|
||||||
|
lastLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
is ParsedItem.Ordered -> {
|
||||||
|
val indexToUse =
|
||||||
|
if (level == lastLevel && lastItemWasOrdered) {
|
||||||
|
if (item.rawNumber > currentSequenceNumber) {
|
||||||
|
currentSequenceNumber = item.rawNumber
|
||||||
|
} else {
|
||||||
|
currentSequenceNumber++
|
||||||
|
}
|
||||||
|
currentSequenceNumber
|
||||||
|
} else {
|
||||||
|
currentSequenceNumber = item.rawNumber
|
||||||
|
currentSequenceNumber
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
|
||||||
|
lastItemWasOrdered = true
|
||||||
|
lastLevel = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks to j
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHorizontalRule(line: String): Boolean {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.length < 3) return false
|
||||||
|
val c = trimmed[0]
|
||||||
|
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTableStart(
|
||||||
|
lines: List<String>,
|
||||||
|
i: Int,
|
||||||
|
): Boolean {
|
||||||
|
val l = lines[i]
|
||||||
|
if (!l.contains('|')) return false
|
||||||
|
if (i + 1 >= lines.size) return false
|
||||||
|
return isTableSeparator(lines[i + 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTableSeparator(line: String): Boolean {
|
||||||
|
val t = line.trim().trim('|')
|
||||||
|
if (t.isEmpty()) return false
|
||||||
|
// every cell must be only dashes/colons/spaces, with at least one dash
|
||||||
|
return t.split('|').all { cell ->
|
||||||
|
val c = cell.trim()
|
||||||
|
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTable(
|
||||||
|
lines: List<String>,
|
||||||
|
i: Int,
|
||||||
|
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
|
||||||
|
val header = splitRow(lines[i])
|
||||||
|
val alignments =
|
||||||
|
splitRow(lines[i + 1]).map { cell ->
|
||||||
|
val c = cell.trim()
|
||||||
|
when {
|
||||||
|
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
|
||||||
|
c.endsWith(":") -> TableAlign.RIGHT
|
||||||
|
c.startsWith(":") -> TableAlign.LEFT
|
||||||
|
else -> TableAlign.LEFT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = mutableListOf<List<String>>()
|
||||||
|
var j = i + 2
|
||||||
|
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
|
||||||
|
body.add(splitRow(lines[j]))
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
return Triple(header, alignments, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitRow(line: String): List<String> {
|
||||||
|
val trimmed = line.trim().trim('|')
|
||||||
|
return trimmed.split('|').map { it.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CodeFenceInfo(
|
||||||
|
val fenceChar: Char,
|
||||||
|
val fenceLength: Int,
|
||||||
|
val language: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
if (trimmed.length < 3) return null
|
||||||
|
val firstChar = trimmed[0]
|
||||||
|
if (firstChar != '`' && firstChar != '~') return null
|
||||||
|
val count = trimmed.takeWhile { it == firstChar }.length
|
||||||
|
if (count < 3) return null
|
||||||
|
val rest = trimmed.substring(count).trim()
|
||||||
|
if (firstChar == '`' && rest.contains('`')) return null
|
||||||
|
return CodeFenceInfo(
|
||||||
|
fenceChar = firstChar,
|
||||||
|
fenceLength = count,
|
||||||
|
language = rest.ifBlank { null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCodeFenceEnd(
|
||||||
|
line: String,
|
||||||
|
fence: CodeFenceInfo,
|
||||||
|
): Boolean {
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
val count = trimmed.takeWhile { it == fence.fenceChar }.length
|
||||||
|
if (count < fence.fenceLength) return false
|
||||||
|
val rest = trimmed.substring(count).trim()
|
||||||
|
return rest.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDefListStart(
|
||||||
|
lines: List<String>,
|
||||||
|
i: Int,
|
||||||
|
): Boolean {
|
||||||
|
val l = lines[i].trim()
|
||||||
|
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
|
||||||
|
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
|
||||||
|
if (i + 1 >= lines.size) return false
|
||||||
|
return lines[i + 1].trim().startsWith(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidHeading(line: String): Boolean {
|
||||||
|
if (!line.startsWith("#")) return false
|
||||||
|
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
|
||||||
|
return level < line.length && line[level] == ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseImage(line: String): MdBlock.Image? {
|
||||||
|
val m = IMAGE_RE.matchAt(line, 0) ?: return null
|
||||||
|
val uri = m.groupValues[2].trim()
|
||||||
|
if (uri.isEmpty()) return null
|
||||||
|
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseVideo(line: String): MdBlock.Video? {
|
||||||
|
val m = IMAGE_RE.matchAt(line, 0) ?: return null
|
||||||
|
val uri = m.groupValues[2].trim()
|
||||||
|
if (uri.isEmpty()) return null
|
||||||
|
val ext = uri.substringAfterLast('.', "").lowercase()
|
||||||
|
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
|
||||||
|
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fallthroughToParagraph(
|
||||||
|
lines: List<String>,
|
||||||
|
start: Int,
|
||||||
|
blocks: MutableList<MdBlock>,
|
||||||
|
): Int {
|
||||||
|
var i = start
|
||||||
|
val para = mutableListOf<String>()
|
||||||
|
while (
|
||||||
|
i < lines.size &&
|
||||||
|
lines[i].isNotBlank() &&
|
||||||
|
parseCodeFenceStart(lines[i]) == null &&
|
||||||
|
!isValidHeading(lines[i]) &&
|
||||||
|
!lines[i].startsWith(">") &&
|
||||||
|
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
|
||||||
|
!isHorizontalRule(lines[i]) &&
|
||||||
|
!isTableStart(lines, i) &&
|
||||||
|
!isDefListStart(lines, i) &&
|
||||||
|
tryParseDisplayMath(lines, i) == null &&
|
||||||
|
FN_DEF_RE.matchAt(lines[i], 0) == null
|
||||||
|
) {
|
||||||
|
para.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
|
||||||
|
return i
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.markdown
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
|
|
||||||
|
private val TABLE_COL_WIDTH = 140.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkdownTable(
|
||||||
|
block: MdBlock.Table,
|
||||||
|
textColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isRtl =
|
||||||
|
remember(block) {
|
||||||
|
block.header.any { BidiUtils.isRtlText(it) } ||
|
||||||
|
block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } }
|
||||||
|
}
|
||||||
|
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
|
val headerBg = textColor.copy(alpha = 0.08f)
|
||||||
|
val alignments = block.alignments
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
// Header row
|
||||||
|
Row(modifier = Modifier.background(headerBg)) {
|
||||||
|
block.header.forEachIndexed { idx, cell ->
|
||||||
|
val cellRtl = BidiUtils.isRtlText(cell)
|
||||||
|
Text(
|
||||||
|
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||||
|
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||||
|
color = textColor,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(TABLE_COL_WIDTH)
|
||||||
|
.padding(6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
|
||||||
|
// Body rows
|
||||||
|
block.rows.forEach { row ->
|
||||||
|
Row {
|
||||||
|
row.forEachIndexed { idx, cell ->
|
||||||
|
val cellRtl = BidiUtils.isRtlText(cell)
|
||||||
|
Text(
|
||||||
|
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||||
|
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||||
|
color = textColor,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(TABLE_COL_WIDTH)
|
||||||
|
.padding(6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableTextAlign(align: TableAlign?): TextAlign? =
|
||||||
|
when (align) {
|
||||||
|
TableAlign.CENTER -> TextAlign.Center
|
||||||
|
TableAlign.RIGHT -> TextAlign.End
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard confirmation dialog across the app.
|
||||||
|
* Supports destructive actions with error styling, optional title icons, and optional extra body content.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
confirmText: String = stringResource(R.string.action_confirm),
|
||||||
|
dismissText: String = stringResource(R.string.action_cancel),
|
||||||
|
isDestructive: Boolean = false,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
extraContent: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
title = {
|
||||||
|
if (icon != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (isDestructive) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (extraContent != null) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(message)
|
||||||
|
extraContent()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (isDestructive) {
|
||||||
|
Button(
|
||||||
|
onClick = onConfirm,
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(confirmText)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = onConfirm) {
|
||||||
|
Text(confirmText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(dismissText)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,33 @@ inline fun <T> ViewModel.safeLaunchLoad(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
|
||||||
|
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
|
||||||
|
*/
|
||||||
|
inline fun <T> ViewModel.safeLaunchAction(
|
||||||
|
currentJob: Job? = null,
|
||||||
|
crossinline onStart: () -> Unit = {},
|
||||||
|
crossinline apiCall: suspend () -> NetworkResult<T>,
|
||||||
|
crossinline onSuccess: (T) -> Unit = {},
|
||||||
|
crossinline onError: (String) -> Unit = {},
|
||||||
|
crossinline onComplete: () -> Unit = {},
|
||||||
|
): Job {
|
||||||
|
if (currentJob?.isActive == true) return currentJob
|
||||||
|
onStart()
|
||||||
|
return viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val result = apiCall()
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> onSuccess(result.data)
|
||||||
|
is NetworkResult.Failure -> onError(result.error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
|
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
|
||||||
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
|
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||||
|
|
||||||
|
object AddServerRequestBuilder {
|
||||||
|
/**
|
||||||
|
* Constructs a validated AddMcpServerRequest from the add-server form parameters.
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
name: String,
|
||||||
|
mode: AddServerMode,
|
||||||
|
url: String,
|
||||||
|
command: String,
|
||||||
|
args: String,
|
||||||
|
auth: String,
|
||||||
|
bearerToken: String,
|
||||||
|
): AddMcpServerRequest {
|
||||||
|
val isHttp = mode == AddServerMode.HTTP
|
||||||
|
val isStdio = mode == AddServerMode.Stdio
|
||||||
|
|
||||||
|
val resolvedUrl = if (isHttp) url.trim().ifBlank { null } else null
|
||||||
|
val resolvedCommand = if (isStdio) command.trim().ifBlank { null } else null
|
||||||
|
val resolvedArgs =
|
||||||
|
if (isStdio && args.isNotBlank()) {
|
||||||
|
args.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val resolvedAuth = if (isHttp && auth != "none") auth else null
|
||||||
|
val resolvedBearerToken =
|
||||||
|
if (isHttp && auth == "header") bearerToken.trim().ifBlank { null } else null
|
||||||
|
|
||||||
|
return AddMcpServerRequest(
|
||||||
|
name = name.trim(),
|
||||||
|
url = resolvedUrl,
|
||||||
|
command = resolvedCommand,
|
||||||
|
args = resolvedArgs,
|
||||||
|
auth = resolvedAuth,
|
||||||
|
bearerToken = resolvedBearerToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overload reading form parameters directly from McpServersUiState.
|
||||||
|
*/
|
||||||
|
fun build(state: McpServersUiState): AddMcpServerRequest =
|
||||||
|
build(
|
||||||
|
name = state.addServerName,
|
||||||
|
mode = state.addMode,
|
||||||
|
url = state.addServerUrl,
|
||||||
|
command = state.addServerCommand,
|
||||||
|
args = state.addServerArgs,
|
||||||
|
auth = state.addServerAuth,
|
||||||
|
bearerToken = state.addServerBearerToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
object McpImportSummary {
|
||||||
|
/**
|
||||||
|
* Formats the human-readable summary message reporting the result of a batch MCP JSON import.
|
||||||
|
*/
|
||||||
|
fun formatSummary(
|
||||||
|
successCount: Int,
|
||||||
|
errors: List<String>,
|
||||||
|
): String {
|
||||||
|
val errSummary = errors.joinToString("; ")
|
||||||
|
return when {
|
||||||
|
errors.isEmpty() -> "Successfully imported $successCount server(s)"
|
||||||
|
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
|
||||||
|
else -> "Failed to import: $errSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,10 @@ import com.m57.hermescontrol.ui.common.ToastEffect
|
|||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||||
|
import com.m57.hermescontrol.ui.mcp.components.AddServerSection
|
||||||
|
import com.m57.hermescontrol.ui.mcp.components.CatalogSection
|
||||||
|
import com.m57.hermescontrol.ui.mcp.components.McpDialogs
|
||||||
|
import com.m57.hermescontrol.ui.mcp.components.ServerCard
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun McpServersScreen(
|
fun McpServersScreen(
|
||||||
@@ -247,842 +251,13 @@ fun McpServersScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showDetail?.let { server ->
|
McpDialogs(
|
||||||
DetailDialog(
|
state = state,
|
||||||
title = server.name,
|
viewModel = viewModel,
|
||||||
rows = server.toDetailRows(),
|
spacing = spacing,
|
||||||
onDismiss = { showDetail = null },
|
selectedServerDetail = showDetail,
|
||||||
)
|
onDismissDetail = { showDetail = null },
|
||||||
}
|
context = context,
|
||||||
|
clipboardManager = clipboardManager,
|
||||||
state.activeOAuthFlow?.let { flow ->
|
|
||||||
androidx.compose.material3.AlertDialog(
|
|
||||||
onDismissRequest = { viewModel.dismissOAuthFlow() },
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
flow.authorizationUrl?.let { url ->
|
|
||||||
try {
|
|
||||||
context.startActivity(
|
|
||||||
android.content.Intent(
|
|
||||||
android.content.Intent.ACTION_VIEW,
|
|
||||||
android.net.Uri.parse(url),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
flow.error?.let { err ->
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.showImportDialog) {
|
|
||||||
androidx.compose.material3.AlertDialog(
|
|
||||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
|
||||||
title = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.UploadFile,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
val clip = clipboardManager.getText()?.text
|
|
||||||
if (!clip.isNullOrBlank()) {
|
|
||||||
viewModel.updateImportJsonInput(clip)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.importJsonInput,
|
|
||||||
onValueChange = viewModel::updateImportJsonInput,
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(180.dp),
|
|
||||||
maxLines = 10,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.submitImportJson() },
|
|
||||||
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
|
||||||
) {
|
|
||||||
if (state.isImportingJson) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.mcp_servers_action_submit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
|
||||||
// as a header, not a button) ──────────────────────────────────────────────
|
|
||||||
@Composable
|
|
||||||
private fun McpSectionHeader(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val spacing = LocalSpacing.current
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
).padding(horizontal = spacing.md, vertical = spacing.sm),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trailing?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sections ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddServerSection(
|
|
||||||
state: McpServersUiState,
|
|
||||||
viewModel: McpServersViewModel,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
) {
|
|
||||||
McpSectionHeader(
|
|
||||||
icon = Icons.Filled.Add,
|
|
||||||
title = stringResource(R.string.mcp_servers_add_server),
|
|
||||||
trailing = {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
|
||||||
TextButton(
|
|
||||||
onClick = { viewModel.toggleImportDialog() },
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.textButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_action_import_json))
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = { viewModel.toggleAddForm() },
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.textButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(if (state.showAddForm) "Hide" else "New")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(visible = state.showAddForm) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(spacing.md)) {
|
|
||||||
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
|
|
||||||
Tab(
|
|
||||||
selected = state.addMode == AddServerMode.HTTP,
|
|
||||||
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
|
|
||||||
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
|
|
||||||
)
|
|
||||||
Tab(
|
|
||||||
selected = state.addMode == AddServerMode.Stdio,
|
|
||||||
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
|
|
||||||
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.addServerName,
|
|
||||||
onValueChange = viewModel::updateAddServerName,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
|
|
||||||
if (state.addMode == AddServerMode.HTTP) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.addServerUrl,
|
|
||||||
onValueChange = viewModel::updateAddServerUrl,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_auth_mode),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = state.addServerAuth == "none",
|
|
||||||
onClick = { viewModel.updateAddServerAuth("none") },
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.addServerAuth == "header",
|
|
||||||
onClick = { viewModel.updateAddServerAuth("header") },
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.addServerAuth == "oauth",
|
|
||||||
onClick = { viewModel.updateAddServerAuth("oauth") },
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.addServerAuth == "header") {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.addServerBearerToken,
|
|
||||||
onValueChange = viewModel::updateAddServerBearerToken,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = remember { PasswordVisualTransformation() },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.addServerCommand,
|
|
||||||
onValueChange = viewModel::updateAddServerCommand,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.addServerArgs,
|
|
||||||
onValueChange = viewModel::updateAddServerArgs,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.md))
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.submitAddServer() },
|
|
||||||
enabled = !state.addingServer,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (state.addingServer) {
|
|
||||||
stringResource(R.string.mcp_servers_action_adding)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.mcp_servers_action_submit)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ServerCard(
|
|
||||||
server: McpServer,
|
|
||||||
state: McpServersUiState,
|
|
||||||
viewModel: McpServersViewModel,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onOpenBrowser: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
var showEnv by remember { mutableStateOf(false) }
|
|
||||||
val isTesting = server.name in state.testingServers
|
|
||||||
val testResult = state.serverTestResults[server.name]
|
|
||||||
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
if (server.enabled) {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
|
|
||||||
// Header row: name + status + toggle
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = server.name.replaceFirstChar { it.uppercase() },
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
when (healthStatus) {
|
|
||||||
McpHealthStatus.HEALTHY -> {
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.mcp_servers_status_healthy),
|
|
||||||
status = StatusBadgeType.SUCCESS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
McpHealthStatus.AUTH_REQUIRED -> {
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
|
||||||
status = StatusBadgeType.WARNING,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
McpHealthStatus.ERROR -> {
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.mcp_servers_status_error),
|
|
||||||
status = StatusBadgeType.ERROR,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
McpHealthStatus.TESTING -> {
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.mcp_servers_status_testing),
|
|
||||||
status = StatusBadgeType.INFO,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
McpHealthStatus.UNKNOWN -> {
|
|
||||||
ServerStatusBadge(server.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tool count & schema token overhead badge (issue #1029)
|
|
||||||
val toolInfos = testResult?.tools
|
|
||||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
|
||||||
if (toolCount != null && toolCount > 0) {
|
|
||||||
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
|
||||||
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
|
||||||
shape = RoundedCornerShape(6.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Build,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = server.enabled,
|
|
||||||
onCheckedChange = { viewModel.toggleServer(server) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL or Command
|
|
||||||
if (server.url != null) {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Text(
|
|
||||||
text = server.url,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
)
|
|
||||||
} else if (server.command != null) {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_label_command),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error diagnostics
|
|
||||||
server.error?.let { error ->
|
|
||||||
if (error.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
StatusBadge(
|
|
||||||
text = error,
|
|
||||||
status = StatusBadgeType.ERROR,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
) {
|
|
||||||
if (server.auth == "oauth") {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
|
||||||
modifier = Modifier.weight(1.2f),
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { viewModel.testServer(server.name) },
|
|
||||||
enabled = !isTesting,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
|
||||||
) {
|
|
||||||
if (isTesting) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
|
||||||
} else {
|
|
||||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Env vars toggle
|
|
||||||
TextButton(
|
|
||||||
onClick = { showEnv = !showEnv },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_env_vars))
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = showEnv) {
|
|
||||||
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EnvVarSection(
|
|
||||||
server: McpServer,
|
|
||||||
state: McpServersUiState,
|
|
||||||
viewModel: McpServersViewModel,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
) {
|
|
||||||
val isEditing = state.editingEnvFor == server.name
|
|
||||||
val env = server.env ?: emptyMap()
|
|
||||||
|
|
||||||
if (env.isEmpty() && !isEditing) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_env_no_vars),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = spacing.xs),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
env.forEach { (key, value) ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
|
||||||
Text(
|
|
||||||
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.envKeyInput,
|
|
||||||
onValueChange = viewModel::updateEnvKey,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.envValueInput,
|
|
||||||
onValueChange = viewModel::updateEnvValue,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
|
||||||
) {
|
|
||||||
Button(onClick = { viewModel.addEnvVar(server.name) }) {
|
|
||||||
Text(stringResource(R.string.mcp_servers_env_add))
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
|
||||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(stringResource(R.string.mcp_servers_env_add))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ServerStatusBadge(status: String?) {
|
|
||||||
if (status == null) return
|
|
||||||
val badgeType =
|
|
||||||
when (status.lowercase()) {
|
|
||||||
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
|
|
||||||
"error", "failed" -> StatusBadgeType.ERROR
|
|
||||||
"stopped" -> StatusBadgeType.NEUTRAL
|
|
||||||
else -> StatusBadgeType.NEUTRAL
|
|
||||||
}
|
|
||||||
StatusBadge(text = status, status = badgeType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Catalog ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CatalogSection(
|
|
||||||
state: McpServersUiState,
|
|
||||||
viewModel: McpServersViewModel,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
filteredCatalog: List<McpCatalogEntry>,
|
|
||||||
) {
|
|
||||||
val catalogExpanded = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
McpSectionHeader(
|
|
||||||
icon = Icons.Filled.Storage,
|
|
||||||
title = stringResource(R.string.mcp_servers_section_catalog),
|
|
||||||
trailing = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
catalogExpanded.value = !catalogExpanded.value
|
|
||||||
if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) {
|
|
||||||
viewModel.loadCatalog()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.textButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(if (catalogExpanded.value) "Hide" else "Browse")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = catalogExpanded.value) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
// Search
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.catalogQuery,
|
|
||||||
onValueChange = viewModel::updateCatalogQuery,
|
|
||||||
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Loading / error / content
|
|
||||||
when {
|
|
||||||
state.catalogLoading -> {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
state.catalogError != null -> {
|
|
||||||
ErrorState(
|
|
||||||
message = state.catalogError,
|
|
||||||
onRetry = { viewModel.loadCatalog() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredCatalog.isEmpty() -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_catalog_empty),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = spacing.md),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
filteredCatalog.forEach { entry ->
|
|
||||||
CatalogEntryCard(
|
|
||||||
entry = entry,
|
|
||||||
state = state,
|
|
||||||
viewModel = viewModel,
|
|
||||||
spacing = spacing,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CatalogEntryCard(
|
|
||||||
entry: McpCatalogEntry,
|
|
||||||
state: McpServersUiState,
|
|
||||||
viewModel: McpServersViewModel,
|
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
|
||||||
) {
|
|
||||||
var showInstallForm by remember { mutableStateOf(false) }
|
|
||||||
val isInstalling = state.installingCatalogEntry == entry.name
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(spacing.md)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = entry.name,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
entry.source?.let {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_catalog_source, it),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (entry.env?.isNotEmpty() == true) {
|
|
||||||
showInstallForm = !showInstallForm
|
|
||||||
} else {
|
|
||||||
viewModel.installCatalogEntry(entry)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isInstalling,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (isInstalling) {
|
|
||||||
stringResource(R.string.mcp_servers_catalog_installing)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.mcp_servers_catalog_install)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.description?.let {
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install form with env vars
|
|
||||||
AnimatedVisibility(visible = showInstallForm) {
|
|
||||||
Column(modifier = Modifier.padding(top = spacing.sm)) {
|
|
||||||
entry.env?.let { envVars ->
|
|
||||||
if (envVars.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mcp_servers_catalog_required_env),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
envVars.forEach { envVar ->
|
|
||||||
val label = envVar.label ?: envVar.key
|
|
||||||
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
|
|
||||||
OutlinedTextField(
|
|
||||||
value = currentValue,
|
|
||||||
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
|
|
||||||
label = { Text(label) },
|
|
||||||
placeholder = envVar.description?.let { { Text(it) } },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.installCatalogEntry(entry) },
|
|
||||||
enabled = !isInstalling,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (isInstalling) {
|
|
||||||
stringResource(R.string.mcp_servers_catalog_installing)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.mcp_servers_catalog_install)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,13 +297,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val errSummary = errors.joinToString("; ")
|
val msg = McpImportSummary.formatSummary(successCount, errors)
|
||||||
val msg =
|
|
||||||
when {
|
|
||||||
errors.isEmpty() -> "Successfully imported $successCount server(s)"
|
|
||||||
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
|
|
||||||
else -> "Failed to import: $errSummary"
|
|
||||||
}
|
|
||||||
state.copy(
|
state.copy(
|
||||||
isImportingJson = false,
|
isImportingJson = false,
|
||||||
showImportDialog = false,
|
showImportDialog = false,
|
||||||
@@ -357,38 +351,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
_uiState.update { it.copy(addingServer = true) }
|
_uiState.update { it.copy(addingServer = true) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val request =
|
val request = AddServerRequestBuilder.build(state)
|
||||||
AddMcpServerRequest(
|
|
||||||
name = state.addServerName.trim(),
|
|
||||||
url = if (state.addMode == AddServerMode.HTTP) state.addServerUrl.trim().ifBlank { null } else null,
|
|
||||||
command =
|
|
||||||
if (state.addMode == AddServerMode.Stdio) {
|
|
||||||
state.addServerCommand.trim().ifBlank { null }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
args =
|
|
||||||
if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) {
|
|
||||||
state.addServerArgs
|
|
||||||
.trim()
|
|
||||||
.split("\\s+".toRegex())
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
auth =
|
|
||||||
if (state.addMode == AddServerMode.HTTP && state.addServerAuth != "none") {
|
|
||||||
state.addServerAuth
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
bearerToken =
|
|
||||||
if (state.addMode == AddServerMode.HTTP && state.addServerAuth == "header") {
|
|
||||||
state.addServerBearerToken.trim().ifBlank { null }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
|
safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
|
||||||
@@ -622,6 +585,7 @@ class McpServersViewModel :
|
|||||||
oauthPollJob =
|
oauthPollJob =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var polling = true
|
var polling = true
|
||||||
|
var consecutiveFailures = 0
|
||||||
while (polling) {
|
while (polling) {
|
||||||
kotlinx.coroutines.delay(2000)
|
kotlinx.coroutines.delay(2000)
|
||||||
val result =
|
val result =
|
||||||
@@ -666,7 +630,16 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
|
consecutiveFailures++
|
||||||
|
if (consecutiveFailures >= 10) {
|
||||||
|
polling = false
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
activeOAuthFlow = null,
|
||||||
|
toastMessage = "OAuth polling timed out after repeated network failures",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SecondaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.mcp.AddServerMode
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddServerSection(
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
McpSectionHeader(
|
||||||
|
icon = Icons.Filled.Add,
|
||||||
|
title = stringResource(R.string.mcp_servers_add_server),
|
||||||
|
trailing = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.toggleImportDialog() },
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_import_json))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.toggleAddForm() },
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(if (state.showAddForm) "Hide" else "New")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = state.showAddForm) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
|
||||||
|
Tab(
|
||||||
|
selected = state.addMode == AddServerMode.HTTP,
|
||||||
|
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
|
||||||
|
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = state.addMode == AddServerMode.Stdio,
|
||||||
|
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
|
||||||
|
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.addServerName,
|
||||||
|
onValueChange = viewModel::updateAddServerName,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
if (state.addMode == AddServerMode.HTTP) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.addServerUrl,
|
||||||
|
onValueChange = viewModel::updateAddServerUrl,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_auth_mode),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = state.addServerAuth == "none",
|
||||||
|
onClick = { viewModel.updateAddServerAuth("none") },
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = state.addServerAuth == "header",
|
||||||
|
onClick = { viewModel.updateAddServerAuth("header") },
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = state.addServerAuth == "oauth",
|
||||||
|
onClick = { viewModel.updateAddServerAuth("oauth") },
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.addServerAuth == "header") {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.addServerBearerToken,
|
||||||
|
onValueChange = viewModel::updateAddServerBearerToken,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = remember { PasswordVisualTransformation() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.addServerCommand,
|
||||||
|
onValueChange = viewModel::updateAddServerCommand,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.addServerArgs,
|
||||||
|
onValueChange = viewModel::updateAddServerArgs,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.md))
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.submitAddServer() },
|
||||||
|
enabled = !state.addingServer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (state.addingServer) {
|
||||||
|
stringResource(R.string.mcp_servers_action_adding)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.mcp_servers_action_submit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CatalogSection(
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
filteredCatalog: List<McpCatalogEntry>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val catalogExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
McpSectionHeader(
|
||||||
|
icon = Icons.Filled.Storage,
|
||||||
|
title = stringResource(R.string.mcp_servers_section_catalog),
|
||||||
|
trailing = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
catalogExpanded.value = !catalogExpanded.value
|
||||||
|
if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) {
|
||||||
|
viewModel.loadCatalog()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(if (catalogExpanded.value) "Hide" else "Browse")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = catalogExpanded.value) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Search
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.catalogQuery,
|
||||||
|
onValueChange = viewModel::updateCatalogQuery,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loading / error / content
|
||||||
|
when {
|
||||||
|
state.catalogLoading -> {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.catalogError != null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.catalogError,
|
||||||
|
onRetry = { viewModel.loadCatalog() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredCatalog.isEmpty() -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_catalog_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = spacing.md),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
filteredCatalog.forEach { entry ->
|
||||||
|
CatalogEntryCard(
|
||||||
|
entry = entry,
|
||||||
|
state = state,
|
||||||
|
viewModel = viewModel,
|
||||||
|
spacing = spacing,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CatalogEntryCard(
|
||||||
|
entry: McpCatalogEntry,
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showInstallForm by remember { mutableStateOf(false) }
|
||||||
|
val isInstalling = state.installingCatalogEntry == entry.name
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = entry.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
entry.source?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_catalog_source, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (entry.env?.isNotEmpty() == true) {
|
||||||
|
showInstallForm = !showInstallForm
|
||||||
|
} else {
|
||||||
|
viewModel.installCatalogEntry(entry)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isInstalling,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (isInstalling) {
|
||||||
|
stringResource(R.string.mcp_servers_catalog_installing)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.mcp_servers_catalog_install)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.description?.let {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install form with env vars
|
||||||
|
AnimatedVisibility(visible = showInstallForm) {
|
||||||
|
Column(modifier = Modifier.padding(top = spacing.sm)) {
|
||||||
|
entry.env?.let { envVars ->
|
||||||
|
if (envVars.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_catalog_required_env),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
envVars.forEach { envVar ->
|
||||||
|
val label = envVar.label ?: envVar.key
|
||||||
|
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
|
||||||
|
OutlinedTextField(
|
||||||
|
value = currentValue,
|
||||||
|
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
|
||||||
|
label = { Text(label) },
|
||||||
|
placeholder = envVar.description?.let { { Text(it) } },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.installCatalogEntry(entry) },
|
||||||
|
enabled = !isInstalling,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (isInstalling) {
|
||||||
|
stringResource(R.string.mcp_servers_catalog_installing)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.mcp_servers_catalog_install)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.McpServer
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||||
|
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun McpDialogs(
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
selectedServerDetail: McpServer?,
|
||||||
|
onDismissDetail: () -> Unit,
|
||||||
|
context: Context = LocalContext.current,
|
||||||
|
clipboardManager: ClipboardManager = LocalClipboardManager.current,
|
||||||
|
) {
|
||||||
|
selectedServerDetail?.let { server ->
|
||||||
|
DetailDialog(
|
||||||
|
title = server.name,
|
||||||
|
rows = server.toDetailRows(),
|
||||||
|
onDismiss = onDismissDetail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activeOAuthFlow?.let { flow ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.dismissOAuthFlow() },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
flow.authorizationUrl?.let { url ->
|
||||||
|
try {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
flow.error?.let { err ->
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.showImportDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.UploadFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val clip = clipboardManager.getText()?.text
|
||||||
|
if (!clip.isNullOrBlank()) {
|
||||||
|
viewModel.updateImportJsonInput(clip)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.importJsonInput,
|
||||||
|
onValueChange = viewModel::updateImportJsonInput,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp),
|
||||||
|
maxLines = 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.submitImportJson() },
|
||||||
|
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (state.isImportingJson) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun McpSectionHeader(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
).padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Science
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.McpServer
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpHealthStatus
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpTokenEstimator
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerCard(
|
||||||
|
server: McpServer,
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onOpenBrowser: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showEnv by remember { mutableStateOf(false) }
|
||||||
|
val isTesting = server.name in state.testingServers
|
||||||
|
val testResult = state.serverTestResults[server.name]
|
||||||
|
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (server.enabled) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
|
||||||
|
// Header row: name + status + toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = server.name.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
when (healthStatus) {
|
||||||
|
McpHealthStatus.HEALTHY -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||||
|
status = StatusBadgeType.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.AUTH_REQUIRED -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||||
|
status = StatusBadgeType.WARNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.ERROR -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_error),
|
||||||
|
status = StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.TESTING -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_testing),
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.UNKNOWN -> {
|
||||||
|
ServerStatusBadge(server.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tool count & schema token overhead badge (issue #1029)
|
||||||
|
val toolInfos = testResult?.tools
|
||||||
|
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||||
|
if (toolCount != null && toolCount > 0) {
|
||||||
|
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||||
|
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Build,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = server.enabled,
|
||||||
|
onCheckedChange = { viewModel.toggleServer(server) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL or Command
|
||||||
|
if (server.url != null) {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = server.url,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
} else if (server.command != null) {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_label_command),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error diagnostics
|
||||||
|
server.error?.let { error ->
|
||||||
|
if (error.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = error,
|
||||||
|
status = StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
if (server.auth == "oauth") {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
||||||
|
modifier = Modifier.weight(1.2f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.testServer(server.name) },
|
||||||
|
enabled = !isTesting,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
if (isTesting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env vars toggle
|
||||||
|
TextButton(
|
||||||
|
onClick = { showEnv = !showEnv },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_env_vars))
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = showEnv) {
|
||||||
|
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EnvVarSection(
|
||||||
|
server: McpServer,
|
||||||
|
state: McpServersUiState,
|
||||||
|
viewModel: McpServersViewModel,
|
||||||
|
spacing: Spacing,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isEditing = state.editingEnvFor == server.name
|
||||||
|
val env = server.env ?: emptyMap()
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (env.isEmpty() && !isEditing) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_env_no_vars),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = spacing.xs),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
env.forEach { (key, value) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.envKeyInput,
|
||||||
|
onValueChange = viewModel::updateEnvKey,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.envValueInput,
|
||||||
|
onValueChange = viewModel::updateEnvValue,
|
||||||
|
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
Button(onClick = { viewModel.addEnvVar(server.name) }) {
|
||||||
|
Text(stringResource(R.string.mcp_servers_env_add))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_env_add))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerStatusBadge(status: String?) {
|
||||||
|
if (status == null) return
|
||||||
|
val badgeType =
|
||||||
|
when (status.lowercase()) {
|
||||||
|
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
|
||||||
|
"error", "failed" -> StatusBadgeType.ERROR
|
||||||
|
"stopped" -> StatusBadgeType.NEUTRAL
|
||||||
|
else -> StatusBadgeType.NEUTRAL
|
||||||
|
}
|
||||||
|
StatusBadge(text = status, status = badgeType)
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
|
|||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel
|
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel
|
||||||
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType
|
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType
|
||||||
|
import com.m57.hermescontrol.util.formatBytes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Memory management home — owns the memory surface that used to live in the
|
* Memory management home — owns the memory surface that used to live in the
|
||||||
@@ -425,10 +426,3 @@ private fun MemoryProviderRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBytes(bytes: Long): String =
|
|
||||||
when {
|
|
||||||
bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
|
||||||
bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0)
|
|
||||||
else -> "$bytes B"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
|
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a backend search hit into a display model WITHOUT faking a title.
|
||||||
|
* The backend returns no session name, only a matched `snippet` + metadata, so the
|
||||||
|
* card must render the snippet as a match excerpt (never as the title). `title` is
|
||||||
|
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
|
||||||
|
*/
|
||||||
|
internal fun SessionSearchResult.toSessionInfo(): SessionInfo =
|
||||||
|
SessionInfo(
|
||||||
|
id = session_id,
|
||||||
|
title = null,
|
||||||
|
preview = snippet,
|
||||||
|
source = source,
|
||||||
|
model = model,
|
||||||
|
started_at = session_started,
|
||||||
|
// message_count/status aren't in the search payload; leave null so the
|
||||||
|
// search card hides those normal-list affordances.
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
|
||||||
|
* string. Used for search results where the session name is unknown.
|
||||||
|
*/
|
||||||
|
internal fun formatPlayedAt(epochSeconds: Double?): String? {
|
||||||
|
if (epochSeconds == null || epochSeconds <= 0.0) return null
|
||||||
|
val instant =
|
||||||
|
try {
|
||||||
|
Instant.ofEpochSecond(epochSeconds.toLong())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val now = Instant.now()
|
||||||
|
val diffSec = abs(now.epochSecond - instant.epochSecond)
|
||||||
|
val absFormatter =
|
||||||
|
DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
return when {
|
||||||
|
diffSec < 60 -> "just now"
|
||||||
|
diffSec < 3600 -> "${diffSec / 60}m ago"
|
||||||
|
diffSec < 86400 -> "${diffSec / 3600}h ago"
|
||||||
|
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
|
||||||
|
else -> absFormatter.format(instant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val searchSnippetJson = Json { isLenient = true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
|
||||||
|
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
|
||||||
|
*/
|
||||||
|
fun cleanSearchSnippet(snippet: String): String {
|
||||||
|
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
|
||||||
|
val parsedSnippet =
|
||||||
|
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
|
||||||
|
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
|
||||||
|
return displayText
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.searchText(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
contentOrNull?.takeIf(String::isNotBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonArray -> {
|
||||||
|
mapNotNull { it.searchText() }
|
||||||
|
.joinToString(" ")
|
||||||
|
.takeIf(String::isNotBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonObject -> {
|
||||||
|
val textFieldNames =
|
||||||
|
listOf("content", "text", "message", "body", "prompt", "summary", "title")
|
||||||
|
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
|
||||||
|
textFieldNames
|
||||||
|
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||||
|
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions
|
||||||
|
|
||||||
|
data class SessionPaginationResolution(
|
||||||
|
val hasMore: Boolean,
|
||||||
|
val total: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
object SessionsPaging {
|
||||||
|
/**
|
||||||
|
* Computes hasMore and effective total when refreshing or loading the initial page of sessions.
|
||||||
|
*/
|
||||||
|
fun resolveInitialPaging(
|
||||||
|
receivedCount: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
backendTotal: Int,
|
||||||
|
accumulatedCount: Int,
|
||||||
|
): SessionPaginationResolution {
|
||||||
|
val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount
|
||||||
|
return SessionPaginationResolution(
|
||||||
|
hasMore = hasMore,
|
||||||
|
total = if (hasMore) backendTotal else accumulatedCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes hasMore and effective total when appending a page of sessions.
|
||||||
|
*/
|
||||||
|
fun resolveLoadMorePaging(
|
||||||
|
receivedCount: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
addedNewItems: Boolean,
|
||||||
|
backendTotal: Int,
|
||||||
|
accumulatedCount: Int,
|
||||||
|
): SessionPaginationResolution {
|
||||||
|
val receivedFullPage = receivedCount >= pageSize
|
||||||
|
val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount
|
||||||
|
return SessionPaginationResolution(
|
||||||
|
hasMore = hasMore,
|
||||||
|
total = if (hasMore) backendTotal else accumulatedCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,17 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
|||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
import kotlinx.serialization.json.Json
|
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
|
||||||
import kotlinx.serialization.json.JsonArray
|
import com.m57.hermescontrol.ui.sessions.components.SessionCard
|
||||||
import kotlinx.serialization.json.JsonElement
|
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
|
||||||
import kotlinx.serialization.json.JsonObject
|
import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import com.m57.hermescontrol.ui.sessions.components.automationGroups
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-load the next history page when the user scrolls to within this many
|
* Auto-load the next history page when the user scrolls to within this many
|
||||||
@@ -197,89 +192,6 @@ private fun highlightText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a backend search hit into a display model WITHOUT faking a title.
|
|
||||||
* The backend returns no session name, only a matched `snippet` + metadata, so the
|
|
||||||
* card must render the snippet as a match excerpt (never as the title). `title` is
|
|
||||||
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
|
|
||||||
*/
|
|
||||||
private fun SessionSearchResult.toSessionInfo(): SessionInfo =
|
|
||||||
SessionInfo(
|
|
||||||
id = session_id,
|
|
||||||
title = null,
|
|
||||||
preview = snippet,
|
|
||||||
source = source,
|
|
||||||
model = model,
|
|
||||||
started_at = session_started,
|
|
||||||
// message_count/status aren't in the search payload; leave null so the
|
|
||||||
// search card hides those normal-list affordances.
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
|
|
||||||
* string. Used for search results where the session name is unknown.
|
|
||||||
*/
|
|
||||||
private fun formatPlayedAt(epochSeconds: Double?): String? {
|
|
||||||
if (epochSeconds == null || epochSeconds <= 0.0) return null
|
|
||||||
val instant =
|
|
||||||
try {
|
|
||||||
Instant.ofEpochSecond(epochSeconds.toLong())
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val now = Instant.now()
|
|
||||||
val diffSec = abs(now.epochSecond - instant.epochSecond)
|
|
||||||
val absFormatter =
|
|
||||||
DateTimeFormatter
|
|
||||||
.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
|
||||||
.withZone(ZoneId.systemDefault())
|
|
||||||
return when {
|
|
||||||
diffSec < 60 -> "just now"
|
|
||||||
diffSec < 3600 -> "${diffSec / 60}m ago"
|
|
||||||
diffSec < 86400 -> "${diffSec / 3600}h ago"
|
|
||||||
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
|
|
||||||
else -> absFormatter.format(instant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val searchSnippetJson = Json { isLenient = true }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
|
|
||||||
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
|
|
||||||
*/
|
|
||||||
internal fun cleanSearchSnippet(snippet: String): String {
|
|
||||||
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
|
|
||||||
val parsedSnippet =
|
|
||||||
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
|
|
||||||
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
|
|
||||||
return displayText
|
|
||||||
.replace(Regex("\\s+"), " ")
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.searchText(): String? =
|
|
||||||
when (this) {
|
|
||||||
is JsonPrimitive -> {
|
|
||||||
contentOrNull?.takeIf(String::isNotBlank)
|
|
||||||
}
|
|
||||||
|
|
||||||
is JsonArray -> {
|
|
||||||
mapNotNull { it.searchText() }
|
|
||||||
.joinToString(" ")
|
|
||||||
.takeIf(String::isNotBlank)
|
|
||||||
}
|
|
||||||
|
|
||||||
is JsonObject -> {
|
|
||||||
val textFieldNames =
|
|
||||||
listOf("content", "text", "message", "body", "prompt", "summary", "title")
|
|
||||||
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
|
|
||||||
textFieldNames
|
|
||||||
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
|
||||||
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||||
if (state.isSearchMode) {
|
if (state.isSearchMode) {
|
||||||
state.searchResults.map { searchResult ->
|
state.searchResults.map { searchResult ->
|
||||||
@@ -774,65 +686,14 @@ fun SessionsScreen(
|
|||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// ── Stats row ───────────────────────────────────────
|
// ── Stats row ───────────────────────────────────────
|
||||||
Row(
|
SessionsStatsRow(
|
||||||
modifier =
|
total = state.total,
|
||||||
Modifier
|
loadedMessageCount = loadedMessageCount,
|
||||||
.fillMaxWidth()
|
section = state.section,
|
||||||
.height(IntrinsicSize.Max)
|
spacing = spacing,
|
||||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
statusColors = statusColors,
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
onPruneClick = { viewModel.showPruneDialog() },
|
||||||
) {
|
)
|
||||||
StatCard(
|
|
||||||
label = stringResource(R.string.sessions_stat_total),
|
|
||||||
value = formatCompactCount(state.total),
|
|
||||||
icon = Icons.Filled.History,
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
|
||||||
)
|
|
||||||
StatCard(
|
|
||||||
label = stringResource(R.string.sessions_stat_messages_loaded),
|
|
||||||
value = formatCompactCount(loadedMessageCount),
|
|
||||||
icon = Icons.Filled.Email,
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
|
||||||
)
|
|
||||||
// Prune button — compact card sized to its label, so the
|
|
||||||
// stat cards keep enough width for large counts.
|
|
||||||
if (state.section == HistorySection.CONVERSATIONS) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxHeight(),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
onClick = { viewModel.showPruneDialog() },
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
|
||||||
// An unweighted Row child with fillMaxSize expands to the
|
|
||||||
// full row width and starves the weighted stat cards.
|
|
||||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DeleteSweep,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = statusColors.warning,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.sessions_action_prune),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = statusColors.warning,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session list ────────────────────────────────────
|
// ── Session list ────────────────────────────────────
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -916,66 +777,21 @@ fun SessionsScreen(
|
|||||||
verticalArrangement = listItemSpacing,
|
verticalArrangement = listItemSpacing,
|
||||||
) {
|
) {
|
||||||
if (state.section == HistorySection.AUTOMATIONS) {
|
if (state.section == HistorySection.AUTOMATIONS) {
|
||||||
automationSessionGroups.forEach { group ->
|
automationGroups(
|
||||||
item(key = "automation-group:${group.key}") {
|
groups = automationSessionGroups,
|
||||||
val expanded = group.key in expandedAutomationGroups
|
expandedGroups = expandedAutomationGroups,
|
||||||
Row(
|
sessionItemsById = sessionItemsById,
|
||||||
modifier =
|
spacing = spacing,
|
||||||
Modifier
|
onToggleGroup = { groupKey ->
|
||||||
.fillMaxWidth()
|
expandedAutomationGroups =
|
||||||
.clickable {
|
if (groupKey in expandedAutomationGroups) {
|
||||||
expandedAutomationGroups =
|
expandedAutomationGroups - groupKey
|
||||||
if (expanded) {
|
} else {
|
||||||
expandedAutomationGroups - group.key
|
expandedAutomationGroups + groupKey
|
||||||
} else {
|
|
||||||
expandedAutomationGroups + group.key
|
|
||||||
}
|
|
||||||
}.padding(horizontal = spacing.sm, vertical = spacing.md),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (expanded) {
|
|
||||||
Icons.Filled.KeyboardArrowDown
|
|
||||||
} else {
|
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
group.title
|
|
||||||
?: group.jobId?.let {
|
|
||||||
stringResource(
|
|
||||||
R.string.sessions_section_automation_job,
|
|
||||||
it,
|
|
||||||
)
|
|
||||||
} ?: stringResource(
|
|
||||||
R.string.sessions_section_automation_unknown,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
stringResource(
|
|
||||||
R.string.sessions_section_automation_runs,
|
|
||||||
group.sessions.size,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
renderSessionCard = sessionCard,
|
||||||
if (group.key in expandedAutomationGroups) {
|
)
|
||||||
items(group.sessions, key = { it.id }) { session ->
|
|
||||||
sessionItemsById[session.id]?.let { sessionCard(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||||
sessionCard(item)
|
sessionCard(item)
|
||||||
@@ -1021,533 +837,21 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
||||||
AnimatedVisibility(
|
SessionsBulkActionBar(
|
||||||
visible = state.isSelecting && hasSelection,
|
visible = state.isSelecting && hasSelection,
|
||||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
allVisibleSessionsSelected = allVisibleSessionsSelected,
|
||||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
selectedCount = state.selectedIds.size,
|
||||||
modifier = Modifier.fillMaxSize(),
|
isDeletingBulk = state.isDeletingBulk,
|
||||||
) {
|
spacing = spacing,
|
||||||
Box(
|
statusColors = statusColors,
|
||||||
modifier = Modifier.fillMaxSize(),
|
onSelectAllToggle = {
|
||||||
contentAlignment = Alignment.BottomCenter,
|
if (allVisibleSessionsSelected) {
|
||||||
) {
|
viewModel.clearSelection()
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shadowElevation = 8.dp,
|
|
||||||
tonalElevation = 4.dp,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// Select all / deselect
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
if (allVisibleSessionsSelected) {
|
|
||||||
viewModel.clearSelection()
|
|
||||||
} else {
|
|
||||||
viewModel.selectAll(visibleSessionIds)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (allVisibleSessionsSelected) {
|
|
||||||
Icons.Filled.Close
|
|
||||||
} else {
|
|
||||||
Icons.Filled.SelectAll
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(
|
|
||||||
if (allVisibleSessionsSelected) {
|
|
||||||
stringResource(R.string.sessions_action_deselect_all)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.sessions_action_select_all)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete selected
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.requestBulkDelete() },
|
|
||||||
enabled = !state.isDeletingBulk,
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = statusColors.error,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
if (state.isDeletingBulk) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = statusColors.onError,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.sessions_action_delete_n,
|
|
||||||
state.selectedIds.size,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun SessionCard(
|
|
||||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
|
||||||
displayTitle: String,
|
|
||||||
branchStem: String?,
|
|
||||||
isFork: Boolean = false,
|
|
||||||
forkDepth: Int = 0,
|
|
||||||
query: String,
|
|
||||||
isSelecting: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
isDeleting: Boolean,
|
|
||||||
isPinned: Boolean,
|
|
||||||
isHidden: Boolean = false,
|
|
||||||
highlightBackground: Color,
|
|
||||||
highlightForeground: Color,
|
|
||||||
onCardClick: () -> Unit,
|
|
||||||
onToggleSelection: () -> Unit,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onTogglePin: () -> Unit,
|
|
||||||
onToggleHide: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
val spacing = LocalSpacing.current
|
|
||||||
val statusColors = LocalHermesStatusColors.current
|
|
||||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
|
||||||
val srcIcon = sourceIcon(session.source)
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag("session_card_${session.id}")
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = onCardClick,
|
|
||||||
// Long-press opens the per-session action menu (issue #785):
|
|
||||||
// Select / Rename / Delete. The old long-press behavior
|
|
||||||
// (jump straight into selection mode) is now the menu's
|
|
||||||
// "Select" item.
|
|
||||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
|
||||||
),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
border =
|
|
||||||
if (isActive && !isSelecting) {
|
|
||||||
BorderStroke(2.dp, statusColors.success)
|
|
||||||
} else if (isSelected) {
|
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(spacing.md),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// Checkbox in select mode
|
|
||||||
if (isSelecting) {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onToggleSelection() },
|
|
||||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fork marker: a fork icon + its nesting depth, so a chain reads
|
|
||||||
// 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the
|
|
||||||
// number carries the structure so names never get squished.
|
|
||||||
if (isFork && !isSelecting) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.CallSplit,
|
|
||||||
contentDescription = stringResource(R.string.sessions_fork_indicator),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
if (forkDepth > 0) {
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Text(
|
|
||||||
text = forkDepth.toString(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source icon
|
|
||||||
if (srcIcon != null && !isSelecting) {
|
|
||||||
Icon(
|
|
||||||
imageVector = srcIcon,
|
|
||||||
contentDescription = sourceLabel(session.source),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main content
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
if (query.isNotBlank()) {
|
|
||||||
highlightText(displayTitle, query, highlightBackground, highlightForeground)
|
|
||||||
} else {
|
|
||||||
AnnotatedString(displayTitle)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (isPinned) {
|
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.PushPin,
|
|
||||||
contentDescription = stringResource(R.string.sessions_pinned_indicator),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (session.hidden == true) {
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.sessions_hidden_badge),
|
|
||||||
status = StatusBadgeType.NEUTRAL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!session.status.isNullOrBlank()) {
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
StatusBadge(
|
|
||||||
text = session.status,
|
|
||||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-session action menu (hold the card to open)
|
|
||||||
SessionActionMenu(
|
|
||||||
expanded = menuExpanded,
|
|
||||||
onDismiss = { menuExpanded = false },
|
|
||||||
isDeleting = isDeleting,
|
|
||||||
isPinned = isPinned,
|
|
||||||
onTogglePin = {
|
|
||||||
menuExpanded = false
|
|
||||||
onTogglePin()
|
|
||||||
},
|
|
||||||
isHidden = isHidden,
|
|
||||||
onToggleHide = {
|
|
||||||
menuExpanded = false
|
|
||||||
onToggleHide()
|
|
||||||
},
|
|
||||||
onSelect = {
|
|
||||||
menuExpanded = false
|
|
||||||
onSelect()
|
|
||||||
},
|
|
||||||
onRename = {
|
|
||||||
menuExpanded = false
|
|
||||||
onRename()
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
menuExpanded = false
|
|
||||||
onDelete()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search-result card. The backend search payload has no session title, so this card is
|
|
||||||
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
|
|
||||||
* source / model / played-at metadata chips. It never presents the snippet as a name.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun SearchResultCard(
|
|
||||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
|
||||||
query: String,
|
|
||||||
isSelecting: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
isDeleting: Boolean,
|
|
||||||
highlightBackground: Color,
|
|
||||||
highlightForeground: Color,
|
|
||||||
onCardClick: () -> Unit,
|
|
||||||
onToggleSelection: () -> Unit,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
val spacing = LocalSpacing.current
|
|
||||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
|
||||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
|
||||||
val playedAt = formatPlayedAt(session.started_at)
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag("session_card_${session.id}")
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = onCardClick,
|
|
||||||
// Long-press opens the per-session action menu (issue #785)
|
|
||||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
|
||||||
),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
border =
|
|
||||||
if (isSelected) {
|
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(spacing.sm),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
// Checkbox in select mode
|
|
||||||
if (isSelecting) {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onToggleSelection() },
|
|
||||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
// Header row: "Match" label + source icon
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
|
||||||
) {
|
|
||||||
val srcIcon = sourceIcon(session.source)
|
|
||||||
if (srcIcon != null && !isSelecting) {
|
|
||||||
Icon(
|
|
||||||
imageVector = srcIcon,
|
|
||||||
contentDescription = sourceLabel(session.source),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.sessions_search_match_label),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
letterSpacing = 0.5.sp,
|
|
||||||
)
|
|
||||||
if (!session.id.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = "· ${session.id.take(8)}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
|
|
||||||
// The matched snippet, highlighted — shown as the body, NOT as a title.
|
|
||||||
Text(
|
|
||||||
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 4,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
|
||||||
|
|
||||||
// Metadata chips row
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
|
||||||
) {
|
|
||||||
session.source?.let { src ->
|
|
||||||
StatusBadge(
|
|
||||||
text = sourceLabel(src),
|
|
||||||
status = StatusBadgeType.NEUTRAL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
session.model?.let { mdl ->
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
|
|
||||||
status = StatusBadgeType.INFO,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
playedAt?.let { time ->
|
|
||||||
StatusBadge(
|
|
||||||
text = stringResource(R.string.sessions_search_played_at, time),
|
|
||||||
status = StatusBadgeType.NEUTRAL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-session action menu (hold the card to open)
|
|
||||||
SessionActionMenu(
|
|
||||||
expanded = menuExpanded,
|
|
||||||
onDismiss = { menuExpanded = false },
|
|
||||||
isDeleting = isDeleting,
|
|
||||||
onSelect = {
|
|
||||||
menuExpanded = false
|
|
||||||
onSelect()
|
|
||||||
},
|
|
||||||
onRename = {
|
|
||||||
menuExpanded = false
|
|
||||||
onRename()
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
menuExpanded = false
|
|
||||||
onDelete()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete.
|
|
||||||
* Opened by long-pressing a session card. The pin item is only shown when
|
|
||||||
* [onTogglePin] is provided (history cards; search-result cards omit it).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SessionActionMenu(
|
|
||||||
expanded: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
isDeleting: Boolean,
|
|
||||||
isPinned: Boolean = false,
|
|
||||||
onTogglePin: (() -> Unit)? = null,
|
|
||||||
isHidden: Boolean = false,
|
|
||||||
onToggleHide: (() -> Unit)? = null,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
val statusColors = LocalHermesStatusColors.current
|
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
|
||||||
if (onTogglePin != null) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
if (isPinned) {
|
|
||||||
R.string.sessions_action_unpin
|
|
||||||
} else {
|
|
||||||
R.string.sessions_action_pin
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
|
|
||||||
onClick = onTogglePin,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (onToggleHide != null) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
if (isHidden) {
|
|
||||||
R.string.sessions_action_unhide
|
|
||||||
} else {
|
|
||||||
R.string.sessions_action_hide
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (isHidden) {
|
|
||||||
Icons.Filled.Visibility
|
|
||||||
} else {
|
|
||||||
Icons.Filled.VisibilityOff
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onToggleHide,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.sessions_action_select)) },
|
|
||||||
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
|
||||||
onClick = onSelect,
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.sessions_action_rename)) },
|
|
||||||
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
|
|
||||||
onClick = onRename,
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.action_delete)) },
|
|
||||||
leadingIcon = {
|
|
||||||
if (isDeleting) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
viewModel.selectAll(visibleSessionIds)
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = statusColors.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isDeleting,
|
onDeleteSelected = { viewModel.requestBulkDelete() },
|
||||||
onClick = onDelete,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,11 +147,17 @@ class SessionsViewModel :
|
|||||||
rawPaginationOffset = data.nextOffset(0)
|
rawPaginationOffset = data.nextOffset(0)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
val paging =
|
||||||
|
SessionsPaging.resolveInitialPaging(
|
||||||
|
receivedCount = data.sessions.size,
|
||||||
|
pageSize = PAGE_SIZE,
|
||||||
|
backendTotal = data.total,
|
||||||
|
accumulatedCount = newSessions.size,
|
||||||
|
)
|
||||||
it.copy(
|
it.copy(
|
||||||
sessions = newSessions,
|
sessions = newSessions,
|
||||||
total = if (hasMore) data.total else newSessions.size,
|
total = paging.total,
|
||||||
hasMore = hasMore,
|
hasMore = paging.hasMore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,14 +235,20 @@ class SessionsViewModel :
|
|||||||
if (requestGeneration != generation) return@safeLaunchLoad
|
if (requestGeneration != generation) return@safeLaunchLoad
|
||||||
rawPaginationOffset = data.nextOffset(0)
|
rawPaginationOffset = data.nextOffset(0)
|
||||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
val paging =
|
||||||
|
SessionsPaging.resolveInitialPaging(
|
||||||
|
receivedCount = data.sessions.size,
|
||||||
|
pageSize = PAGE_SIZE,
|
||||||
|
backendTotal = data.total,
|
||||||
|
accumulatedCount = sessionsList.size,
|
||||||
|
)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
sessions = sessionsList,
|
sessions = sessionsList,
|
||||||
total = if (hasMore) data.total else sessionsList.size,
|
total = paging.total,
|
||||||
hasMore = hasMore,
|
hasMore = paging.hasMore,
|
||||||
selectedIds = emptySet(),
|
selectedIds = emptySet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,14 +296,20 @@ class SessionsViewModel :
|
|||||||
(it.sessions + data.sessions)
|
(it.sessions + data.sessions)
|
||||||
.distinctBy { s -> s.id }
|
.distinctBy { s -> s.id }
|
||||||
.pinnedFirst()
|
.pinnedFirst()
|
||||||
val receivedFullPage = data.sessions.size >= PAGE_SIZE
|
|
||||||
val addedNewItems = newSessions.size > it.sessions.size
|
val addedNewItems = newSessions.size > it.sessions.size
|
||||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
val paging =
|
||||||
|
SessionsPaging.resolveLoadMorePaging(
|
||||||
|
receivedCount = data.sessions.size,
|
||||||
|
pageSize = PAGE_SIZE,
|
||||||
|
addedNewItems = addedNewItems,
|
||||||
|
backendTotal = data.total,
|
||||||
|
accumulatedCount = newSessions.size,
|
||||||
|
)
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
sessions = newSessions,
|
sessions = newSessions,
|
||||||
total = if (hasMore) data.total else newSessions.size,
|
total = paging.total,
|
||||||
hasMore = hasMore,
|
hasMore = paging.hasMore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.SessionTreeItem
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.sessions.AutomationSessionGroup
|
||||||
|
|
||||||
|
internal fun LazyListScope.automationGroups(
|
||||||
|
groups: List<AutomationSessionGroup>,
|
||||||
|
expandedGroups: Set<String>,
|
||||||
|
sessionItemsById: Map<String, SessionTreeItem>,
|
||||||
|
spacing: Spacing,
|
||||||
|
onToggleGroup: (String) -> Unit,
|
||||||
|
renderSessionCard: @Composable (SessionTreeItem) -> Unit,
|
||||||
|
) {
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "automation-group:${group.key}") {
|
||||||
|
val expanded = group.key in expandedGroups
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onToggleGroup(group.key) }
|
||||||
|
.padding(horizontal = spacing.sm, vertical = spacing.md),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (expanded) {
|
||||||
|
Icons.Filled.KeyboardArrowDown
|
||||||
|
} else {
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
group.title
|
||||||
|
?: group.jobId?.let {
|
||||||
|
stringResource(
|
||||||
|
R.string.sessions_section_automation_job,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
} ?: stringResource(
|
||||||
|
R.string.sessions_section_automation_unknown,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.sessions_section_automation_runs,
|
||||||
|
group.sessions.size,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (group.key in expandedGroups) {
|
||||||
|
items(group.sessions, key = { it.id }) { session ->
|
||||||
|
sessionItemsById[session.id]?.let { renderSessionCard(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet
|
||||||
|
import com.m57.hermescontrol.ui.sessions.formatPlayedAt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search-result card. The backend search payload has no session title, so this card is
|
||||||
|
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
|
||||||
|
* source / model / played-at metadata chips. It never presents the snippet as a name.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchResultCard(
|
||||||
|
session: SessionInfo,
|
||||||
|
query: String,
|
||||||
|
isSelecting: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
highlightBackground: Color,
|
||||||
|
highlightForeground: Color,
|
||||||
|
onCardClick: () -> Unit,
|
||||||
|
onToggleSelection: () -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
|
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||||
|
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||||
|
val playedAt = formatPlayedAt(session.started_at)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("session_card_${session.id}")
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onCardClick,
|
||||||
|
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||||
|
),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
border =
|
||||||
|
if (isSelected) {
|
||||||
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(spacing.sm),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
if (isSelecting) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { onToggleSelection() },
|
||||||
|
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
// Header row: "Match" label + source icon
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
|
) {
|
||||||
|
val srcIcon = sourceIcon(session.source)
|
||||||
|
if (srcIcon != null && !isSelecting) {
|
||||||
|
Icon(
|
||||||
|
imageVector = srcIcon,
|
||||||
|
contentDescription = sourceLabel(session.source),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sessions_search_match_label),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
)
|
||||||
|
if (!session.id.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "· ${session.id.take(8)}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
|
||||||
|
// The matched snippet, highlighted — shown as the body, NOT as a title.
|
||||||
|
Text(
|
||||||
|
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
|
||||||
|
// Metadata chips row
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
|
) {
|
||||||
|
session.source?.let { src ->
|
||||||
|
StatusBadge(
|
||||||
|
text = sourceLabel(src),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
session.model?.let { mdl ->
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
playedAt?.let { time ->
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.sessions_search_played_at, time),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-session action menu (hold the card to open)
|
||||||
|
SessionActionMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismiss = { menuExpanded = false },
|
||||||
|
isDeleting = isDeleting,
|
||||||
|
onSelect = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSelect()
|
||||||
|
},
|
||||||
|
onRename = {
|
||||||
|
menuExpanded = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions.components
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session overflow/long-press menu.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SessionActionMenu(
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
isPinned: Boolean = false,
|
||||||
|
onTogglePin: (() -> Unit)? = null,
|
||||||
|
isHidden: Boolean = false,
|
||||||
|
onToggleHide: (() -> Unit)? = null,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
|
if (onTogglePin != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isPinned) {
|
||||||
|
R.string.sessions_action_unpin
|
||||||
|
} else {
|
||||||
|
R.string.sessions_action_pin
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
|
||||||
|
onClick = onTogglePin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onToggleHide != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isHidden) {
|
||||||
|
R.string.sessions_action_unhide
|
||||||
|
} else {
|
||||||
|
R.string.sessions_action_hide
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isHidden) {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
} else {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onToggleHide,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.sessions_action_select)) },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
||||||
|
onClick = onSelect,
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.sessions_action_rename)) },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
|
||||||
|
onClick = onRename,
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_delete),
|
||||||
|
color = statusColors.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = statusColors.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = !isDeleting,
|
||||||
|
onClick = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
|
import androidx.compose.material.icons.filled.Language
|
||||||
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
|
||||||
|
fun sourceIcon(source: String?): ImageVector? =
|
||||||
|
when (source?.lowercase()) {
|
||||||
|
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send
|
||||||
|
"web", "dashboard" -> Icons.Filled.Language
|
||||||
|
"api", "rest" -> Icons.Filled.Code
|
||||||
|
"cli", "terminal" -> Icons.Filled.Terminal
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sourceLabel(source: String?): String =
|
||||||
|
when (source?.lowercase()) {
|
||||||
|
"telegram", "tg" -> "Telegram"
|
||||||
|
"web", "dashboard" -> "Web"
|
||||||
|
"api", "rest" -> "API"
|
||||||
|
"cli", "terminal" -> "CLI"
|
||||||
|
else -> source ?: "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun highlightText(
|
||||||
|
text: String,
|
||||||
|
query: String,
|
||||||
|
highlightBackground: Color,
|
||||||
|
highlightForeground: Color,
|
||||||
|
): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (query.isBlank()) {
|
||||||
|
append(text)
|
||||||
|
return@buildAnnotatedString
|
||||||
|
}
|
||||||
|
val lowerText = text.lowercase()
|
||||||
|
val lowerQuery = query.lowercase()
|
||||||
|
var currentIndex = 0
|
||||||
|
while (currentIndex < text.length) {
|
||||||
|
val matchIndex = lowerText.indexOf(lowerQuery, currentIndex)
|
||||||
|
if (matchIndex == -1) {
|
||||||
|
append(text.substring(currentIndex))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (matchIndex > currentIndex) {
|
||||||
|
append(text.substring(currentIndex, matchIndex))
|
||||||
|
}
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
background = highlightBackground,
|
||||||
|
color = highlightForeground,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(text.substring(matchIndex, matchIndex + query.length))
|
||||||
|
}
|
||||||
|
currentIndex = matchIndex + query.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SessionCard(
|
||||||
|
session: SessionInfo,
|
||||||
|
displayTitle: String,
|
||||||
|
branchStem: String?,
|
||||||
|
isFork: Boolean = false,
|
||||||
|
forkDepth: Int = 0,
|
||||||
|
query: String,
|
||||||
|
isSelecting: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
isPinned: Boolean,
|
||||||
|
isHidden: Boolean = false,
|
||||||
|
highlightBackground: Color,
|
||||||
|
highlightForeground: Color,
|
||||||
|
onCardClick: () -> Unit,
|
||||||
|
onToggleSelection: () -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onTogglePin: () -> Unit,
|
||||||
|
onToggleHide: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||||
|
val srcIcon = sourceIcon(session.source)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("session_card_${session.id}")
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onCardClick,
|
||||||
|
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||||
|
),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
border =
|
||||||
|
if (isActive && !isSelecting) {
|
||||||
|
BorderStroke(2.dp, statusColors.success)
|
||||||
|
} else if (isSelected) {
|
||||||
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(spacing.md),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (isSelecting) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { onToggleSelection() },
|
||||||
|
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFork && !isSelecting) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.CallSplit,
|
||||||
|
contentDescription = stringResource(R.string.sessions_fork_indicator),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
if (forkDepth > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = forkDepth.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srcIcon != null && !isSelecting) {
|
||||||
|
Icon(
|
||||||
|
imageVector = srcIcon,
|
||||||
|
contentDescription = sourceLabel(session.source),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
highlightText(displayTitle, query, highlightBackground, highlightForeground)
|
||||||
|
} else {
|
||||||
|
AnnotatedString(displayTitle)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (isPinned) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PushPin,
|
||||||
|
contentDescription = stringResource(R.string.sessions_pinned_indicator),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (session.hidden == true) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.sessions_hidden_badge),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!session.status.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = session.status,
|
||||||
|
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionActionMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismiss = { menuExpanded = false },
|
||||||
|
isDeleting = isDeleting,
|
||||||
|
isPinned = isPinned,
|
||||||
|
onTogglePin = {
|
||||||
|
menuExpanded = false
|
||||||
|
onTogglePin()
|
||||||
|
},
|
||||||
|
isHidden = isHidden,
|
||||||
|
onToggleHide = {
|
||||||
|
menuExpanded = false
|
||||||
|
onToggleHide()
|
||||||
|
},
|
||||||
|
onSelect = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSelect()
|
||||||
|
},
|
||||||
|
onRename = {
|
||||||
|
menuExpanded = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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),
|
DISABLED(R.string.skills_status_disabled),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class CategoryFilter {
|
|
||||||
ALL,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SkillsUiState(
|
data class SkillsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val skills: List<Skill> = emptyList(),
|
val skills: List<Skill> = emptyList(),
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.HubSkill
|
||||||
|
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||||
|
import com.m57.hermescontrol.ui.common.DetailRow
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.ui.skills.SkillsUiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HubBrowseView(
|
||||||
|
state: SkillsUiState,
|
||||||
|
hubQuery: String,
|
||||||
|
onHubQueryChange: (String) -> Unit,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onClearSearch: () -> Unit,
|
||||||
|
onInstall: (String) -> Unit,
|
||||||
|
onPreviewHubSkill: (String) -> Unit,
|
||||||
|
onScanHubSkill: (String) -> Unit,
|
||||||
|
onClearHubScan: () -> Unit,
|
||||||
|
onLoadHubSources: () -> Unit,
|
||||||
|
isInstalling: Boolean,
|
||||||
|
installingSkillName: String?,
|
||||||
|
) {
|
||||||
|
var hubShowDetail by remember { mutableStateOf<HubSkill?>(null) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hubQuery,
|
||||||
|
onValueChange = onHubQueryChange,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
|
||||||
|
trailingIcon = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (hubQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onHubQueryChange("")
|
||||||
|
onClearSearch()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { onSearch(hubQuery) },
|
||||||
|
enabled = hubQuery.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = stringResource(R.string.skills_hub_search_button),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
if (hubQuery.isNotBlank()) {
|
||||||
|
onSearch(hubQuery)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isHubSearching -> {
|
||||||
|
SkeletonListState()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.hubSearchError != null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.hubSearchError,
|
||||||
|
onRetry = { onSearch(hubQuery) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
|
||||||
|
EmptyState(
|
||||||
|
icon = Icons.Filled.Extension,
|
||||||
|
title = stringResource(R.string.skills_hub_empty_results),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.hubResults.isNotEmpty() -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
state.hubResults,
|
||||||
|
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
|
||||||
|
) { _, hubSkill ->
|
||||||
|
HubSkillCard(
|
||||||
|
hubSkill = hubSkill,
|
||||||
|
onInstall = { onInstall(hubSkill.name) },
|
||||||
|
isInstalling = isInstalling && installingSkillName == hubSkill.name,
|
||||||
|
onClick = {
|
||||||
|
hubShowDetail = hubSkill
|
||||||
|
hubSkill.identifier?.let { onPreviewHubSkill(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
HubLandingView(
|
||||||
|
state = state,
|
||||||
|
onLoadHubSources = onLoadHubSources,
|
||||||
|
onInstall = onInstall,
|
||||||
|
isInstalling = isInstalling,
|
||||||
|
installingSkillName = installingSkillName,
|
||||||
|
onOpenDetail = { skill ->
|
||||||
|
hubShowDetail = skill
|
||||||
|
skill.identifier?.let { onPreviewHubSkill(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hubShowDetail?.let { hubSkill ->
|
||||||
|
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
|
||||||
|
val fullContent =
|
||||||
|
if (previewReady) {
|
||||||
|
state.hubPreviewContent ?: hubSkill.description
|
||||||
|
} else {
|
||||||
|
hubSkill.description
|
||||||
|
}
|
||||||
|
DetailDialog(
|
||||||
|
title = hubSkill.name,
|
||||||
|
rows =
|
||||||
|
listOf(
|
||||||
|
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
|
||||||
|
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
|
||||||
|
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
|
||||||
|
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
|
||||||
|
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
|
||||||
|
),
|
||||||
|
actions = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
if (previewReady && state.isHubPreviewing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
hubSkill.identifier?.let { identifier ->
|
||||||
|
SkillScanSection(
|
||||||
|
scanResult =
|
||||||
|
state.hubScanResult.takeIf {
|
||||||
|
state.hubScanIdentifier == identifier
|
||||||
|
},
|
||||||
|
isScanning =
|
||||||
|
state.isHubScanning &&
|
||||||
|
state.hubScanIdentifier == identifier,
|
||||||
|
scanError =
|
||||||
|
state.hubScanError.takeIf {
|
||||||
|
state.hubScanIdentifier == identifier
|
||||||
|
},
|
||||||
|
onScan = { onScanHubSkill(identifier) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
hubShowDetail = null
|
||||||
|
onClearHubScan()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HubLandingView(
|
||||||
|
state: SkillsUiState,
|
||||||
|
onLoadHubSources: () -> Unit,
|
||||||
|
onInstall: (String) -> Unit,
|
||||||
|
isInstalling: Boolean,
|
||||||
|
installingSkillName: String?,
|
||||||
|
onOpenDetail: (HubSkill) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isHubSourcesLoading -> {
|
||||||
|
SkeletonListState()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.hubSourcesError != null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.hubSourcesError,
|
||||||
|
onRetry = onLoadHubSources,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> {
|
||||||
|
EmptyState(
|
||||||
|
icon = Icons.Filled.Extension,
|
||||||
|
title = stringResource(R.string.skills_hub_landing_hint),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
if (state.hubSources.isNotEmpty()) {
|
||||||
|
item(key = "hub_sources_header") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.skills_hub_sources_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
state.hubSources.forEach { source ->
|
||||||
|
SourceChip(source = source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hubFeatured.isNotEmpty()) {
|
||||||
|
item(key = "hub_featured_header") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.skills_hub_featured_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.skills_hub_featured_subtitle),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
state.hubFeatured,
|
||||||
|
key = { index, hubSkill ->
|
||||||
|
"featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index"
|
||||||
|
},
|
||||||
|
) { _, hubSkill ->
|
||||||
|
HubSkillCard(
|
||||||
|
hubSkill = hubSkill,
|
||||||
|
onInstall = { onInstall(hubSkill.name) },
|
||||||
|
isInstalling =
|
||||||
|
isInstalling && installingSkillName == hubSkill.name,
|
||||||
|
onClick = { onOpenDetail(hubSkill) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.HubSkill
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HubSkillCard(
|
||||||
|
hubSkill: HubSkill,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
isInstalling: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// ── Top row: name ──
|
||||||
|
Text(
|
||||||
|
text = hubSkill.name,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Category ──
|
||||||
|
hubSkill.category?.let { cat ->
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = cat,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Description ──
|
||||||
|
hubSkill.description?.let { desc ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = desc,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source badge + Install button ──
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
hubSkill.source?.let { source ->
|
||||||
|
SourceBadge(source = source)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = onInstall,
|
||||||
|
enabled = !isInstalling,
|
||||||
|
) {
|
||||||
|
if (isInstalling) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CloudDownload,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.skills_hub_install))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
|
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.FilterChipRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SearchBar
|
||||||
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||||
|
import com.m57.hermescontrol.ui.skills.CATEGORY_ALL
|
||||||
|
import com.m57.hermescontrol.ui.skills.SkillFilter
|
||||||
|
import com.m57.hermescontrol.ui.skills.SkillListFilter
|
||||||
|
import com.m57.hermescontrol.ui.skills.SkillsUiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InstalledSkillsView(
|
||||||
|
state: SkillsUiState,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
selectedStatus: SkillFilter,
|
||||||
|
onStatusChange: (SkillFilter) -> Unit,
|
||||||
|
selectedCategory: String?,
|
||||||
|
onCategoryChange: (String) -> Unit,
|
||||||
|
onToggle: (Skill) -> Unit,
|
||||||
|
onEdit: (String) -> Unit,
|
||||||
|
onUninstall: (String) -> Unit,
|
||||||
|
onSetSourceFilter: (String?) -> Unit,
|
||||||
|
sourceFilter: String?,
|
||||||
|
isUninstalling: Boolean,
|
||||||
|
uninstallingSkillName: String?,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onBrowseHub: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showDetail by remember { mutableStateOf<Skill?>(null) }
|
||||||
|
|
||||||
|
val categories =
|
||||||
|
remember(state.skills) {
|
||||||
|
SkillListFilter.extractCategories(state.skills)
|
||||||
|
}
|
||||||
|
val sources =
|
||||||
|
remember(state.skills) {
|
||||||
|
SkillListFilter.extractSources(state.skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredSkills =
|
||||||
|
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = state.skills,
|
||||||
|
query = query,
|
||||||
|
selectedStatus = selectedStatus,
|
||||||
|
selectedCategory = selectedCategory,
|
||||||
|
sourceFilter = sourceFilter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SearchBar(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
placeholder = stringResource(R.string.skills_search_placeholder),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
FilterChipRow(
|
||||||
|
chips = SkillFilter.entries.toList(),
|
||||||
|
selectedChip = selectedStatus,
|
||||||
|
onChipSelected = onStatusChange,
|
||||||
|
chipLabel = { chip ->
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (chip == SkillFilter.ALL_STATUSES) {
|
||||||
|
stringResource(R.string.skills_category_all)
|
||||||
|
} else {
|
||||||
|
stringResource(chip.labelRes)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (categories.isNotEmpty() || sources.isNotEmpty()) {
|
||||||
|
FilterChipRow(
|
||||||
|
chips = listOf(stringResource(R.string.skills_category_all)) + categories,
|
||||||
|
selectedChip = selectedCategory,
|
||||||
|
onChipSelected = onCategoryChange,
|
||||||
|
)
|
||||||
|
if (sources.isNotEmpty()) {
|
||||||
|
FilterChipRow(
|
||||||
|
chips = listOf<String?>(null) + sources,
|
||||||
|
selectedChip = sourceFilter,
|
||||||
|
onChipSelected = onSetSourceFilter,
|
||||||
|
chipLabel = { source ->
|
||||||
|
Text(
|
||||||
|
text = source ?: stringResource(R.string.skills_source_all),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
SkeletonListState()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage != null -> {
|
||||||
|
ErrorState(
|
||||||
|
message = state.errorMessage,
|
||||||
|
onRetry = onRefresh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredSkills.isEmpty() -> {
|
||||||
|
EmptyState(
|
||||||
|
icon = Icons.Filled.Extension,
|
||||||
|
title = stringResource(R.string.skills_empty_message),
|
||||||
|
actionLabel = stringResource(R.string.empty_action_browse_hub),
|
||||||
|
onAction = onBrowseHub,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
filteredSkills,
|
||||||
|
key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" },
|
||||||
|
) { _, skill ->
|
||||||
|
SkillCard(
|
||||||
|
skill = skill,
|
||||||
|
onToggle = { onToggle(skill) },
|
||||||
|
onAction = { onEdit(skill.name) },
|
||||||
|
onUninstall =
|
||||||
|
if (skill.source == "hub") {
|
||||||
|
{ onUninstall(skill.name) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
|
||||||
|
onClick = { showDetail = skill },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDetail?.let { skill ->
|
||||||
|
DetailDialog(
|
||||||
|
title = skill.name,
|
||||||
|
rows = skill.toDetailRows(),
|
||||||
|
onDismiss = { showDetail = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkillCard(
|
||||||
|
skill: Skill,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onAction: () -> Unit,
|
||||||
|
onUninstall: (() -> Unit)?,
|
||||||
|
isUninstalling: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// ── Top row: name + source badge + toggle ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = skill.name,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
skill.source?.let { source ->
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
SourceBadge(source = source)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = skill.enabled,
|
||||||
|
onCheckedChange = { onToggle() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category ──
|
||||||
|
skill.category?.let { cat ->
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = cat,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Description ──
|
||||||
|
skill.description?.let { desc ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = desc,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action buttons row ──
|
||||||
|
if (onUninstall != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onAction) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_edit_skill),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onUninstall,
|
||||||
|
enabled = !isUninstalling,
|
||||||
|
) {
|
||||||
|
if (isUninstalling) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CloudOff,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_uninstall_skill),
|
||||||
|
tint = statusColors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onAction) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_edit_skill),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkillPreviewDialog(
|
||||||
|
skillName: String,
|
||||||
|
isLoading: Boolean,
|
||||||
|
content: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
HermesScaffold(
|
||||||
|
title = { Text(stringResource(R.string.skills_preview_title, skillName)) },
|
||||||
|
navigationIcon = NavIcon.Back(onBack = onDismiss),
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
LoadingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
content != null -> {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
EmptyState(
|
||||||
|
icon = Icons.Filled.Extension,
|
||||||
|
title = stringResource(R.string.skills_preview_empty),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Shield
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.SkillScanFinding
|
||||||
|
import com.m57.hermescontrol.data.model.SkillScanResponse
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.skills.SkillScanPresentation
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkillScanSection(
|
||||||
|
scanResult: SkillScanResponse?,
|
||||||
|
isScanning: Boolean,
|
||||||
|
scanError: String?,
|
||||||
|
onScan: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onScan,
|
||||||
|
enabled = !isScanning,
|
||||||
|
) {
|
||||||
|
if (isScanning) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Shield,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.skills_hub_scan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
isScanning -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
scanError != null -> {
|
||||||
|
Surface(
|
||||||
|
color = LocalHermesStatusColors.current.errorContainer,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = scanError,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanResult != null -> {
|
||||||
|
ScanResultCard(result = scanResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScanResultCard(result: SkillScanResponse) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors)
|
||||||
|
val policyLabel =
|
||||||
|
SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) }
|
||||||
|
?: result.policy.orEmpty()
|
||||||
|
val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// ── Verdict header ──
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Shield,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = verdictColor,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = verdictColor,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
color = policyColor.copy(alpha = 0.15f),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = policyLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = policyColor,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trust + finding count ──
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (!result.trustLevel.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = result.trustLevel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.skills_hub_scan_findings_count,
|
||||||
|
result.findings.size,
|
||||||
|
result.findings.size,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Severity tally ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
listOf("critical", "high", "medium", "low").forEach { sev ->
|
||||||
|
val count = result.severityCounts[sev] ?: 0
|
||||||
|
if (count > 0) {
|
||||||
|
val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors)
|
||||||
|
Surface(
|
||||||
|
color = sevColor.copy(alpha = 0.15f),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$count $sev",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = sevColor,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.findings.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.skills_hub_scan_no_findings),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusColors.success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Policy reason ──
|
||||||
|
if (!result.policyReason.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = result.policyReason,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Findings ──
|
||||||
|
if (result.findings.isNotEmpty()) {
|
||||||
|
HorizontalDivider()
|
||||||
|
result.findings.forEach { finding ->
|
||||||
|
ScanFindingRow(finding = finding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScanFindingRow(finding: SkillScanFinding) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = severityColor.copy(alpha = 0.15f),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = finding.severity,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = severityColor,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = finding.category,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
val fileLocation =
|
||||||
|
buildString {
|
||||||
|
finding.file?.let { append(it) }
|
||||||
|
finding.line?.let { append(":").append(it) }
|
||||||
|
}
|
||||||
|
if (fileLocation.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = fileLocation,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finding.description?.let { description ->
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.SkillHubSource
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourceChip(source: SkillHubSource) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val label =
|
||||||
|
buildString {
|
||||||
|
append(source.label ?: source.id)
|
||||||
|
when {
|
||||||
|
source.rateLimited == true -> {
|
||||||
|
append(" · ")
|
||||||
|
append(stringResource(R.string.skills_hub_source_rate_limited))
|
||||||
|
}
|
||||||
|
|
||||||
|
source.available == false -> {
|
||||||
|
append(" · ")
|
||||||
|
append(stringResource(R.string.skills_hub_source_offline))
|
||||||
|
}
|
||||||
|
|
||||||
|
source.searchable == false -> {
|
||||||
|
append(" · ")
|
||||||
|
append(stringResource(R.string.skills_hub_source_via_index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val color =
|
||||||
|
when {
|
||||||
|
source.rateLimited == true -> statusColors.warning
|
||||||
|
source.available == false -> statusColors.error
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = color.copy(alpha = 0.12f),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = color,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val ActionButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActionButtonContent(
|
||||||
|
icon: ImageVector?,
|
||||||
|
text: String,
|
||||||
|
iconTint: Color = LocalContentColor.current,
|
||||||
|
textColor: Color = Color.Unspecified,
|
||||||
|
loading: Boolean = false,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else if (icon != null) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = textColor,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.actionLogSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.system_action_log_title),
|
||||||
|
trailing = {
|
||||||
|
TextButton(onClick = { viewModel.closeActionLog() }) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Action title
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.activeAction ?: "",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
val actionLog = state.actionLog
|
||||||
|
if (actionLog != null) {
|
||||||
|
if (actionLog.running == true) {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_action_log_running),
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_action_log_done),
|
||||||
|
status = StatusBadgeType.SUCCESS,
|
||||||
|
)
|
||||||
|
actionLog.exit_code?.let { code ->
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_action_log_exit, code),
|
||||||
|
status =
|
||||||
|
if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_action_log_starting),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output lines
|
||||||
|
state.actionLog?.lines?.let { lines ->
|
||||||
|
if (lines.isNotEmpty()) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.sm)) {
|
||||||
|
lines.take(20).forEach { line ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (lines.size > 20) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_more_lines, lines.size - 20),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.InfoRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.util.formatBytes
|
||||||
|
|
||||||
|
fun LazyListScope.checkpointsSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
onPruneConfirm: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
state.checkpoints?.let { checkpoints ->
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_checkpoints))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
val sessionsCount = checkpoints.sessions?.size ?: 0
|
||||||
|
val totalBytes = checkpoints.total_bytes ?: 0L
|
||||||
|
InfoRow(
|
||||||
|
label = "",
|
||||||
|
value =
|
||||||
|
stringResource(
|
||||||
|
R.string.system_checkpoints_summary,
|
||||||
|
sessionsCount,
|
||||||
|
formatBytes(totalBytes),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Button(
|
||||||
|
onClick = { onPruneConfirm(true) },
|
||||||
|
enabled = sessionsCount > 0,
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.credentialsSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
onRemoveRequest: (Pair<String, Int>) -> Unit,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.system_sec_credentials),
|
||||||
|
trailing = {
|
||||||
|
val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true }
|
||||||
|
if (hasCreds) {
|
||||||
|
Text(
|
||||||
|
text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Add credential form
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.credProvider,
|
||||||
|
onValueChange = viewModel::updateCredProvider,
|
||||||
|
label = { Text(stringResource(R.string.system_credentials_provider)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.credKey,
|
||||||
|
onValueChange = viewModel::updateCredKey,
|
||||||
|
label = { Text(stringResource(R.string.system_credentials_api_key)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.credLabel,
|
||||||
|
onValueChange = viewModel::updateCredLabel,
|
||||||
|
label = { Text(stringResource(R.string.system_credentials_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.addCredential() },
|
||||||
|
enabled = !state.addingCred && state.credKey.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.addingCred) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.sm))
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
|
||||||
|
// Provider list with entries
|
||||||
|
if (state.credentials.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_credentials_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = spacing.sm),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.credentials.forEach { provider ->
|
||||||
|
Text(
|
||||||
|
text = provider.provider ?: "?",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(vertical = spacing.xs),
|
||||||
|
)
|
||||||
|
provider.entries?.forEach { entry ->
|
||||||
|
CredentialEntryRow(
|
||||||
|
entry = entry,
|
||||||
|
providerName = provider.provider ?: "",
|
||||||
|
onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) },
|
||||||
|
spacing = spacing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.InfoRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.curatorSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
) {
|
||||||
|
state.curator?.let { curator ->
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_curator))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Status
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
val statusText: String
|
||||||
|
val statusType: StatusBadgeType
|
||||||
|
when {
|
||||||
|
curator.enabled != true -> {
|
||||||
|
statusText = stringResource(R.string.system_status_disabled)
|
||||||
|
statusType = StatusBadgeType.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
curator.paused == true -> {
|
||||||
|
statusText = stringResource(R.string.system_status_paused)
|
||||||
|
statusType = StatusBadgeType.WARNING
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
statusText = stringResource(R.string.system_status_active)
|
||||||
|
statusType = StatusBadgeType.SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StatusBadge(text = statusText, status = statusType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interval info
|
||||||
|
curator.interval_hours?.let { hrs ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_curator_interval, hrs),
|
||||||
|
value = "",
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last run
|
||||||
|
curator.last_run_at?.let { lastRun ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_curator_last_run, lastRun),
|
||||||
|
value = "",
|
||||||
|
)
|
||||||
|
} ?: InfoRow(
|
||||||
|
label = stringResource(R.string.system_curator_never_run),
|
||||||
|
value = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
if (curator.enabled == true) {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.toggleCuratorPaused() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
if (curator.paused == true) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.PlayArrow,
|
||||||
|
text = stringResource(R.string.system_curator_resume),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Pause,
|
||||||
|
text = stringResource(R.string.system_curator_pause),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.runCuratorNow() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Refresh,
|
||||||
|
text = stringResource(R.string.system_curator_run_now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Devices
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.InfoRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.gatewaySection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
) {
|
||||||
|
state.status?.let { status ->
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_gateway))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Status badge + version
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
val isRunning = status.gateway_running == true
|
||||||
|
StatusBadge(
|
||||||
|
text =
|
||||||
|
if (isRunning) {
|
||||||
|
stringResource(R.string.system_status_running)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.system_status_stopped)
|
||||||
|
},
|
||||||
|
status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version · Sessions · PID
|
||||||
|
status.version?.let { ver ->
|
||||||
|
val parts = mutableListOf(ver)
|
||||||
|
status.active_sessions?.let { parts.add("$it session(s)") }
|
||||||
|
state.doctorReport?.let { report ->
|
||||||
|
if (report.ok && report.pid != null) {
|
||||||
|
parts.add("pid ${report.pid}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_hermes_version),
|
||||||
|
value = parts.joinToString(" · "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth required
|
||||||
|
status.auth_required?.let { auth ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_auth_required),
|
||||||
|
value = if (auth) "yes" else "no",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active platforms
|
||||||
|
status.gateway_platforms?.let { platforms ->
|
||||||
|
if (platforms.isNotEmpty()) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
platforms.forEach { (name, platform) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Devices,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
platform?.state?.let { platformState ->
|
||||||
|
StatusBadge(
|
||||||
|
text = platformState,
|
||||||
|
status =
|
||||||
|
when (platformState.lowercase()) {
|
||||||
|
"connected", "running", "ready" -> StatusBadgeType.SUCCESS
|
||||||
|
"error", "disconnected", "failed" -> StatusBadgeType.ERROR
|
||||||
|
else -> StatusBadgeType.NEUTRAL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
val isRunning = status.gateway_running == true
|
||||||
|
if (!isRunning) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.startGateway() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.PlayArrow,
|
||||||
|
text = stringResource(R.string.system_gateway_start),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRunning) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.restartGateway() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.RestartAlt,
|
||||||
|
text = stringResource(R.string.system_gateway_restart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.stopGateway() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Stop,
|
||||||
|
text = stringResource(R.string.system_gateway_stop),
|
||||||
|
iconTint = MaterialTheme.colorScheme.error,
|
||||||
|
textColor = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HookCreateDialog(
|
||||||
|
isOpen: Boolean,
|
||||||
|
event: String,
|
||||||
|
command: String,
|
||||||
|
matcher: String,
|
||||||
|
timeout: String,
|
||||||
|
approve: Boolean,
|
||||||
|
isCreating: Boolean,
|
||||||
|
onEventChange: (String) -> Unit,
|
||||||
|
onCommandChange: (String) -> Unit,
|
||||||
|
onMatcherChange: (String) -> Unit,
|
||||||
|
onTimeoutChange: (String) -> Unit,
|
||||||
|
onApproveChange: (Boolean) -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!isOpen) return
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.system_hooks_modal_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = event,
|
||||||
|
onValueChange = onEventChange,
|
||||||
|
label = { Text(stringResource(R.string.system_hooks_field_event)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = command,
|
||||||
|
onValueChange = onCommandChange,
|
||||||
|
label = { Text(stringResource(R.string.system_hooks_field_command)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = matcher,
|
||||||
|
onValueChange = onMatcherChange,
|
||||||
|
label = { Text(stringResource(R.string.system_hooks_field_matcher)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = timeout,
|
||||||
|
onValueChange = onTimeoutChange,
|
||||||
|
label = { Text(stringResource(R.string.system_hooks_field_timeout)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Switch(
|
||||||
|
checked = approve,
|
||||||
|
onCheckedChange = onApproveChange,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_hooks_field_approve),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_hooks_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onCreate,
|
||||||
|
enabled = !isCreating && command.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (isCreating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.sm))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.system_hooks_create))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.system_confirm_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Memory
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Speed
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.Update
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.InfoRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatCard
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
import com.m57.hermescontrol.util.formatBytes
|
||||||
|
import com.m57.hermescontrol.util.formatDuration
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
fun LazyListScope.hostSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
) {
|
||||||
|
state.stats?.let { stats ->
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_host))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// OS, Arch, Hostname
|
||||||
|
stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) }
|
||||||
|
stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) }
|
||||||
|
stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) }
|
||||||
|
stats.python_version?.let { python ->
|
||||||
|
val impl = stats.python_impl?.let { " ($it)" } ?: ""
|
||||||
|
InfoRow(stringResource(R.string.system_label_python), "$python$impl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hermes version + update badge
|
||||||
|
stats.hermes_version?.let { ver ->
|
||||||
|
val updateBadge =
|
||||||
|
state.updateInfo?.let { info ->
|
||||||
|
when {
|
||||||
|
info.update_available == true && info.behind != null && info.behind > 0 -> {
|
||||||
|
" (${stringResource(
|
||||||
|
R.string.system_version_update_available,
|
||||||
|
)}: ${stringResource(R.string.system_version_behind, info.behind)})"
|
||||||
|
}
|
||||||
|
|
||||||
|
info.update_available == true -> {
|
||||||
|
" (${stringResource(
|
||||||
|
R.string.system_version_update_available,
|
||||||
|
)})"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
" (${stringResource(R.string.system_version_latest)})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: ""
|
||||||
|
InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = spacing.sm),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.checkForUpdate(false) },
|
||||||
|
enabled = !state.checkingUpdate,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
if (state.checkingUpdate) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = null,
|
||||||
|
text = stringResource(R.string.system_action_check_updates),
|
||||||
|
loading = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Update,
|
||||||
|
text = stringResource(R.string.system_action_check_updates),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.updateInfo?.let { info ->
|
||||||
|
if (info.update_available == true && info.can_apply == true) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.openUpdateConfirm() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.PlayArrow,
|
||||||
|
text = stringResource(R.string.system_action_update_now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
|
||||||
|
// CPU / Memory / Disk stat cards
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
stats.cpu_percent?.let { cpu ->
|
||||||
|
StatCard(
|
||||||
|
label = stringResource(R.string.system_label_cpu),
|
||||||
|
value = "${cpu.toInt()}%",
|
||||||
|
icon = Icons.Filled.Speed,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stats.memory?.percent?.let { mem ->
|
||||||
|
StatCard(
|
||||||
|
label = stringResource(R.string.system_label_memory),
|
||||||
|
value = "${mem.toInt()}%",
|
||||||
|
icon = Icons.Filled.Memory,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stats.disk?.percent?.let { disk ->
|
||||||
|
StatCard(
|
||||||
|
label = stringResource(R.string.system_label_disk),
|
||||||
|
value = "${disk.toInt()}%",
|
||||||
|
icon = Icons.Filled.Storage,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory details
|
||||||
|
stats.memory?.let { mem ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_memory),
|
||||||
|
value =
|
||||||
|
stringResource(
|
||||||
|
R.string.system_label_used_total_percent,
|
||||||
|
formatBytes(mem.used ?: 0L),
|
||||||
|
formatBytes(mem.total ?: 0L),
|
||||||
|
"${(mem.percent ?: 0.0).toInt()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk details
|
||||||
|
stats.disk?.let { disk ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_disk),
|
||||||
|
value =
|
||||||
|
stringResource(
|
||||||
|
R.string.system_label_used_total_percent,
|
||||||
|
formatBytes(disk.used ?: 0L),
|
||||||
|
formatBytes(disk.total ?: 0L),
|
||||||
|
"${(disk.percent ?: 0.0).toInt()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
stats.uptime_seconds?.let { secs ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_uptime),
|
||||||
|
value = formatDuration(secs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load avg
|
||||||
|
stats.load_avg?.let { loads ->
|
||||||
|
if (loads.isNotEmpty()) {
|
||||||
|
val coresText =
|
||||||
|
stats.cpu_count?.let {
|
||||||
|
" ($it ${stringResource(
|
||||||
|
R.string.system_label_cores,
|
||||||
|
)})"
|
||||||
|
} ?: ""
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_load_avg),
|
||||||
|
value = loads.joinToString(", ") { "%.2f".format(it) } + coresText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: stats.cpu_count?.let { cores ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_label_cpu),
|
||||||
|
value = "$cores ${stringResource(R.string.system_label_cores)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// psutil warning
|
||||||
|
if (stats.psutil == false) {
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_psutil_warning),
|
||||||
|
status = StatusBadgeType.WARNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Backup
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.operationsSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
onImportConfirm: (Boolean) -> Unit,
|
||||||
|
onImportPick: () -> Unit,
|
||||||
|
downloadBackup: () -> Unit,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_operations))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Operation buttons row 1
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.loadAll() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.HealthAndSafety,
|
||||||
|
text = stringResource(R.string.system_op_doctor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.runSecurityAudit() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.VerifiedUser,
|
||||||
|
text = stringResource(R.string.system_op_security_audit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.runUpdateSkills() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Refresh,
|
||||||
|
text = stringResource(R.string.system_op_update_skills),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
// Operation buttons row 2
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.runPromptSize() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.HealthAndSafety,
|
||||||
|
text = stringResource(R.string.system_op_prompt_size),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.runDump() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Build,
|
||||||
|
text = stringResource(R.string.system_op_dump),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.runConfigMigrate() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Build,
|
||||||
|
text = stringResource(R.string.system_op_config_migrate),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
|
||||||
|
// Backup section
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_sec_backup),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.triggerBackup() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
ActionButtonContent(
|
||||||
|
icon = Icons.Filled.Backup,
|
||||||
|
text = stringResource(R.string.system_op_backup_create),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = downloadBackup,
|
||||||
|
enabled = state.backupArchive != null && !state.isDownloading,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
if (state.isDownloading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CloudDownload,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_op_backup_download),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore from picked archive (issue #786: SAF picker → upload)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_sec_restore),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onImportPick,
|
||||||
|
enabled = !state.isImporting,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = ActionButtonPadding,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_op_restore_pick),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.importFileName?.let { name ->
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Description,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onImportConfirm(true) },
|
||||||
|
enabled = state.importFileName != null && !state.isImporting,
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.isImporting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onError,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
|
||||||
|
// Debug share section
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_debug_share))
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_debug_share_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = state.shareRedact,
|
||||||
|
onCheckedChange = { viewModel.toggleShareRedact() },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.sm))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_debug_share_redact),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = spacing.xs),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.runDebugShare() },
|
||||||
|
enabled = !state.sharing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.sharing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(spacing.sm))
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (state.sharing) {
|
||||||
|
stringResource(R.string.system_debug_share_uploading)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.system_debug_share_generate)
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug share results
|
||||||
|
state.debugShare?.let { share ->
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
StatusBadge(
|
||||||
|
text =
|
||||||
|
if (share.ok == true) {
|
||||||
|
stringResource(R.string.system_debug_share_uploaded)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.system_debug_share_failed)
|
||||||
|
},
|
||||||
|
status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
share.redacted?.let { redacted ->
|
||||||
|
StatusBadge(
|
||||||
|
text =
|
||||||
|
if (redacted) {
|
||||||
|
stringResource(R.string.system_debug_share_redacted)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.system_debug_share_not_redacted)
|
||||||
|
},
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
share.auto_delete_seconds?.let { secs ->
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
share.urls?.forEach { (name, url) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$name: $url",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
clipboardManager.setText(AnnotatedString(url))
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = stringResource(R.string.system_debug_share_copy_all),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Link
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.UriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.InfoRow
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
|
||||||
|
fun LazyListScope.portalSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
uriHandler: UriHandler,
|
||||||
|
) {
|
||||||
|
state.portal?.let { portal ->
|
||||||
|
item {
|
||||||
|
SectionHeader(title = stringResource(R.string.system_sec_portal))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(spacing.md)) {
|
||||||
|
// Login status
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
|
) {
|
||||||
|
val loggedIn = portal.logged_in == true
|
||||||
|
StatusBadge(
|
||||||
|
text =
|
||||||
|
if (loggedIn) {
|
||||||
|
stringResource(R.string.system_status_logged_in)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.system_status_not_logged_in)
|
||||||
|
},
|
||||||
|
status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
|
||||||
|
)
|
||||||
|
if (!loggedIn) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_portal_login_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
portal.provider?.let { provider ->
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.system_portal_provider, provider),
|
||||||
|
value = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manage subscription link
|
||||||
|
portal.subscription_url?.let { url ->
|
||||||
|
TextButton(
|
||||||
|
onClick = { uriHandler.openUri(url) },
|
||||||
|
modifier = Modifier.padding(top = spacing.xs),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.system_portal_manage_subscription),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature routing
|
||||||
|
portal.features?.let { features ->
|
||||||
|
if (features.isNotEmpty()) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||||
|
features.forEach { feature ->
|
||||||
|
InfoRow(
|
||||||
|
label = feature.label ?: stringResource(R.string.system_portal_feature_routing),
|
||||||
|
value = feature.state ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.m57.hermescontrol.ui.system.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||||
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||||
|
|
||||||
|
fun LazyListScope.shellHooksSection(
|
||||||
|
state: SystemUiState,
|
||||||
|
spacing: Spacing,
|
||||||
|
viewModel: SystemViewModel,
|
||||||
|
) {
|
||||||
|
val hooks = state.hooks?.hooks ?: emptyList()
|
||||||
|
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.system_sec_hooks),
|
||||||
|
trailing = {
|
||||||
|
FilledTonalButton(onClick = { viewModel.toggleHookModal() }) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.size(spacing.xs))
|
||||||
|
Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hooks.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.system_hooks_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(hooks, key = { "${it.event}:${it.command}" }) { hook ->
|
||||||
|
HookCard(hook = hook, spacing = spacing, onDelete = {
|
||||||
|
viewModel.deleteHook(hook.event ?: "", hook.command ?: "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a byte count into human-readable B, KB, MB, or GB.
|
||||||
|
*/
|
||||||
|
fun formatBytes(bytes: Long): String =
|
||||||
|
when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
|
||||||
|
else -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / (1024 * 1024 * 1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats seconds into a human-readable duration (e.g. "2d 4h 15m").
|
||||||
|
*/
|
||||||
|
fun formatDuration(totalSeconds: Double): String {
|
||||||
|
val days = (totalSeconds / 86400).toInt()
|
||||||
|
val hours = ((totalSeconds % 86400) / 3600).toInt()
|
||||||
|
val minutes = ((totalSeconds % 3600) / 60).toInt()
|
||||||
|
return buildString {
|
||||||
|
if (days > 0) append("${days}d ")
|
||||||
|
if (hours > 0 || days > 0) append("${hours}h ")
|
||||||
|
append("${minutes}m")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer
|
|||||||
import com.m57.hermescontrol.theme.StatusYellow
|
import com.m57.hermescontrol.theme.StatusYellow
|
||||||
import com.m57.hermescontrol.theme.StatusYellowContainer
|
import com.m57.hermescontrol.theme.StatusYellowContainer
|
||||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||||
|
import com.m57.hermescontrol.ui.chat.markdown.FnNote
|
||||||
|
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
||||||
|
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
||||||
|
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ViewModelExtTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestViewModel : ViewModel()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val vm = TestViewModel()
|
||||||
|
var started = false
|
||||||
|
var successResult: String? = null
|
||||||
|
var completed = false
|
||||||
|
|
||||||
|
vm.safeLaunchAction(
|
||||||
|
onStart = { started = true },
|
||||||
|
apiCall = { NetworkResult.Success("ok") },
|
||||||
|
onSuccess = { successResult = it },
|
||||||
|
onError = {},
|
||||||
|
onComplete = { completed = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(started)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("ok", successResult)
|
||||||
|
assertTrue(completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `safeLaunchAction triggers onError and onComplete on failure`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val vm = TestViewModel()
|
||||||
|
var errorMessage: String? = null
|
||||||
|
var completed = false
|
||||||
|
|
||||||
|
vm.safeLaunchAction(
|
||||||
|
apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) },
|
||||||
|
onSuccess = {},
|
||||||
|
onError = { errorMessage = it },
|
||||||
|
onComplete = { completed = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("network error", errorMessage)
|
||||||
|
assertTrue(completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AddServerRequestBuilderTest {
|
||||||
|
@Test
|
||||||
|
fun `build HTTP mode creates request with url and auth`() {
|
||||||
|
val request =
|
||||||
|
AddServerRequestBuilder.build(
|
||||||
|
name = " weather-server ",
|
||||||
|
mode = AddServerMode.HTTP,
|
||||||
|
url = " https://api.weather.com/mcp ",
|
||||||
|
command = "ignored-cmd",
|
||||||
|
args = "ignored-arg",
|
||||||
|
auth = "header",
|
||||||
|
bearerToken = " secret-token ",
|
||||||
|
)
|
||||||
|
assertEquals("weather-server", request.name)
|
||||||
|
assertEquals("https://api.weather.com/mcp", request.url)
|
||||||
|
assertNull(request.command)
|
||||||
|
assertNull(request.args)
|
||||||
|
assertEquals("header", request.auth)
|
||||||
|
assertEquals("secret-token", request.bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build HTTP mode with none auth nulls auth and bearerToken`() {
|
||||||
|
val request =
|
||||||
|
AddServerRequestBuilder.build(
|
||||||
|
name = "public-server",
|
||||||
|
mode = AddServerMode.HTTP,
|
||||||
|
url = "https://example.com/mcp",
|
||||||
|
command = "",
|
||||||
|
args = "",
|
||||||
|
auth = "none",
|
||||||
|
bearerToken = "should-be-ignored",
|
||||||
|
)
|
||||||
|
assertEquals("public-server", request.name)
|
||||||
|
assertEquals("https://example.com/mcp", request.url)
|
||||||
|
assertNull(request.auth)
|
||||||
|
assertNull(request.bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build Stdio mode creates request with command and parsed args`() {
|
||||||
|
val request =
|
||||||
|
AddServerRequestBuilder.build(
|
||||||
|
name = "local-tools",
|
||||||
|
mode = AddServerMode.Stdio,
|
||||||
|
url = "https://ignored.com",
|
||||||
|
command = " python3 ",
|
||||||
|
args = " -m mcp_server --debug ",
|
||||||
|
auth = "header",
|
||||||
|
bearerToken = "token",
|
||||||
|
)
|
||||||
|
assertEquals("local-tools", request.name)
|
||||||
|
assertNull(request.url)
|
||||||
|
assertEquals("python3", request.command)
|
||||||
|
assertEquals(listOf("-m", "mcp_server", "--debug"), request.args)
|
||||||
|
assertNull(request.auth)
|
||||||
|
assertNull(request.bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build Stdio mode with empty args nulls args`() {
|
||||||
|
val request =
|
||||||
|
AddServerRequestBuilder.build(
|
||||||
|
name = "npx-server",
|
||||||
|
mode = AddServerMode.Stdio,
|
||||||
|
url = "",
|
||||||
|
command = "npx server",
|
||||||
|
args = " ",
|
||||||
|
auth = "none",
|
||||||
|
bearerToken = "",
|
||||||
|
)
|
||||||
|
assertEquals("npx server", request.command)
|
||||||
|
assertNull(request.args)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build from McpServersUiState populates fields correctly`() {
|
||||||
|
val state =
|
||||||
|
McpServersUiState(
|
||||||
|
addServerName = "state-server",
|
||||||
|
addMode = AddServerMode.HTTP,
|
||||||
|
addServerUrl = "https://state.com",
|
||||||
|
addServerAuth = "none",
|
||||||
|
)
|
||||||
|
val request = AddServerRequestBuilder.build(state)
|
||||||
|
assertEquals("state-server", request.name)
|
||||||
|
assertEquals("https://state.com", request.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class McpImportSummaryTest {
|
||||||
|
@Test
|
||||||
|
fun `formatSummary returns success message when zero errors`() {
|
||||||
|
val result = McpImportSummary.formatSummary(3, emptyList())
|
||||||
|
assertEquals("Successfully imported 3 server(s)", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatSummary returns partial success message when some failed`() {
|
||||||
|
val result = McpImportSummary.formatSummary(2, listOf("serverA: timeout", "serverB: 404"))
|
||||||
|
assertEquals("Imported 2 server(s), 2 failed: serverA: timeout; serverB: 404", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatSummary returns failure message when all failed`() {
|
||||||
|
val result = McpImportSummary.formatSummary(0, listOf("serverA: connection refused"))
|
||||||
|
assertEquals("Failed to import: serverA: connection refused", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SessionSearchFormattingTest {
|
||||||
|
@Test
|
||||||
|
fun `clean search snippet strips highlight markers from plain text`() {
|
||||||
|
assertEquals(
|
||||||
|
"Find the deployment logs",
|
||||||
|
cleanSearchSnippet(">>>Find<<< the deployment logs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clean search snippet extracts text from JSON payload`() {
|
||||||
|
assertEquals(
|
||||||
|
"Find the deployment logs",
|
||||||
|
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clean search snippet extracts nested text array`() {
|
||||||
|
assertEquals(
|
||||||
|
"First part second part",
|
||||||
|
cleanSearchSnippet("{\"parts\":[\">>>First<<< part\",\"second part\"]}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clean search snippet normalizes whitespace`() {
|
||||||
|
assertEquals(
|
||||||
|
"hello world",
|
||||||
|
cleanSearchSnippet(" hello world \n "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toSessionInfo leaves title null for match layout`() {
|
||||||
|
val searchResult =
|
||||||
|
SessionSearchResult(
|
||||||
|
session_id = "test-123",
|
||||||
|
session_started = 1700000000.0,
|
||||||
|
snippet = "matched query excerpt",
|
||||||
|
source = "cli",
|
||||||
|
model = "test-model",
|
||||||
|
)
|
||||||
|
val info = searchResult.toSessionInfo()
|
||||||
|
assertEquals("test-123", info.id)
|
||||||
|
assertNull(info.title)
|
||||||
|
assertEquals("matched query excerpt", info.preview)
|
||||||
|
assertEquals("cli", info.source)
|
||||||
|
assertEquals("test-model", info.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatPlayedAt handles null and negative epoch`() {
|
||||||
|
assertNull(formatPlayedAt(null))
|
||||||
|
assertNull(formatPlayedAt(0.0))
|
||||||
|
assertNull(formatPlayedAt(-100.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatPlayedAt returns formatted string for valid timestamp`() {
|
||||||
|
val result = formatPlayedAt(1700000000.0)
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.m57.hermescontrol.ui.sessions
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SessionsPagingTest {
|
||||||
|
@Test
|
||||||
|
fun `resolveInitialPaging hasMore when received full page and total exceeds accumulated`() {
|
||||||
|
val resolution =
|
||||||
|
SessionsPaging.resolveInitialPaging(
|
||||||
|
receivedCount = 50,
|
||||||
|
pageSize = 50,
|
||||||
|
backendTotal = 120,
|
||||||
|
accumulatedCount = 50,
|
||||||
|
)
|
||||||
|
assertTrue(resolution.hasMore)
|
||||||
|
assertEquals(120, resolution.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveInitialPaging no more when received less than page size`() {
|
||||||
|
val resolution =
|
||||||
|
SessionsPaging.resolveInitialPaging(
|
||||||
|
receivedCount = 30,
|
||||||
|
pageSize = 50,
|
||||||
|
backendTotal = 30,
|
||||||
|
accumulatedCount = 30,
|
||||||
|
)
|
||||||
|
assertFalse(resolution.hasMore)
|
||||||
|
assertEquals(30, resolution.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveInitialPaging no more when backendTotal equals accumulated`() {
|
||||||
|
val resolution =
|
||||||
|
SessionsPaging.resolveInitialPaging(
|
||||||
|
receivedCount = 50,
|
||||||
|
pageSize = 50,
|
||||||
|
backendTotal = 50,
|
||||||
|
accumulatedCount = 50,
|
||||||
|
)
|
||||||
|
assertFalse(resolution.hasMore)
|
||||||
|
assertEquals(50, resolution.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveLoadMorePaging hasMore when full page added new items and total exceeds accumulated`() {
|
||||||
|
val resolution =
|
||||||
|
SessionsPaging.resolveLoadMorePaging(
|
||||||
|
receivedCount = 50,
|
||||||
|
pageSize = 50,
|
||||||
|
addedNewItems = true,
|
||||||
|
backendTotal = 150,
|
||||||
|
accumulatedCount = 100,
|
||||||
|
)
|
||||||
|
assertTrue(resolution.hasMore)
|
||||||
|
assertEquals(150, resolution.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveLoadMorePaging no more when no new items added`() {
|
||||||
|
val resolution =
|
||||||
|
SessionsPaging.resolveLoadMorePaging(
|
||||||
|
receivedCount = 50,
|
||||||
|
pageSize = 50,
|
||||||
|
addedNewItems = false,
|
||||||
|
backendTotal = 150,
|
||||||
|
accumulatedCount = 100,
|
||||||
|
)
|
||||||
|
assertFalse(resolution.hasMore)
|
||||||
|
assertEquals(100, resolution.total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,14 +92,6 @@ class SessionsViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `clean search snippet extracts text from JSON payload`() {
|
|
||||||
assertEquals(
|
|
||||||
"Find the deployment logs",
|
|
||||||
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sections request conversations excluding cron and automations from cron`() {
|
fun `sections request conversations excluding cron and automations from cron`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SkillListFilterTest {
|
||||||
|
private val sampleSkills =
|
||||||
|
listOf(
|
||||||
|
Skill(
|
||||||
|
name = "code-review",
|
||||||
|
category = "dev",
|
||||||
|
description = "Automated code review assistant",
|
||||||
|
enabled = true,
|
||||||
|
source = "built-in",
|
||||||
|
),
|
||||||
|
Skill(
|
||||||
|
name = "deploy-bot",
|
||||||
|
category = "ops",
|
||||||
|
description = "Deploy to production environments",
|
||||||
|
enabled = false,
|
||||||
|
source = "hub",
|
||||||
|
),
|
||||||
|
Skill(
|
||||||
|
name = "image-gen",
|
||||||
|
category = "creative",
|
||||||
|
description = "Generate creative assets",
|
||||||
|
enabled = true,
|
||||||
|
source = "hub",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterSkills empty query and all statuses returns all`() {
|
||||||
|
val result =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "",
|
||||||
|
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterSkills filters by search query on name and description`() {
|
||||||
|
val nameResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "code",
|
||||||
|
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(1, nameResult.size)
|
||||||
|
assertEquals("code-review", nameResult[0].name)
|
||||||
|
|
||||||
|
val descResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "production",
|
||||||
|
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(1, descResult.size)
|
||||||
|
assertEquals("deploy-bot", descResult[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterSkills filters by enabled and disabled status`() {
|
||||||
|
val enabledResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "",
|
||||||
|
selectedStatus = SkillFilter.ENABLED,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(2, enabledResult.size)
|
||||||
|
assertTrue(enabledResult.all { it.enabled })
|
||||||
|
|
||||||
|
val disabledResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "",
|
||||||
|
selectedStatus = SkillFilter.DISABLED,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(1, disabledResult.size)
|
||||||
|
assertEquals("deploy-bot", disabledResult[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterSkills filters by category and source`() {
|
||||||
|
val catResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "",
|
||||||
|
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||||
|
selectedCategory = "ops",
|
||||||
|
sourceFilter = null,
|
||||||
|
)
|
||||||
|
assertEquals(1, catResult.size)
|
||||||
|
assertEquals("deploy-bot", catResult[0].name)
|
||||||
|
|
||||||
|
val sourceResult =
|
||||||
|
SkillListFilter.filterSkills(
|
||||||
|
skills = sampleSkills,
|
||||||
|
query = "",
|
||||||
|
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||||
|
selectedCategory = CATEGORY_ALL,
|
||||||
|
sourceFilter = "built-in",
|
||||||
|
)
|
||||||
|
assertEquals(1, sourceResult.size)
|
||||||
|
assertEquals("code-review", sourceResult[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractCategories and extractSources return distinct sorted values`() {
|
||||||
|
val categories = SkillListFilter.extractCategories(sampleSkills)
|
||||||
|
assertEquals(listOf("creative", "dev", "ops"), categories)
|
||||||
|
|
||||||
|
val sources = SkillListFilter.extractSources(sampleSkills)
|
||||||
|
assertEquals(listOf("built-in", "hub"), sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.m57.hermescontrol.ui.skills
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SkillScanPresentationTest {
|
||||||
|
private val dummyColors =
|
||||||
|
HermesStatusColors(
|
||||||
|
success = Color(0xFF00FF00),
|
||||||
|
successContainer = Color(0xFF003300),
|
||||||
|
onSuccess = Color.White,
|
||||||
|
warning = Color(0xFFFFFF00),
|
||||||
|
warningContainer = Color(0xFF333300),
|
||||||
|
onWarning = Color.Black,
|
||||||
|
error = Color(0xFFFF0000),
|
||||||
|
errorContainer = Color(0xFF330000),
|
||||||
|
onError = Color.White,
|
||||||
|
info = Color(0xFF0000FF),
|
||||||
|
infoContainer = Color(0xFF000033),
|
||||||
|
onInfo = Color.White,
|
||||||
|
neutral = Color.Gray,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveVerdictKind maps safe dangerous and fallback`() {
|
||||||
|
assertEquals(ScanVerdictKind.SAFE, SkillScanPresentation.resolveVerdictKind("safe"))
|
||||||
|
assertEquals(ScanVerdictKind.DANGEROUS, SkillScanPresentation.resolveVerdictKind("dangerous"))
|
||||||
|
assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind("unknown"))
|
||||||
|
assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveVerdictColor returns corresponding semantic color`() {
|
||||||
|
assertEquals(dummyColors.success, SkillScanPresentation.resolveVerdictColor("safe", dummyColors))
|
||||||
|
assertEquals(dummyColors.error, SkillScanPresentation.resolveVerdictColor("dangerous", dummyColors))
|
||||||
|
assertEquals(dummyColors.warning, SkillScanPresentation.resolveVerdictColor("suspicious", dummyColors))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolvePolicyKind and labels map allow ask block and fallback`() {
|
||||||
|
assertEquals(ScanPolicyKind.ALLOW, SkillScanPresentation.resolvePolicyKind("allow"))
|
||||||
|
assertEquals(ScanPolicyKind.ASK, SkillScanPresentation.resolvePolicyKind("ask"))
|
||||||
|
assertEquals(ScanPolicyKind.BLOCK, SkillScanPresentation.resolvePolicyKind("block"))
|
||||||
|
assertEquals(ScanPolicyKind.OTHER, SkillScanPresentation.resolvePolicyKind("custom"))
|
||||||
|
|
||||||
|
assertEquals(R.string.skills_hub_scan_policy_allow, SkillScanPresentation.resolvePolicyLabelRes("allow"))
|
||||||
|
assertEquals(R.string.skills_hub_scan_policy_ask, SkillScanPresentation.resolvePolicyLabelRes("ask"))
|
||||||
|
assertEquals(R.string.skills_hub_scan_policy_block, SkillScanPresentation.resolvePolicyLabelRes("block"))
|
||||||
|
assertNull(SkillScanPresentation.resolvePolicyLabelRes("other"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveSeverityColor maps critical high medium low and fallback`() {
|
||||||
|
assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("critical", dummyColors))
|
||||||
|
assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("high", dummyColors))
|
||||||
|
assertEquals(dummyColors.warning, SkillScanPresentation.resolveSeverityColor("medium", dummyColors))
|
||||||
|
assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("low", dummyColors))
|
||||||
|
assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("info", dummyColors))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FormatUtilsTest {
|
||||||
|
@Test
|
||||||
|
fun testFormatBytes_bytes() {
|
||||||
|
assertEquals("0 B", formatBytes(0L))
|
||||||
|
assertEquals("500 B", formatBytes(500L))
|
||||||
|
assertEquals("1023 B", formatBytes(1023L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatBytes_kilobytes() {
|
||||||
|
assertEquals("1 KB", formatBytes(1024L))
|
||||||
|
assertEquals("2 KB", formatBytes(2048L))
|
||||||
|
assertEquals("1023 KB", formatBytes(1024 * 1024 - 1L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatBytes_megabytes() {
|
||||||
|
assertEquals("1 MB", formatBytes(1024 * 1024L))
|
||||||
|
assertEquals("500 MB", formatBytes(500 * 1024 * 1024L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatBytes_gigabytes() {
|
||||||
|
assertEquals("1.0 GB", formatBytes(1024 * 1024 * 1024L))
|
||||||
|
assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatDuration_minutes() {
|
||||||
|
assertEquals("0m", formatDuration(0.0))
|
||||||
|
assertEquals("0m", formatDuration(45.0))
|
||||||
|
assertEquals("1m", formatDuration(60.0))
|
||||||
|
assertEquals("59m", formatDuration(3540.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatDuration_hoursAndMinutes() {
|
||||||
|
assertEquals("1h 0m", formatDuration(3600.0))
|
||||||
|
assertEquals("1h 1m", formatDuration(3660.0))
|
||||||
|
assertEquals("23h 59m", formatDuration(86399.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatDuration_daysHoursMinutes() {
|
||||||
|
assertEquals("1d 0h 0m", formatDuration(86400.0))
|
||||||
|
assertEquals("1d 1h 1m", formatDuration(90060.0))
|
||||||
|
assertEquals("3d 5h 30m", formatDuration(3 * 86400.0 + 5 * 3600.0 + 30 * 60.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user