From c43904b2b8c4af39ddc7bc9b16d77dd413011df5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:04:55 +0300 Subject: [PATCH] feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../java/com/m57/hermescontrol/theme/Color.kt | 16 + .../m57/hermescontrol/ui/chat/ChatMessage.kt | 2 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 + .../m57/hermescontrol/ui/chat/MarkdownText.kt | 105 +--- .../ui/chat/components/ChatDialogs.kt | 104 ---- .../ui/chat/components/ChatIndicators.kt | 232 ------- .../chat/components/ChatLifecycleEffects.kt | 9 - .../ui/chat/components/ChatMessageList.kt | 49 +- .../ui/chat/components/MessageCards.kt | 564 ++++++++++++++++++ 9 files changed, 641 insertions(+), 443 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 2266fede..77d9a828 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -87,3 +87,19 @@ val SkeletonBase = Color(0xFF1C1C26) val SkeletonHighlight = Color(0xFF2F2F3D) val SkeletonBaseLight = Color(0xFFE6E3EE) 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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 24e670ab..72652381 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -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? = null, /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ec9ce0ba..6fd7d5c8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 88b7c760..d5c5c3f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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 { 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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt index 85977ef8..0d3dad90 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt @@ -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). diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt deleted file mode 100644 index a4560204..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt +++ /dev/null @@ -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, - ) - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 8874b6bd..f78c14fd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index f77b7e2f..7c3991cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -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 = 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() }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt new file mode 100644 index 00000000..63511519 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -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 = + 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>() + + 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>() + 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.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, + 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 = + 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, + ) {} + } +}