refactor(chat): split transcript component files (#1310, #1311, #1312) (#1349)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 07:31:04 +03:00
1 parent 65d6f98262
commit f2675e0b1a
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,