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

@@ -35,6 +35,8 @@ data class ChatMessage(
val toolName: String? = null,
val toolStatus: ToolStatus? = 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. */
val attachments: List<Attachment>? = null,
/**
@@ -438,6 +438,9 @@ fun ChatScreen(
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
)
// Loading overlay
@@ -1,10 +1,7 @@
package com.m57.hermescontrol.ui.chat
import android.content.ClipData
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.IntrinsicSize
@@ -19,30 +16,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
import androidx.compose.material3.HorizontalDivider
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.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.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.LinkAnnotation
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.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
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 TABLE_COL_WIDTH = 160.dp
@@ -105,7 +89,11 @@ fun MarkdownText(
for (block in blocks) {
when (block) {
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 -> {
@@ -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
private fun MarkdownTable(
block: MdBlock.Table,
@@ -505,12 +426,23 @@ internal fun parseBlocks(src: String): List<MdBlock> {
when {
line.startsWith("```") -> {
val lang = line.removePrefix("```").trim().ifBlank { null }
val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
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
} 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
}
}
@@ -953,6 +885,7 @@ internal fun parseInline(
internal sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Heading(
@@ -1,23 +1,16 @@
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
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
@@ -26,111 +19,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
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.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
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).
@@ -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)
sudoPrompt?.let { prompt ->
SudoPromptDialog(
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.ChatMessage
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.SubagentIndicator
import com.m57.hermescontrol.ui.common.EmptyState
/**
* 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
fun ChatMessageList(
@@ -51,6 +49,9 @@ fun ChatMessageList(
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
subagentIndicators: List<SubagentIndicator> = emptyList(),
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
) {
if (messages.isEmpty() && !isLoading) {
Box(
@@ -91,8 +92,14 @@ fun ChatMessageList(
val isLastMessage = index == messages.lastIndex
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()) {
ReasoningIndicator(message.reasoningText)
Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
}
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
@@ -120,9 +127,15 @@ fun ChatMessageList(
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
// Reasoning card — rendered inline in the same streaming item
if (streaming.reasoningText.isNotBlank()) {
ReasoningIndicator(streaming.reasoningText)
Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = streaming.reasoningText,
isStreaming = streaming.isStreaming,
)
}
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
@@ -140,19 +153,31 @@ fun ChatMessageList(
}
}
// Thinking indicator
// Typing indicator — bouncing dots
if (isThinking) {
item(key = "thinking") {
ThinkingIndicator(thinkingText)
item(key = "typing_indicator") {
TypingIndicator()
}
}
// Subagent indicators
// Subagent indicators — SubagentCard replaces SubagentIndicatorRow
items(
items = subagentIndicators,
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
) { 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,
) {}
}
}