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