This commit is contained in:
20 files changed
+2361
-1841
No files matched your search
@@ -1,696 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ClipData
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.theme.DarkOnSurface
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LightOnSurface
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.onColorFor
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* The user-message bubble — the universal anchor in the full-bleed chat
|
||||
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
||||
* this bubble so the conversation stays scannable.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
message: ChatMessage,
|
||||
searchQuery: String = "",
|
||||
isCurrentMatch: Boolean = false,
|
||||
onOpenAttachment: (Attachment) -> Unit = {},
|
||||
onSaveAttachment: (Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showUserMessageTokens: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.80f
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter =
|
||||
fadeIn() +
|
||||
expandVertically(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
),
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
// Copy feedback: briefly show ✓ then revert
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val highlightedText =
|
||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
buildHighlightedString(
|
||||
message.content,
|
||||
searchQuery,
|
||||
isCurrentMatch,
|
||||
statusColors,
|
||||
)
|
||||
} else {
|
||||
AnnotatedString(message.content)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val userBubbleTextColor =
|
||||
if (primary.luminance() > 0.5f) {
|
||||
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
LightOnSurface
|
||||
}
|
||||
} else {
|
||||
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
DarkOnSurface
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = 16.dp,
|
||||
bottomEnd = 4.dp,
|
||||
),
|
||||
).background(color = primary)
|
||||
.testTag("chat_bubble_user"),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = highlightedText,
|
||||
color = userBubbleTextColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection =
|
||||
if (isRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
// Render inline attachments
|
||||
if (!message.attachments.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
message.attachments.forEach { attachment ->
|
||||
InlineAttachment(
|
||||
attachment = attachment,
|
||||
textColor = userBubbleTextColor,
|
||||
onOpen = { onOpenAttachment(it) },
|
||||
onSave = { onSaveAttachment(it) },
|
||||
savingPath = savingAttachmentPath,
|
||||
openingPath = openingAttachmentPath,
|
||||
canSave = canSaveAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
if (!message.isStreaming) {
|
||||
FlowRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 4.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(null, message.content)),
|
||||
)
|
||||
}
|
||||
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 = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text =
|
||||
formatTimestamp(
|
||||
message.timestamp,
|
||||
DateFormat.is24HourFormat(LocalContext.current),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
if (messageStatsEnabled && showUserMessageTokens &&
|
||||
message.tokenCount != null && message.tokenCount > 0
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.testTag("bubble_token_count"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelfImprovementReviewCard(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cleanText =
|
||||
content
|
||||
.removePrefix("💾")
|
||||
.replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
val isSkill =
|
||||
cleanText.contains("skill", ignoreCase = true) ||
|
||||
cleanText.contains("SKILL.md", ignoreCase = true)
|
||||
val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology
|
||||
val title =
|
||||
if (isSkill) {
|
||||
"Self-Improvement Review • Skill Patched"
|
||||
} else {
|
||||
"Self-Improvement Review • Memory Updated"
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.testTag("self_improvement_review_card"),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = cleanText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SystemBubble(
|
||||
message: ChatMessage,
|
||||
onRespondApproval: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (message.content.contains("Self-improvement review:", ignoreCase = true)) {
|
||||
SelfImprovementReviewCard(content = message.content, modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val approvalInfo = message.approvalInfo
|
||||
var confirmAlways by remember(message.id) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
|
||||
// Approval action buttons — dynamic from backend `choices`
|
||||
// (desktop `approval.tsx` parity: once/session/always/deny,
|
||||
// smart-denied → once/deny only, allowPermanent=false hides Always).
|
||||
if (approvalInfo != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
|
||||
val visibleChoices =
|
||||
rawChoices.filter { choice ->
|
||||
when (choice) {
|
||||
"always" -> approvalInfo.allowPermanent != false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (choice in visibleChoices) {
|
||||
when (choice) {
|
||||
"deny" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval("deny") },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("deny_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Deny")
|
||||
}
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval("session") },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("session_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Allow session")
|
||||
}
|
||||
}
|
||||
|
||||
"always" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { confirmAlways = true },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("always_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Always allow")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// "once" (+ legacy "approve" → normalized to once in VM)
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval(choice) },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("approve_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(if (choice == "approve") "Approve" else "Run")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// "Always allow" persists the pattern permanently — confirm first
|
||||
// (desktop confirm-modal parity).
|
||||
if (confirmAlways) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmAlways = false },
|
||||
title = { Text("Always allow this command?") },
|
||||
text = {
|
||||
Text(
|
||||
"This persists the pattern permanently so future " +
|
||||
"matching commands run without asking.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmAlways = false
|
||||
onRespondApproval("always")
|
||||
},
|
||||
) {
|
||||
Text("Always allow")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmAlways = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatTimestamp(
|
||||
timestamp: Long,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val pattern = if (is24Hour) "HH:mm" else "h:mm a"
|
||||
return DateTimeFormatter
|
||||
.ofPattern(pattern)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AnnotatedString with search matches highlighted.
|
||||
*/
|
||||
private fun buildHighlightedString(
|
||||
text: String,
|
||||
query: String,
|
||||
isCurrentMatch: Boolean = false,
|
||||
statusColors: HermesStatusColors,
|
||||
): AnnotatedString {
|
||||
val (highlightColor, highlightText) =
|
||||
if (isCurrentMatch) {
|
||||
statusColors.warning to statusColors.onWarning
|
||||
} else {
|
||||
statusColors.warningContainer to onColorFor(statusColors.warningContainer)
|
||||
}
|
||||
return buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val matchEnd = text.indexOf(query, i, ignoreCase = true)
|
||||
if (matchEnd == -1) {
|
||||
// No more matches — append the rest
|
||||
append(text.substring(i))
|
||||
i = text.length
|
||||
} else {
|
||||
// Append text before the match
|
||||
if (matchEnd > i) {
|
||||
append(text.substring(i, matchEnd))
|
||||
}
|
||||
// Append the match highlighted
|
||||
withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
|
||||
append(text.substring(matchEnd, matchEnd + query.length))
|
||||
}
|
||||
i = matchEnd + query.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an attachment inline inside a chat bubble.
|
||||
* Images are displayed as thumbnails; other files show a compact card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InlineAttachment(
|
||||
attachment: Attachment,
|
||||
textColor: Color,
|
||||
onOpen: (Attachment) -> Unit,
|
||||
onSave: (Attachment) -> Unit,
|
||||
savingPath: String?,
|
||||
openingPath: String?,
|
||||
canSave: Boolean,
|
||||
onImageClick: (ImageViewerModel) -> Unit,
|
||||
) {
|
||||
val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name
|
||||
val isSaving = savingPath != null && savingPath == attachmentPath
|
||||
val isOpening = openingPath != null && openingPath == attachmentPath
|
||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||
if (attachment.isVideo || attachment.isAudio) {
|
||||
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
|
||||
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
|
||||
uri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onFullScreenClick = { showMediaDialog = true },
|
||||
)
|
||||
if (showMediaDialog) {
|
||||
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
|
||||
mediaUri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onDismissRequest = { showMediaDialog = false },
|
||||
)
|
||||
}
|
||||
} else if (attachment.isImage) {
|
||||
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
|
||||
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||
model = attachment.uri,
|
||||
contentDescription = attachment.name,
|
||||
isGif = attachment.isGif,
|
||||
onClick = {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = attachment.uri,
|
||||
name = attachment.name,
|
||||
mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Non-image file — native save picker on the trailing action.
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = textColor.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)),
|
||||
modifier = clickable,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(9.dp).size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = attachment.name,
|
||||
color = textColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = attachment.formattedSize,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
if (isOpening) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
if (attachment.gatewayUrl != null) {
|
||||
IconButton(
|
||||
onClick = { onSave(attachment) },
|
||||
enabled = canSave,
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.chat_save_attachment, attachment.name),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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 coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||
import com.m57.hermescontrol.ui.chat.components.FileViewCard
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||
|
||||
/**
|
||||
* Renders the engine's [ToolView] — the expanded body of a tool card.
|
||||
*
|
||||
* Order: error line → terminal streams ($ command / stdout / stderr / exit
|
||||
* code) → file diff → search hits → plain detail → duration footer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ExpandedToolContent(
|
||||
view: ToolView,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
val isTerminal =
|
||||
view.stdout != null ||
|
||||
view.stderr != null ||
|
||||
view.exitCode != null ||
|
||||
view.terminalCommand != null
|
||||
|
||||
// ── Error line ──
|
||||
view.error?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_execution_error, it),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = statusColors.error,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: $ command + streams + exit code ──
|
||||
if (isTerminal) {
|
||||
view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command ->
|
||||
Text(
|
||||
text = "$ $command",
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.stdout?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.stderr?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.exitCode?.let { code ->
|
||||
if (code != 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Generated/resolved image preview ──
|
||||
view.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = view.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
// ── File diff ──
|
||||
if (view.inlineDiff != null) {
|
||||
DiffViewCard(
|
||||
diffText = view.inlineDiff,
|
||||
filePath = view.diffPath,
|
||||
)
|
||||
} else if (view.fileContent != null) {
|
||||
FileViewCard(
|
||||
content = view.fileContent,
|
||||
filePath = view.filePath,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search hits ──
|
||||
if (!view.searchHits.isNullOrEmpty()) {
|
||||
view.detailLabel?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.searchHits.forEach { hit ->
|
||||
Column(modifier = Modifier.padding(top = 2.dp)) {
|
||||
if (hit.title.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.title,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hit.snippet.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.snippet,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (hit.url.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.url,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plain detail body ──
|
||||
if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) {
|
||||
Text(
|
||||
text = view.detail,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
view.outputCut?.let { omitted ->
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_output_omitted, omitted),
|
||||
style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duration footer ──
|
||||
view.durationLabel?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.tool_duration, it),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
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.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.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
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.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.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.Attachment
|
||||
|
||||
/**
|
||||
* Renders an attachment inline inside a chat bubble.
|
||||
* Images are displayed as thumbnails; other files show a compact card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InlineAttachment(
|
||||
attachment: Attachment,
|
||||
textColor: Color,
|
||||
onOpen: (Attachment) -> Unit,
|
||||
onSave: (Attachment) -> Unit,
|
||||
savingPath: String?,
|
||||
openingPath: String?,
|
||||
canSave: Boolean,
|
||||
onImageClick: (ImageViewerModel) -> Unit,
|
||||
) {
|
||||
val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name
|
||||
val isSaving = savingPath != null && savingPath == attachmentPath
|
||||
val isOpening = openingPath != null && openingPath == attachmentPath
|
||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||
if (attachment.isVideo || attachment.isAudio) {
|
||||
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
|
||||
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
|
||||
uri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onFullScreenClick = { showMediaDialog = true },
|
||||
)
|
||||
if (showMediaDialog) {
|
||||
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
|
||||
mediaUri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onDismissRequest = { showMediaDialog = false },
|
||||
)
|
||||
}
|
||||
} else if (attachment.isImage) {
|
||||
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
|
||||
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||
model = attachment.uri,
|
||||
contentDescription = attachment.name,
|
||||
isGif = attachment.isGif,
|
||||
onClick = {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = attachment.uri,
|
||||
name = attachment.name,
|
||||
mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Non-image file — native save picker on the trailing action.
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = textColor.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)),
|
||||
modifier = clickable,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(9.dp).size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = attachment.name,
|
||||
color = textColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = attachment.formattedSize,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
if (isOpening) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
if (attachment.gatewayUrl != null) {
|
||||
IconButton(
|
||||
onClick = { onSave(attachment) },
|
||||
enabled = canSave,
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.chat_save_attachment, attachment.name),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
internal fun formatTimestamp(
|
||||
timestamp: Long,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val pattern = if (is24Hour) "HH:mm" else "h:mm a"
|
||||
return DateTimeFormatter
|
||||
.ofPattern(pattern)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
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.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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
private fun SelfImprovementReviewCard(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cleanText =
|
||||
content
|
||||
.removePrefix("💾")
|
||||
.replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
val isSkill =
|
||||
cleanText.contains("skill", ignoreCase = true) ||
|
||||
cleanText.contains("SKILL.md", ignoreCase = true)
|
||||
val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology
|
||||
val title =
|
||||
if (isSkill) {
|
||||
"Self-Improvement Review • Skill Patched"
|
||||
} else {
|
||||
"Self-Improvement Review • Memory Updated"
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.testTag("self_improvement_review_card"),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = cleanText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SystemBubble(
|
||||
message: ChatMessage,
|
||||
onRespondApproval: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (message.content.contains("Self-improvement review:", ignoreCase = true)) {
|
||||
SelfImprovementReviewCard(content = message.content, modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val approvalInfo = message.approvalInfo
|
||||
var confirmAlways by remember(message.id) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
|
||||
// Approval action buttons — dynamic from backend `choices`
|
||||
// (desktop `approval.tsx` parity: once/session/always/deny,
|
||||
// smart-denied → once/deny only, allowPermanent=false hides Always).
|
||||
if (approvalInfo != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
|
||||
val visibleChoices =
|
||||
rawChoices.filter { choice ->
|
||||
when (choice) {
|
||||
"always" -> approvalInfo.allowPermanent != false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (choice in visibleChoices) {
|
||||
when (choice) {
|
||||
"deny" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval("deny") },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("deny_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Deny")
|
||||
}
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval("session") },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("session_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Allow session")
|
||||
}
|
||||
}
|
||||
|
||||
"always" -> {
|
||||
FilledTonalButton(
|
||||
onClick = { confirmAlways = true },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("always_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Always allow")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// "once" (+ legacy "approve" → normalized to once in VM)
|
||||
FilledTonalButton(
|
||||
onClick = { onRespondApproval(choice) },
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.testTag("approve_button"),
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(if (choice == "approve") "Approve" else "Run")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// "Always allow" persists the pattern permanently — confirm first
|
||||
// (desktop confirm-modal parity).
|
||||
if (confirmAlways) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmAlways = false },
|
||||
title = { Text("Always allow this command?") },
|
||||
text = {
|
||||
Text(
|
||||
"This persists the pattern permanently so future " +
|
||||
"matching commands run without asking.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmAlways = false
|
||||
onRespondApproval("always")
|
||||
},
|
||||
) {
|
||||
Text("Always allow")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmAlways = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -60,7 +59,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@@ -69,23 +67,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBg
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard
|
||||
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||
import com.m57.hermescontrol.ui.chat.components.FileViewCard
|
||||
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Composes the collapsed summary lines for a tool card.
|
||||
@@ -179,7 +168,7 @@ internal fun ToolBubble(
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
// ── Header row: icon + tool name ──
|
||||
HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName)
|
||||
ToolBubbleHeader(message, config, contentColor, statusColors, view?.serverDisplayName)
|
||||
|
||||
// ── Tool progress preview (tool.progress) ──
|
||||
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
||||
@@ -314,394 +303,6 @@ internal fun ToolBubble(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the engine's [ToolView] — the expanded body of a tool card.
|
||||
*
|
||||
* Order: error line → terminal streams ($ command / stdout / stderr / exit
|
||||
* code) → file diff → search hits → plain detail → duration footer.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExpandedToolContent(
|
||||
view: ToolView,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
val isTerminal =
|
||||
view.stdout != null ||
|
||||
view.stderr != null ||
|
||||
view.exitCode != null ||
|
||||
view.terminalCommand != null
|
||||
|
||||
// ── Error line ──
|
||||
view.error?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_execution_error, it),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = statusColors.error,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: $ command + streams + exit code ──
|
||||
if (isTerminal) {
|
||||
view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command ->
|
||||
Text(
|
||||
text = "$ $command",
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.stdout?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.stderr?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.exitCode?.let { code ->
|
||||
if (code != 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Generated/resolved image preview ──
|
||||
view.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = view.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
// ── File diff ──
|
||||
if (view.inlineDiff != null) {
|
||||
DiffViewCard(
|
||||
diffText = view.inlineDiff,
|
||||
filePath = view.diffPath,
|
||||
)
|
||||
} else if (view.fileContent != null) {
|
||||
FileViewCard(
|
||||
content = view.fileContent,
|
||||
filePath = view.filePath,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search hits ──
|
||||
if (!view.searchHits.isNullOrEmpty()) {
|
||||
view.detailLabel?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.searchHits.forEach { hit ->
|
||||
Column(modifier = Modifier.padding(top = 2.dp)) {
|
||||
if (hit.title.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.title,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hit.snippet.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.snippet,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (hit.url.isNotEmpty()) {
|
||||
Text(
|
||||
text = hit.url,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plain detail body ──
|
||||
if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) {
|
||||
Text(
|
||||
text = view.detail,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
view.outputCut?.let { omitted ->
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_output_omitted, omitted),
|
||||
style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duration footer ──
|
||||
view.durationLabel?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.tool_duration, it),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderRow(
|
||||
message: ChatMessage,
|
||||
config: ToolDisplayConfig,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
displayName: String? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Status icon or spinner
|
||||
if (message.isToolRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
} else {
|
||||
val icon =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle
|
||||
ToolStatus.FAILED -> Icons.Filled.Error
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
val tint =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> statusColors.success
|
||||
ToolStatus.FAILED -> statusColors.error
|
||||
else -> contentColor.copy(alpha = 0.6f)
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
color = contentColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security risk chip for [tool.output_risk] events.
|
||||
*
|
||||
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
|
||||
* Renders in the tool card between the header row and the summary line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SecurityRiskChip(
|
||||
riskData: ToolOutputRiskData,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (chipColor, label) =
|
||||
when {
|
||||
riskData.risk == "high" -> statusColors.error to "Risky output"
|
||||
riskData.risk == "medium" -> statusColors.warning to "Caution"
|
||||
else -> statusColors.warning to "Redacted"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(top = 4.dp, start = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = chipColor,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = chipColor,
|
||||
)
|
||||
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
|
||||
Text(
|
||||
text = stringResource(R.string.tool_redacted),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolRawJsonView(
|
||||
rawContent: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var formatJson by remember { mutableStateOf(true) }
|
||||
val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) }
|
||||
|
||||
// Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing.
|
||||
val formatted by produceState<Pair<String, String>?>(initialValue = null, key1 = rawContent) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
val full = ToolJson.prettyPrintJson(rawContent)
|
||||
if (full != rawContent) full to ToolJson.clampForDisplay(full) else null
|
||||
}
|
||||
}
|
||||
|
||||
val isFormatDifferent = formatted != null
|
||||
val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw
|
||||
val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent
|
||||
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) },
|
||||
key1 = displayText,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(displayText)
|
||||
}
|
||||
}
|
||||
|
||||
CodeTerminalCard(
|
||||
textToCopy = copyText,
|
||||
modifier = modifier,
|
||||
testTag = "tool_raw_json",
|
||||
title = "JSON",
|
||||
copyContentDescription = stringResource(R.string.content_desc_copy),
|
||||
headerActions = {
|
||||
if (isFormatDifferent) {
|
||||
Text(
|
||||
text =
|
||||
if (formatJson) {
|
||||
stringResource(R.string.chat_tool_compact_json)
|
||||
} else {
|
||||
stringResource(R.string.chat_tool_format_json)
|
||||
},
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("tool_json_format_toggle")
|
||||
.clickable(role = Role.Button) { formatJson = !formatJson }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = highlighted,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
visible: Boolean,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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.graphics.Color
|
||||
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.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
|
||||
@Composable
|
||||
internal fun ToolBubbleHeader(
|
||||
message: ChatMessage,
|
||||
config: ToolDisplayConfig,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
displayName: String? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Status icon or spinner
|
||||
if (message.isToolRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
} else {
|
||||
val icon =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle
|
||||
ToolStatus.FAILED -> Icons.Filled.Error
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
val tint =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> statusColors.success
|
||||
ToolStatus.FAILED -> statusColors.error
|
||||
else -> contentColor.copy(alpha = 0.6f)
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
color = contentColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security risk chip for [tool.output_risk] events.
|
||||
*
|
||||
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
|
||||
* Renders in the tool card between the header row and the summary line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SecurityRiskChip(
|
||||
riskData: ToolOutputRiskData,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (chipColor, label) =
|
||||
when {
|
||||
riskData.risk == "high" -> statusColors.error to "Risky output"
|
||||
riskData.risk == "medium" -> statusColors.warning to "Caution"
|
||||
else -> statusColors.warning to "Redacted"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(top = 4.dp, start = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = chipColor,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = chipColor,
|
||||
)
|
||||
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
|
||||
Text(
|
||||
text = stringResource(R.string.tool_redacted),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard
|
||||
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun ToolRawJsonView(
|
||||
rawContent: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var formatJson by remember { mutableStateOf(true) }
|
||||
val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) }
|
||||
|
||||
// Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing.
|
||||
val formatted by produceState<Pair<String, String>?>(initialValue = null, key1 = rawContent) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
val full = ToolJson.prettyPrintJson(rawContent)
|
||||
if (full != rawContent) full to ToolJson.clampForDisplay(full) else null
|
||||
}
|
||||
}
|
||||
|
||||
val isFormatDifferent = formatted != null
|
||||
val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw
|
||||
val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent
|
||||
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) },
|
||||
key1 = displayText,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(displayText)
|
||||
}
|
||||
}
|
||||
|
||||
CodeTerminalCard(
|
||||
textToCopy = copyText,
|
||||
modifier = modifier,
|
||||
testTag = "tool_raw_json",
|
||||
title = "JSON",
|
||||
copyContentDescription = stringResource(R.string.content_desc_copy),
|
||||
headerActions = {
|
||||
if (isFormatDifferent) {
|
||||
Text(
|
||||
text =
|
||||
if (formatJson) {
|
||||
stringResource(R.string.chat_tool_compact_json)
|
||||
} else {
|
||||
stringResource(R.string.chat_tool_format_json)
|
||||
},
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("tool_json_format_toggle")
|
||||
.clickable(role = Role.Button) { formatJson = !formatJson }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = highlighted,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ClipData
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.background
|
||||
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.foundation.layout.widthIn
|
||||
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.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.getValue
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.style.TextDirection
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.theme.DarkOnSurface
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LightOnSurface
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.onColorFor
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The user-message bubble — the universal anchor in the full-bleed chat
|
||||
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
||||
* this bubble so the conversation stays scannable.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UserBubble(
|
||||
message: ChatMessage,
|
||||
searchQuery: String = "",
|
||||
isCurrentMatch: Boolean = false,
|
||||
onOpenAttachment: (Attachment) -> Unit = {},
|
||||
onSaveAttachment: (Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showUserMessageTokens: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.80f
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter =
|
||||
fadeIn() +
|
||||
expandVertically(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
),
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Copy feedback: briefly show ✓ then revert
|
||||
var copied by rememberCopyFeedback()
|
||||
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val highlightedText =
|
||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
buildHighlightedString(
|
||||
message.content,
|
||||
searchQuery,
|
||||
isCurrentMatch,
|
||||
statusColors,
|
||||
)
|
||||
} else {
|
||||
AnnotatedString(message.content)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val userBubbleTextColor =
|
||||
if (primary.luminance() > 0.5f) {
|
||||
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
LightOnSurface
|
||||
}
|
||||
} else {
|
||||
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
DarkOnSurface
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = 16.dp,
|
||||
bottomEnd = 4.dp,
|
||||
),
|
||||
).background(color = primary)
|
||||
.testTag("chat_bubble_user"),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = highlightedText,
|
||||
color = userBubbleTextColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection =
|
||||
if (isRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
// Render inline attachments
|
||||
if (!message.attachments.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
message.attachments.forEach { attachment ->
|
||||
InlineAttachment(
|
||||
attachment = attachment,
|
||||
textColor = userBubbleTextColor,
|
||||
onOpen = { onOpenAttachment(it) },
|
||||
onSave = { onSaveAttachment(it) },
|
||||
savingPath = savingAttachmentPath,
|
||||
openingPath = openingAttachmentPath,
|
||||
canSave = canSaveAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
if (!message.isStreaming) {
|
||||
FlowRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 4.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(null, message.content)),
|
||||
)
|
||||
}
|
||||
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 = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text =
|
||||
formatTimestamp(
|
||||
message.timestamp,
|
||||
DateFormat.is24HourFormat(LocalContext.current),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
if (messageStatsEnabled && showUserMessageTokens &&
|
||||
message.tokenCount != null && message.tokenCount > 0
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.testTag("bubble_token_count"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AnnotatedString with search matches highlighted.
|
||||
*/
|
||||
private fun buildHighlightedString(
|
||||
text: String,
|
||||
query: String,
|
||||
isCurrentMatch: Boolean = false,
|
||||
statusColors: HermesStatusColors,
|
||||
): AnnotatedString {
|
||||
val (highlightColor, highlightText) =
|
||||
if (isCurrentMatch) {
|
||||
statusColors.warning to statusColors.onWarning
|
||||
} else {
|
||||
statusColors.warningContainer to onColorFor(statusColors.warningContainer)
|
||||
}
|
||||
return buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val matchEnd = text.indexOf(query, i, ignoreCase = true)
|
||||
if (matchEnd == -1) {
|
||||
// No more matches — append the rest
|
||||
append(text.substring(i))
|
||||
i = text.length
|
||||
} else {
|
||||
// Append text before the match
|
||||
if (matchEnd > i) {
|
||||
append(text.substring(i, matchEnd))
|
||||
}
|
||||
// Append the match highlighted
|
||||
withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
|
||||
append(text.substring(matchEnd, matchEnd + query.length))
|
||||
}
|
||||
i = matchEnd + query.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.chat.ClarifyUi
|
||||
|
||||
// ── ClarifyBubble ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Centered dashed-border bubble showing a clarify question (or batch of questions) with
|
||||
* selectable option chips, multi-select toggles, open-ended input, and a dismiss button.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ClarifyBubble(
|
||||
clarifyRequest: ClarifyUi,
|
||||
onRespondSingle: (String) -> Unit,
|
||||
onRespondBatch: (Map<String, String>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val questions = clarifyRequest.resolvedQuestions
|
||||
val isBatch = questions.size > 1
|
||||
|
||||
var selectedChoicesByQid by remember(clarifyRequest) {
|
||||
mutableStateOf<Map<String, Set<String>>>(emptyMap())
|
||||
}
|
||||
var customTextByQid by remember(clarifyRequest) {
|
||||
mutableStateOf<Map<String, String>>(emptyMap())
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.testTag("clarify_bubble"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
questions.forEachIndexed { index, q ->
|
||||
val lockedAnswer = clarifyRequest.lockedAnswers[q.qid]
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
Text(
|
||||
text = "${index + 1}. ${q.question}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = q.question,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (lockedAnswer != null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Answered: $lockedAnswer",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (q.multiSelect) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Select all that apply",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (q.choices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
q.choices.forEach { choice ->
|
||||
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
|
||||
val isSelected = selectedSet.contains(choice)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (q.multiSelect) {
|
||||
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
} else {
|
||||
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
|
||||
// Fast 1-tap respond for lone single-select question when no custom text is entered
|
||||
onRespondSingle(choice)
|
||||
} else {
|
||||
val updated = if (isSelected) emptySet() else setOf(choice)
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(choice) },
|
||||
leadingIcon =
|
||||
if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val typed = customTextByQid[q.qid].orEmpty()
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { newText ->
|
||||
customTextByQid = customTextByQid + (q.qid to newText)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
if (q.choices.isEmpty()) {
|
||||
stringResource(R.string.message_your_response)
|
||||
} else {
|
||||
"Other (optional)"
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val hasAnyInput =
|
||||
questions.any { q ->
|
||||
(selectedChoicesByQid[q.qid]?.isNotEmpty() == true) ||
|
||||
(!customTextByQid[q.qid].isNullOrBlank())
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (isBatch) {
|
||||
val answers =
|
||||
questions.associate { q ->
|
||||
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
|
||||
val custom = customTextByQid[q.qid]?.trim().orEmpty()
|
||||
val finalAns =
|
||||
when {
|
||||
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
|
||||
(selectedList + custom).joinToString(", ")
|
||||
}
|
||||
|
||||
selectedList.isNotEmpty() -> {
|
||||
selectedList.joinToString(", ")
|
||||
}
|
||||
|
||||
else -> {
|
||||
custom
|
||||
}
|
||||
}
|
||||
q.qid to finalAns
|
||||
}
|
||||
onRespondBatch(answers)
|
||||
} else {
|
||||
val q = questions.first()
|
||||
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
|
||||
val custom = customTextByQid[q.qid]?.trim().orEmpty()
|
||||
val finalAns =
|
||||
when {
|
||||
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
|
||||
(selectedList + custom).joinToString(", ")
|
||||
}
|
||||
|
||||
selectedList.isNotEmpty() -> {
|
||||
selectedList.joinToString(", ")
|
||||
}
|
||||
|
||||
else -> {
|
||||
custom
|
||||
}
|
||||
}
|
||||
if (finalAns.isNotBlank()) {
|
||||
onRespondSingle(finalAns)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasAnyInput,
|
||||
) {
|
||||
Text(if (isBatch) "Submit All" else "Send")
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible overload for legacy callers and unit tests.
|
||||
*/
|
||||
@Composable
|
||||
fun ClarifyBubble(
|
||||
text: String,
|
||||
options: List<String>,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = ClarifyUi(text = text, options = options),
|
||||
onRespondSingle = onOptionSelected,
|
||||
onRespondBatch = { answers ->
|
||||
answers.values.firstOrNull()?.let(onOptionSelected)
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Terminal-style card for a fenced code block with syntax highlighting,
|
||||
* a language badge, and a copy button.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeBlockCard(
|
||||
code: String,
|
||||
language: String?,
|
||||
onCopy: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(code) { AnnotatedString(code) },
|
||||
key1 = code,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(code)
|
||||
}
|
||||
}
|
||||
|
||||
CodeTerminalCard(
|
||||
textToCopy = code,
|
||||
modifier = modifier,
|
||||
testTag = "code_block",
|
||||
title = language?.takeIf { it.isNotBlank() }?.uppercase(),
|
||||
onCopy = onCopy,
|
||||
copyContentDescription = "Copy code",
|
||||
) {
|
||||
Text(
|
||||
text = highlighted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.m57.hermescontrol.theme.CodeComment
|
||||
import com.m57.hermescontrol.theme.CodeKeyword
|
||||
import com.m57.hermescontrol.theme.CodeNumber
|
||||
import com.m57.hermescontrol.theme.CodePunctuation
|
||||
import com.m57.hermescontrol.theme.CodeString
|
||||
|
||||
// Hoisted token patterns for syntax highlighting — compiled once.
|
||||
private val HIGHLIGHT_TOKENS =
|
||||
listOf(
|
||||
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
|
||||
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
|
||||
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
|
||||
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
|
||||
TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber),
|
||||
TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber),
|
||||
TokenPattern(
|
||||
Regex(
|
||||
"""\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" +
|
||||
"""override|private|protected|public|internal|import|package|""" +
|
||||
"""if|else|when|for|while|do|return|throw|try|catch|finally|""" +
|
||||
"""true|false|null|this|super|is|in|as|typealias|companion|""" +
|
||||
"""init|constructor|by|get|set|field|value|suspend|inline|""" +
|
||||
"""infix|operator|tailrec|external|annotation)\b""",
|
||||
),
|
||||
CodeKeyword,
|
||||
),
|
||||
TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an [AnnotatedString] from [code] with syntax highlighting colours
|
||||
* applied via token regexes. Covers keywords, strings, comments, numbers,
|
||||
* and punctuation — everything else remains the default light-grey.
|
||||
*/
|
||||
internal fun highlightSyntax(code: String): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
val tokens = HIGHLIGHT_TOKENS
|
||||
var lastIndex = 0
|
||||
val matches = mutableListOf<Pair<IntRange, Color>>()
|
||||
|
||||
for (pattern in tokens) {
|
||||
for (match in pattern.regex.findAll(code)) {
|
||||
matches.add(match.range to pattern.color)
|
||||
}
|
||||
}
|
||||
|
||||
matches.sortBy { it.first.first }
|
||||
// Resolve overlaps: later-in-text wins for same-pos, else first-match
|
||||
val resolved = mutableListOf<Pair<IntRange, Color>>()
|
||||
for (m in matches) {
|
||||
if (resolved.isEmpty()) {
|
||||
resolved.add(m)
|
||||
} else {
|
||||
val last = resolved.last()
|
||||
if (m.first.first >= last.first.last + 1) {
|
||||
// Non-overlapping — safe add
|
||||
resolved.add(m)
|
||||
} else if (m.first.first > last.first.first) {
|
||||
// Overlap, this match starts later — it wins the overlapping portion
|
||||
// Keep the last match's pre-overlap, then replace
|
||||
resolved.removeAt(resolved.lastIndex)
|
||||
// Split: keep text before overlap from last match
|
||||
if (last.first.first < m.first.first) {
|
||||
resolved.add(last.first.first..<m.first.first to last.second)
|
||||
}
|
||||
resolved.add(m)
|
||||
// If last match extends beyond this match, add remainder
|
||||
if (last.first.last > m.first.last) {
|
||||
resolved.add(m.first.last + 1..last.first.last to last.second)
|
||||
}
|
||||
}
|
||||
// else same start position — first match wins, skip this one
|
||||
}
|
||||
}
|
||||
|
||||
var pos = 0
|
||||
for ((range, color) in resolved) {
|
||||
if (range.first > pos) {
|
||||
append(code.substring(pos, range.first))
|
||||
}
|
||||
withStyle(SpanStyle(color = color)) {
|
||||
append(code.substring(range.first, range.last + 1))
|
||||
}
|
||||
pos = range.last + 1
|
||||
}
|
||||
if (pos < code.length) {
|
||||
append(code.substring(pos))
|
||||
}
|
||||
}
|
||||
|
||||
private data class TokenPattern(
|
||||
val regex: Regex,
|
||||
val color: Color,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Remembers a copy feedback state and automatically resets it to `false`
|
||||
* after 1500ms when set to `true`.
|
||||
*
|
||||
* If `copied.value` is already `true`, setting it to `true` again does not
|
||||
* restart the active timer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberCopyFeedback(): MutableState<Boolean> {
|
||||
val copied = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(copied.value) {
|
||||
if (copied.value) {
|
||||
delay(1500)
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
@@ -1,728 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.heightIn
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.CodeComment
|
||||
import com.m57.hermescontrol.theme.CodeKeyword
|
||||
import com.m57.hermescontrol.theme.CodeNumber
|
||||
import com.m57.hermescontrol.theme.CodePunctuation
|
||||
import com.m57.hermescontrol.theme.CodeString
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBg
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// ── ReasoningCard ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collapsible card showing the assistant's reasoning trace
|
||||
* (reasoning-model thinking steps) before the final answer.
|
||||
*
|
||||
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
|
||||
* Expanded: completed reasoning is Markdown; streaming text stays raw,
|
||||
* capped at ~40% of the
|
||||
* screen height with internal scroll; "Show full" lifts the cap.
|
||||
* Long-press anywhere: copies the reasoning trace to the clipboard.
|
||||
* Streaming: pulsing indicator at the bottom while [isStreaming].
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReasoningCard(
|
||||
reasoningText: String,
|
||||
isStreaming: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var fullHeight by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
|
||||
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
|
||||
|
||||
// Copy feedback: briefly show "Copied" then revert
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
|
||||
.combinedClickable(
|
||||
onClick = { expanded = !expanded },
|
||||
onLongClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
?: return@combinedClickable
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
|
||||
copied = true
|
||||
},
|
||||
).testTag("reasoning_card"),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "🧠",
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.message_reasoning_steps, stepCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (copied) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reasoning_copied),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.then(
|
||||
if (!fullHeight) {
|
||||
// Capped at ~40% of the screen with internal scroll.
|
||||
Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
|
||||
} else {
|
||||
// "Show full": render the whole trace at natural height.
|
||||
// A scrollable must NEVER be measured with an infinite
|
||||
// max height — inside a LazyColumn item (unbounded main
|
||||
// axis) that combination throws IllegalStateException.
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (isStreaming) {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
text = reasoningText,
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
if (fullHeight) {
|
||||
TextButton(onClick = { fullHeight = false }) {
|
||||
Text(stringResource(R.string.reasoning_show_less))
|
||||
}
|
||||
} else if (scrollState.maxValue > 0) {
|
||||
TextButton(onClick = { fullHeight = true }) {
|
||||
Text(stringResource(R.string.reasoning_show_full))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small pulsing dot shown at the bottom of [ReasoningCard] while streaming.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReasoningPulsingDot(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse")
|
||||
val pulseSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember {
|
||||
infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = pulseSpec,
|
||||
label = "reasoning_dot_alpha",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer { this.alpha = alpha }
|
||||
.testTag("reasoning_pulsing_dot"),
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CodeBlockCard ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Terminal-style card for a fenced code block with syntax highlighting,
|
||||
* a language badge, and a copy button.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeBlockCard(
|
||||
code: String,
|
||||
language: String?,
|
||||
onCopy: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(code) { AnnotatedString(code) },
|
||||
key1 = code,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(code)
|
||||
}
|
||||
}
|
||||
|
||||
CodeTerminalCard(
|
||||
textToCopy = code,
|
||||
modifier = modifier,
|
||||
testTag = "code_block",
|
||||
title = language?.takeIf { it.isNotBlank() }?.uppercase(),
|
||||
onCopy = onCopy,
|
||||
copyContentDescription = "Copy code",
|
||||
) {
|
||||
Text(
|
||||
text = highlighted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Hoisted token patterns for syntax highlighting — compiled once.
|
||||
private val HIGHLIGHT_TOKENS =
|
||||
listOf(
|
||||
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
|
||||
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
|
||||
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
|
||||
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
|
||||
TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber),
|
||||
TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber),
|
||||
TokenPattern(
|
||||
Regex(
|
||||
"""\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" +
|
||||
"""override|private|protected|public|internal|import|package|""" +
|
||||
"""if|else|when|for|while|do|return|throw|try|catch|finally|""" +
|
||||
"""true|false|null|this|super|is|in|as|typealias|companion|""" +
|
||||
"""init|constructor|by|get|set|field|value|suspend|inline|""" +
|
||||
"""infix|operator|tailrec|external|annotation)\b""",
|
||||
),
|
||||
CodeKeyword,
|
||||
),
|
||||
TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an [AnnotatedString] from [code] with syntax highlighting colours
|
||||
* applied via token regexes. Covers keywords, strings, comments, numbers,
|
||||
* and punctuation — everything else remains the default light-grey.
|
||||
*/
|
||||
internal fun highlightSyntax(code: String): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
val tokens = HIGHLIGHT_TOKENS
|
||||
var lastIndex = 0
|
||||
val matches = mutableListOf<Pair<IntRange, Color>>()
|
||||
|
||||
for (pattern in tokens) {
|
||||
for (match in pattern.regex.findAll(code)) {
|
||||
matches.add(match.range to pattern.color)
|
||||
}
|
||||
}
|
||||
|
||||
matches.sortBy { it.first.first }
|
||||
// Resolve overlaps: later-in-text wins for same-pos, else first-match
|
||||
val resolved = mutableListOf<Pair<IntRange, Color>>()
|
||||
for (m in matches) {
|
||||
if (resolved.isEmpty()) {
|
||||
resolved.add(m)
|
||||
} else {
|
||||
val last = resolved.last()
|
||||
if (m.first.first >= last.first.last + 1) {
|
||||
// Non-overlapping — safe add
|
||||
resolved.add(m)
|
||||
} else if (m.first.first > last.first.first) {
|
||||
// Overlap, this match starts later — it wins the overlapping portion
|
||||
// Keep the last match's pre-overlap, then replace
|
||||
resolved.removeAt(resolved.lastIndex)
|
||||
// Split: keep text before overlap from last match
|
||||
if (last.first.first < m.first.first) {
|
||||
resolved.add(last.first.first..<m.first.first to last.second)
|
||||
}
|
||||
resolved.add(m)
|
||||
// If last match extends beyond this match, add remainder
|
||||
if (last.first.last > m.first.last) {
|
||||
resolved.add(m.first.last + 1..last.first.last to last.second)
|
||||
}
|
||||
}
|
||||
// else same start position — first match wins, skip this one
|
||||
}
|
||||
}
|
||||
|
||||
var pos = 0
|
||||
for ((range, color) in resolved) {
|
||||
if (range.first > pos) {
|
||||
append(code.substring(pos, range.first))
|
||||
}
|
||||
withStyle(SpanStyle(color = color)) {
|
||||
append(code.substring(range.first, range.last + 1))
|
||||
}
|
||||
pos = range.last + 1
|
||||
}
|
||||
if (pos < code.length) {
|
||||
append(code.substring(pos))
|
||||
}
|
||||
}
|
||||
|
||||
private data class TokenPattern(
|
||||
val regex: Regex,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
// ── ClarifyBubble ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Centered dashed-border bubble showing a clarify question (or batch of questions) with
|
||||
* selectable option chips, multi-select toggles, open-ended input, and a dismiss button.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ClarifyBubble(
|
||||
clarifyRequest: ClarifyUi,
|
||||
onRespondSingle: (String) -> Unit,
|
||||
onRespondBatch: (Map<String, String>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val questions = clarifyRequest.resolvedQuestions
|
||||
val isBatch = questions.size > 1
|
||||
|
||||
var selectedChoicesByQid by remember(clarifyRequest) {
|
||||
mutableStateOf<Map<String, Set<String>>>(emptyMap())
|
||||
}
|
||||
var customTextByQid by remember(clarifyRequest) {
|
||||
mutableStateOf<Map<String, String>>(emptyMap())
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.testTag("clarify_bubble"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
questions.forEachIndexed { index, q ->
|
||||
val lockedAnswer = clarifyRequest.lockedAnswers[q.qid]
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
Text(
|
||||
text = "${index + 1}. ${q.question}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = q.question,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (lockedAnswer != null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Answered: $lockedAnswer",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (q.multiSelect) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Select all that apply",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (q.choices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
q.choices.forEach { choice ->
|
||||
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
|
||||
val isSelected = selectedSet.contains(choice)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (q.multiSelect) {
|
||||
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
} else {
|
||||
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
|
||||
// Fast 1-tap respond for lone single-select question when no custom text is entered
|
||||
onRespondSingle(choice)
|
||||
} else {
|
||||
val updated = if (isSelected) emptySet() else setOf(choice)
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(choice) },
|
||||
leadingIcon =
|
||||
if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val typed = customTextByQid[q.qid].orEmpty()
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { newText ->
|
||||
customTextByQid = customTextByQid + (q.qid to newText)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
if (q.choices.isEmpty()) {
|
||||
stringResource(R.string.message_your_response)
|
||||
} else {
|
||||
"Other (optional)"
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val hasAnyInput =
|
||||
questions.any { q ->
|
||||
(selectedChoicesByQid[q.qid]?.isNotEmpty() == true) ||
|
||||
(!customTextByQid[q.qid].isNullOrBlank())
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (isBatch) {
|
||||
val answers =
|
||||
questions.associate { q ->
|
||||
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
|
||||
val custom = customTextByQid[q.qid]?.trim().orEmpty()
|
||||
val finalAns =
|
||||
when {
|
||||
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
|
||||
(selectedList + custom).joinToString(", ")
|
||||
}
|
||||
|
||||
selectedList.isNotEmpty() -> {
|
||||
selectedList.joinToString(", ")
|
||||
}
|
||||
|
||||
else -> {
|
||||
custom
|
||||
}
|
||||
}
|
||||
q.qid to finalAns
|
||||
}
|
||||
onRespondBatch(answers)
|
||||
} else {
|
||||
val q = questions.first()
|
||||
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
|
||||
val custom = customTextByQid[q.qid]?.trim().orEmpty()
|
||||
val finalAns =
|
||||
when {
|
||||
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
|
||||
(selectedList + custom).joinToString(", ")
|
||||
}
|
||||
|
||||
selectedList.isNotEmpty() -> {
|
||||
selectedList.joinToString(", ")
|
||||
}
|
||||
|
||||
else -> {
|
||||
custom
|
||||
}
|
||||
}
|
||||
if (finalAns.isNotBlank()) {
|
||||
onRespondSingle(finalAns)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasAnyInput,
|
||||
) {
|
||||
Text(if (isBatch) "Submit All" else "Send")
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible overload for legacy callers and unit tests.
|
||||
*/
|
||||
@Composable
|
||||
fun ClarifyBubble(
|
||||
text: String,
|
||||
options: List<String>,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = ClarifyUi(text = text, options = options),
|
||||
onRespondSingle = onOptionSelected,
|
||||
onRespondBatch = { answers ->
|
||||
answers.values.firstOrNull()?.let(onOptionSelected)
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
// ── TypingIndicator ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Three subtle dots shown while the assistant is waiting to produce visible
|
||||
* content. Staggered opacity/scale animation keeps the indicator lightweight
|
||||
* without the distracting vertical bounce used by the old chat renderer.
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
val description = stringResource(R.string.chat_agent_status_typing)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.semantics { contentDescription = description }
|
||||
.testTag("typing_indicator"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
for (i in 0 until 3) {
|
||||
TypingDot(delayMs = i * 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingDot(delayMs: Int) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs")
|
||||
val typingSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember(delayMs) {
|
||||
infiniteRepeatable(
|
||||
animation = tween(700, delayMillis = delayMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val offset by infiniteTransition.animateFloat(
|
||||
initialValue = 0.85f,
|
||||
targetValue = 1f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_scale_$delayMs",
|
||||
)
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_alpha_$delayMs",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer {
|
||||
this.scaleX = offset
|
||||
this.scaleY = offset
|
||||
this.alpha = alpha
|
||||
},
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.heightIn
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
|
||||
/**
|
||||
* Collapsible card showing the assistant's reasoning trace
|
||||
* (reasoning-model thinking steps) before the final answer.
|
||||
*
|
||||
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
|
||||
* Expanded: completed reasoning is Markdown; streaming text stays raw,
|
||||
* capped at ~40% of the
|
||||
* screen height with internal scroll; "Show full" lifts the cap.
|
||||
* Long-press anywhere: copies the reasoning trace to the clipboard.
|
||||
* Streaming: pulsing indicator at the bottom while [isStreaming].
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReasoningCard(
|
||||
reasoningText: String,
|
||||
isStreaming: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var fullHeight by remember { mutableStateOf(false) }
|
||||
// Copy feedback: briefly show "Copied" then revert
|
||||
var copied by rememberCopyFeedback()
|
||||
val scrollState = rememberScrollState()
|
||||
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
|
||||
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
|
||||
.combinedClickable(
|
||||
onClick = { expanded = !expanded },
|
||||
onLongClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
?: return@combinedClickable
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
|
||||
copied = true
|
||||
},
|
||||
).testTag("reasoning_card"),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "🧠",
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.message_reasoning_steps, stepCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (copied) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reasoning_copied),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.then(
|
||||
if (!fullHeight) {
|
||||
// Capped at ~40% of the screen with internal scroll.
|
||||
Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
|
||||
} else {
|
||||
// "Show full": render the whole trace at natural height.
|
||||
// A scrollable must NEVER be measured with an infinite
|
||||
// max height — inside a LazyColumn item (unbounded main
|
||||
// axis) that combination throws IllegalStateException.
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (isStreaming) {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
text = reasoningText,
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
if (fullHeight) {
|
||||
TextButton(onClick = { fullHeight = false }) {
|
||||
Text(stringResource(R.string.reasoning_show_less))
|
||||
}
|
||||
} else if (scrollState.maxValue > 0) {
|
||||
TextButton(onClick = { fullHeight = true }) {
|
||||
Text(stringResource(R.string.reasoning_show_full))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small pulsing dot shown at the bottom of [ReasoningCard] while streaming.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReasoningPulsingDot(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse")
|
||||
val pulseSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember {
|
||||
infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = pulseSpec,
|
||||
label = "reasoning_dot_alpha",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer { this.alpha = alpha }
|
||||
.testTag("reasoning_pulsing_dot"),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
/**
|
||||
* Three subtle dots shown while the assistant is waiting to produce visible
|
||||
* content. Staggered opacity/scale animation keeps the indicator lightweight
|
||||
* without the distracting vertical bounce used by the old chat renderer.
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
val description = stringResource(R.string.chat_agent_status_typing)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.semantics { contentDescription = description }
|
||||
.testTag("typing_indicator"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
for (i in 0 until 3) {
|
||||
TypingDot(delayMs = i * 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingDot(delayMs: Int) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs")
|
||||
val typingSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember(delayMs) {
|
||||
infiniteRepeatable(
|
||||
animation = tween(700, delayMillis = delayMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val offset by infiniteTransition.animateFloat(
|
||||
initialValue = 0.85f,
|
||||
targetValue = 1f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_scale_$delayMs",
|
||||
)
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_alpha_$delayMs",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer {
|
||||
this.scaleX = offset
|
||||
this.scaleY = offset
|
||||
this.alpha = alpha
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -43,13 +40,13 @@ import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.coroutines.delay
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Full-bleed renderer for ONE agent (assistant) message (issue #866).
|
||||
*
|
||||
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
|
||||
* Unlike [com.m57.hermescontrol.ui.chat.UserBubble], agent prose renders
|
||||
* directly on the background — no bubble container, no width cap — with a
|
||||
* trailing copy affordance. User messages keep their bubbles; this composable
|
||||
* is only used for ASSISTANT messages.
|
||||
@@ -77,15 +74,8 @@ internal fun FullBleedAgentMessage(
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
// Copy feedback: briefly show ✓ then revert
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
var copied by rememberCopyFeedback()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||
import com.m57.hermescontrol.ui.chat.ChatBubble
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
@@ -40,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
@@ -332,7 +332,7 @@ fun FullBleedChatList(
|
||||
contentType = FullBleedContentType.USER,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
renderUserBubble(
|
||||
message = userMessage,
|
||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||
isCurrentMatch =
|
||||
@@ -512,7 +512,7 @@ fun FullBleedChatList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun renderChatBubble(
|
||||
private fun renderUserBubble(
|
||||
message: ChatMessage,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
@@ -524,7 +524,7 @@ private fun renderChatBubble(
|
||||
messageStatsEnabled: Boolean,
|
||||
showUserMessageTokens: Boolean,
|
||||
) {
|
||||
ChatBubble(
|
||||
UserBubble(
|
||||
message = message,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
|
||||
Reference in new issue
Block a user