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