feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) (#671)

* feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664)

* fix(chat): render ClarifyBubble at bottom of message list, not as inline SYSTEM message

Fixes two on-device bugs:
1. ClarifyBubble now renders at the very bottom of the LazyColumn
   (after all messages, streaming message, typing indicator, and subagent
   indicators) instead of inline among regular messages.
2. Bubble disappears when an option is selected or dismissed — the
   ClarifyBubble is driven by state.clarifyRequest instead of a
   persisted ChatMessage.clarifyInfo, so clearing the request state
   removes it from the list entirely.

* fix(chat): add text input field to ClarifyBubble for custom typed answers

* fix(chat): render ReasoningCard at list level with stable key, not inside AssistantBubble

* fix(chat): address agy review — hoist regex, fix a11y, cache anim specs, disable animateContentSize while streaming

* fix(chat): replace hardcoded Color() with theme constants to pass CI checkColorLiterals

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-22 02:04:55 +03:00
1 parent bb4149eb0d
commit c43904b2b8
9 files changed
+641 -443

No files matched your search

@@ -87,3 +87,19 @@ val SkeletonBase = Color(0xFF1C1C26)
val SkeletonHighlight = Color(0xFF2F2F3D) val SkeletonHighlight = Color(0xFF2F2F3D)
val SkeletonBaseLight = Color(0xFFE6E3EE) val SkeletonBaseLight = Color(0xFFE6E3EE)
val SkeletonHighlightLight = Color(0xFFEFEDF4) val SkeletonHighlightLight = Color(0xFFEFEDF4)
// ── Syntax highlighting tokens (code blocks) ─────────────────────────────
// VS Code Dark+ palette — good general readability across all presets.
// These are consumed exclusively by CodeBlockCard (MessageCards.kt).
val CodeKeyword = Color(0xFF569CD6) // blue — for val, fun, class, etc.
val CodeString = Color(0xFFCE9178) // orange — for "string literals"
val CodeComment = Color(0xFF6A9955) // green — for // comments
val CodeNumber = Color(0xFFB5CEA8) // light green — for 42, 0xFF
val CodePunctuation = Color(0xFFD4D4D4) // gray — for {, (, ;, etc.
// ── Code block / terminal surface colours (issue #659) ───────────────
val CodeTerminalBg = Color(0xFF1E1E1E)
val CodeTerminalBorder = Color(0xFF333333)
val CodeTerminalText = Color(0xFFD4D4D4)
val CodeTerminalMuted = Color(0xFF808080)
@@ -35,6 +35,8 @@ data class ChatMessage(
val toolName: String? = null, val toolName: String? = null,
val toolStatus: ToolStatus? = null, val toolStatus: ToolStatus? = null,
val approvalInfo: ApprovalInfo? = null, val approvalInfo: ApprovalInfo? = null,
/** Transient clarify request data — when present, renders [ClarifyBubble] inline. */
val clarifyInfo: ClarifyUi? = null,
/** Files attached to this message — shown inline in the bubble. */ /** Files attached to this message — shown inline in the bubble. */
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
/** /**
@@ -438,6 +438,9 @@ fun ChatScreen(
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel, viewModel = viewModel,
subagentIndicators = state.subagentIndicators, subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
) )
// Loading overlay // Loading overlay
@@ -1,10 +1,7 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import android.content.ClipData
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@@ -19,30 +16,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -56,12 +43,9 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp private val TABLE_COL_WIDTH = 160.dp
@@ -105,7 +89,11 @@ fun MarkdownText(
for (block in blocks) { for (block in blocks) {
when (block) { when (block) {
is MdBlock.Code -> { is MdBlock.Code -> {
CodeBlockCard(code = block.code, textColor = textColor) com.m57.hermescontrol.ui.chat.components.CodeBlockCard(
code = block.code,
language = block.language,
onCopy = { /* clipboard handled internally */ },
)
} }
is MdBlock.Hr -> { is MdBlock.Hr -> {
@@ -354,73 +342,6 @@ fun MarkdownText(
} }
} }
/**
* Fenced code block rendered as a card: monospaced text with horizontal scroll and a copy button.
*/
@Composable
private fun CodeBlockCard(
code: String,
textColor: Color,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
// Auto-dismiss the "copied" checkmark after 2s
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Surface(
shape = RoundedCornerShape(8.dp),
color = textColor.copy(alpha = 0.06f),
border = BorderStroke(1.dp, textColor.copy(alpha = 0.15f)),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, code)))
}
copied = true
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(15.dp),
tint = textColor.copy(alpha = 0.7f),
)
}
}
Text(
text = code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = textColor,
softWrap = false,
modifier =
Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
}
@Composable @Composable
private fun MarkdownTable( private fun MarkdownTable(
block: MdBlock.Table, block: MdBlock.Table,
@@ -505,12 +426,23 @@ internal fun parseBlocks(src: String): List<MdBlock> {
when { when {
line.startsWith("```") -> { line.startsWith("```") -> {
val lang = line.removePrefix("```").trim().ifBlank { null }
val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") } val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
if (end != null) { if (end != null) {
blocks.add(MdBlock.Code(lines.subList(i + 1, end).joinToString("\n"))) blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = lang,
),
)
i = end + 1 i = end + 1
} else { } else {
blocks.add(MdBlock.Code(lines.subList(i + 1, lines.size).joinToString("\n"))) blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = lang,
),
)
i = lines.size i = lines.size
} }
} }
@@ -953,6 +885,7 @@ internal fun parseInline(
internal sealed interface MdBlock { internal sealed interface MdBlock {
data class Code( data class Code(
val code: String, val code: String,
val language: String? = null,
) : MdBlock ) : MdBlock
data class Heading( data class Heading(
@@ -1,23 +1,16 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,111 +19,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ClarifyUi
/** Dialog shown when the agent asks the user to clarify between options. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClarifyDialog(
clarify: ClarifyUi,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
var typedText by rememberSaveable(clarify.text) { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_clarify_title)) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = clarify.text)
if (clarify.options.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.chat_clarify_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
clarify.options.forEach { option ->
Surface(
onClick = { typedText = option },
modifier =
Modifier
.fillMaxWidth()
.testTag("clarify_option"),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Box(
modifier =
Modifier
.combinedClickable(
role = Role.Button,
onClick = { typedText = option },
onLongClick = { onOptionSelected(option) },
).padding(horizontal = 24.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = option,
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text(stringResource(R.string.chat_clarify_response)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = {
if (typedText.isNotBlank()) {
onOptionSelected(typedText)
}
},
enabled = typedText.isNotBlank(),
) {
Text(stringResource(R.string.chat_send))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
},
)
}
/** /**
* Secure password dialog for a pending `sudo.request` (issue #524). * Secure password dialog for a pending `sudo.request` (issue #524).
@@ -1,232 +0,0 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Psychology
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.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.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.SubagentIndicator
/**
* Animated "🤔 Thinking…" card shown while the assistant is thinking
* but has not yet produced visible text.
*/
@Composable
fun ThinkingIndicator(thinkingText: String) {
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "thinking_alpha",
)
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "🤔",
fontSize = 14.sp,
modifier = Modifier.graphicsLayer { this.alpha = alpha },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (thinkingText.isNotBlank()) {
val display = thinkingText.takeLast(100)
stringResource(R.string.chat_thinking_param, display)
} else {
stringResource(R.string.chat_thinking)
},
style =
MaterialTheme.typography.bodySmall.copy(
color = LocalHermesStatusColors.current.info,
),
maxLines = 2,
modifier = Modifier.animateContentSize(),
)
}
}
}
}
/**
* Collapsible card showing the assistant's reasoning trace
* (reasoning-model thinking steps) before the final answer.
*/
@Composable
fun ReasoningIndicator(reasoningText: String) {
var expanded by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clickable(
role = Role.Button,
onClick = { expanded = !expanded },
),
colors =
CardDefaults.cardColors(
containerColor = statusColors.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Psychology,
contentDescription = stringResource(R.string.chat_reasoning),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.chat_reasoning),
style = MaterialTheme.typography.labelMedium,
color = statusColors.info,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription =
stringResource(
if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand,
),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
}
AnimatedVisibility(visible = expanded) {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall,
color = statusColors.info,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
/**
* Row showing a subagent task progress chip in the message list.
*/
@Composable
fun SubagentIndicatorRow(
indicator: SubagentIndicator,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "🔀",
style = MaterialTheme.typography.bodySmall,
)
val taskProgressText =
if (indicator.taskIndex != null && indicator.taskCount != null) {
" (${indicator.taskIndex}/${indicator.taskCount})"
} else {
""
}
val goalText = indicator.goal ?: "Subagent task"
val textPreview =
if (!indicator.text.isNullOrEmpty()) {
": ${indicator.text}"
} else if (!indicator.summary.isNullOrEmpty()) {
": ${indicator.summary}"
} else {
""
}
Text(
text = "$goalText$taskProgressText$textPreview",
style =
MaterialTheme.typography.bodySmall.copy(
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -168,15 +168,6 @@ fun ChatLifecycleEffects(
} }
} }
// Clarify dialog
clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
// Sudo / secret prompt dialogs (issue #524) // Sudo / secret prompt dialogs (issue #524)
sudoPrompt?.let { prompt -> sudoPrompt?.let { prompt ->
SudoPromptDialog( SudoPromptDialog(
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -19,17 +21,13 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
/** /**
* The chat message list. * The chat message list.
*
* Lay children out vertically so the search bar occupies real layout space
* ABOVE the message list. Without this container the call site is a Box,
* which overlays the LazyColumn on top of the search AnimatedVisibility and
* swallows every tap on the bar (bar visible but not clickable).
*/ */
@Composable @Composable
fun ChatMessageList( fun ChatMessageList(
@@ -51,6 +49,9 @@ fun ChatMessageList(
onLastAnimatedMessageIdChange: (String?) -> Unit, onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel, viewModel: ChatViewModel,
subagentIndicators: List<SubagentIndicator> = emptyList(), subagentIndicators: List<SubagentIndicator> = emptyList(),
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
) { ) {
if (messages.isEmpty() && !isLoading) { if (messages.isEmpty() && !isLoading) {
Box( Box(
@@ -91,8 +92,14 @@ fun ChatMessageList(
val isLastMessage = index == messages.lastIndex val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT val isAssistant = message.role == MessageRole.ASSISTANT
// Reasoning card — rendered inline in the same list item so it
// stays visible during AND after streaming
if (isAssistant && message.reasoningText.isNotBlank()) { if (isAssistant && message.reasoningText.isNotBlank()) {
ReasoningIndicator(message.reasoningText) Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
} }
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
@@ -120,9 +127,15 @@ fun ChatMessageList(
// Streaming message // Streaming message
streamingMessage?.let { streaming -> streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") { item(key = "streaming-${streaming.id}") {
// Reasoning card — rendered inline in the same streaming item
if (streaming.reasoningText.isNotBlank()) { if (streaming.reasoningText.isNotBlank()) {
ReasoningIndicator(streaming.reasoningText) Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = streaming.reasoningText,
isStreaming = streaming.isStreaming,
)
} }
if (typingEffectEnabled && streaming.isStreaming) { if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect( StreamingBubbleWithTypingEffect(
streaming = streaming, streaming = streaming,
@@ -140,19 +153,31 @@ fun ChatMessageList(
} }
} }
// Thinking indicator // Typing indicator — bouncing dots
if (isThinking) { if (isThinking) {
item(key = "thinking") { item(key = "typing_indicator") {
ThinkingIndicator(thinkingText) TypingIndicator()
} }
} }
// Subagent indicators // Subagent indicators — SubagentCard replaces SubagentIndicatorRow
items( items(
items = subagentIndicators, items = subagentIndicators,
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" }, key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
) { indicator -> ) { indicator ->
SubagentIndicatorRow(indicator = indicator) SubagentCard(indicator = indicator)
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
onDismiss = { onDismissClarify?.invoke() },
)
}
} }
} }
} }
@@ -0,0 +1,564 @@
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.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.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.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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
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.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.LocalContext
import androidx.compose.ui.platform.testTag
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.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.SubagentIndicator
// ── ReasoningCard ─────────────────────────────────────────────────────────
/**
* Dark collapsible card showing the assistant's reasoning trace
* (reasoning-model thinking steps) before the final answer.
*
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
* Expanded: full reasoning text in monospace bodySmall.
* Streaming: pulsing indicator at bottom while [isStreaming].
*/
@Composable
fun ReasoningCard(
reasoningText: String,
isStreaming: Boolean = false,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
Card(
modifier =
modifier
.fillMaxWidth()
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
.testTag("reasoning_card"),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
),
shape = RoundedCornerShape(12.dp),
onClick = { expanded = !expanded },
) {
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 = "Reasoning · $stepCount steps",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
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 {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
if (isStreaming) {
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
}
}
}
}
}
}
/**
* 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 context = LocalContext.current
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("code_block"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
) {
Column {
// Header row: language badge (left) + copy button (right)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeTerminalMuted,
),
)
}
Spacer(Modifier.weight(1f))
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
kotlinx.coroutines.delay(2000)
copied = false
}
}
IconButton(
onClick = {
copyToClipboard(context, code)
copied = true
onCopy(code)
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
// Code content with syntax highlighting
val highlighted = remember(code) { highlightSyntax(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(""""[^"\\]*(\\.[^"\\]*)*""""), 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.removeLast()
// 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)
private fun copyToClipboard(
context: Context,
text: String,
) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
clipboard.setPrimaryClip(ClipData.newPlainText(null, text))
}
// ── ClarifyBubble ─────────────────────────────────────────────────────────
/**
* Centered dashed-border bubble showing a clarify question with
* selectable option chips and a dismiss button.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ClarifyBubble(
text: String,
options: List<String>,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var typedText by remember { mutableStateOf("") }
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(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (options.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
options.forEach { option ->
SuggestionChip(
onClick = { onOptionSelected(option) },
label = { Text(option) },
colors =
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
)
}
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text("Your response") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
FilledTonalButton(
onClick = {
if (typedText.isNotBlank()) {
onOptionSelected(typedText)
typedText = ""
}
},
enabled = typedText.isNotBlank(),
) {
Text("Send")
}
TextButton(onClick = onDismiss) {
Text("Dismiss")
}
}
}
}
}
// ── SubagentCard ──────────────────────────────────────────────────────────
/**
* Inline card showing subagent task progress.
*
* - While running: [CircularProgressIndicator] + goal text on [tertiaryContainer].
* - On completion: ✅ checkmark + summary text.
*/
@Composable
fun SubagentCard(
indicator: SubagentIndicator,
modifier: Modifier = Modifier,
) {
val isComplete = indicator.type == "subagent.complete"
Surface(
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp).testTag("subagent_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isComplete) {
Text(text = "✅", fontSize = 14.sp)
} else {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
Spacer(Modifier.width(8.dp))
val displayText =
if (isComplete && !indicator.summary.isNullOrBlank()) {
indicator.summary
} else if (!indicator.goal.isNullOrBlank()) {
indicator.goal
} else {
"Subagent task"
}
Text(
text = displayText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── TypingIndicator ───────────────────────────────────────────────────────
/**
* Three bouncing dots shown while the assistant is typing / thinking.
* Staggered animation: 0ms, 150ms, 300ms delay per dot.
*/
@Composable
fun TypingIndicator(modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).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(400, delayMillis = delayMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
)
}
val offset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = -6f,
animationSpec = typingSpec,
label = "typing_dot_offset_$delayMs",
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.4f,
targetValue = 1f,
animationSpec = typingSpec,
label = "typing_dot_alpha_$delayMs",
)
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.graphicsLayer {
this.translationY = offset
this.alpha = alpha
},
) {
androidx.compose.material3.Surface(
modifier = Modifier.size(8.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) {}
}
}