feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) (#671)
* feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) * fix(chat): render ClarifyBubble at bottom of message list, not as inline SYSTEM message Fixes two on-device bugs: 1. ClarifyBubble now renders at the very bottom of the LazyColumn (after all messages, streaming message, typing indicator, and subagent indicators) instead of inline among regular messages. 2. Bubble disappears when an option is selected or dismissed — the ClarifyBubble is driven by state.clarifyRequest instead of a persisted ChatMessage.clarifyInfo, so clearing the request state removes it from the list entirely. * fix(chat): add text input field to ClarifyBubble for custom typed answers * fix(chat): render ReasoningCard at list level with stable key, not inside AssistantBubble * fix(chat): address agy review — hoist regex, fix a11y, cache anim specs, disable animateContentSize while streaming * fix(chat): replace hardcoded Color() with theme constants to pass CI checkColorLiterals --------- Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
bb4149eb0d
commit
c43904b2b8
9 files changed
+641
-443
No files matched your search
@@ -87,3 +87,19 @@ val SkeletonBase = Color(0xFF1C1C26)
|
||||
val SkeletonHighlight = Color(0xFF2F2F3D)
|
||||
val SkeletonBaseLight = Color(0xFFE6E3EE)
|
||||
val SkeletonHighlightLight = Color(0xFFEFEDF4)
|
||||
|
||||
// ── Syntax highlighting tokens (code blocks) ─────────────────────────────
|
||||
// VS Code Dark+ palette — good general readability across all presets.
|
||||
// These are consumed exclusively by CodeBlockCard (MessageCards.kt).
|
||||
|
||||
val CodeKeyword = Color(0xFF569CD6) // blue — for val, fun, class, etc.
|
||||
val CodeString = Color(0xFFCE9178) // orange — for "string literals"
|
||||
val CodeComment = Color(0xFF6A9955) // green — for // comments
|
||||
val CodeNumber = Color(0xFFB5CEA8) // light green — for 42, 0xFF
|
||||
val CodePunctuation = Color(0xFFD4D4D4) // gray — for {, (, ;, etc.
|
||||
|
||||
// ── Code block / terminal surface colours (issue #659) ───────────────
|
||||
val CodeTerminalBg = Color(0xFF1E1E1E)
|
||||
val CodeTerminalBorder = Color(0xFF333333)
|
||||
val CodeTerminalText = Color(0xFFD4D4D4)
|
||||
val CodeTerminalMuted = Color(0xFF808080)
|
||||
@@ -35,6 +35,8 @@ data class ChatMessage(
|
||||
val toolName: String? = null,
|
||||
val toolStatus: ToolStatus? = null,
|
||||
val approvalInfo: ApprovalInfo? = null,
|
||||
/** Transient clarify request data — when present, renders [ClarifyBubble] inline. */
|
||||
val clarifyInfo: ClarifyUi? = null,
|
||||
/** Files attached to this message — shown inline in the bubble. */
|
||||
val attachments: List<Attachment>? = null,
|
||||
/**
|
||||
|
||||
@@ -438,6 +438,9 @@ fun ChatScreen(
|
||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||
viewModel = viewModel,
|
||||
subagentIndicators = state.subagentIndicators,
|
||||
clarifyRequest = state.clarifyRequest,
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
)
|
||||
|
||||
// Loading overlay
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -19,30 +16,20 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.CheckBox
|
||||
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -56,12 +43,9 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
|
||||
private val TABLE_COL_WIDTH = 160.dp
|
||||
@@ -105,7 +89,11 @@ fun MarkdownText(
|
||||
for (block in blocks) {
|
||||
when (block) {
|
||||
is MdBlock.Code -> {
|
||||
CodeBlockCard(code = block.code, textColor = textColor)
|
||||
com.m57.hermescontrol.ui.chat.components.CodeBlockCard(
|
||||
code = block.code,
|
||||
language = block.language,
|
||||
onCopy = { /* clipboard handled internally */ },
|
||||
)
|
||||
}
|
||||
|
||||
is MdBlock.Hr -> {
|
||||
@@ -354,73 +342,6 @@ fun MarkdownText(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenced code block rendered as a card: monospaced text with horizontal scroll and a copy button.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeBlockCard(
|
||||
code: String,
|
||||
textColor: Color,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
// Auto-dismiss the "copied" checkmark after 2s
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = textColor.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, textColor.copy(alpha = 0.15f)),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, code)))
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.content_desc_copy),
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = textColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = code,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = textColor,
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownTable(
|
||||
block: MdBlock.Table,
|
||||
@@ -505,12 +426,23 @@ internal fun parseBlocks(src: String): List<MdBlock> {
|
||||
|
||||
when {
|
||||
line.startsWith("```") -> {
|
||||
val lang = line.removePrefix("```").trim().ifBlank { null }
|
||||
val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
|
||||
if (end != null) {
|
||||
blocks.add(MdBlock.Code(lines.subList(i + 1, end).joinToString("\n")))
|
||||
blocks.add(
|
||||
MdBlock.Code(
|
||||
code = lines.subList(i + 1, end).joinToString("\n"),
|
||||
language = lang,
|
||||
),
|
||||
)
|
||||
i = end + 1
|
||||
} else {
|
||||
blocks.add(MdBlock.Code(lines.subList(i + 1, lines.size).joinToString("\n")))
|
||||
blocks.add(
|
||||
MdBlock.Code(
|
||||
code = lines.subList(i + 1, lines.size).joinToString("\n"),
|
||||
language = lang,
|
||||
),
|
||||
)
|
||||
i = lines.size
|
||||
}
|
||||
}
|
||||
@@ -953,6 +885,7 @@ internal fun parseInline(
|
||||
internal sealed interface MdBlock {
|
||||
data class Code(
|
||||
val code: String,
|
||||
val language: String? = null,
|
||||
) : MdBlock
|
||||
|
||||
data class Heading(
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,111 +19,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
|
||||
/** Dialog shown when the agent asks the user to clarify between options. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ClarifyDialog(
|
||||
clarify: ClarifyUi,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var typedText by rememberSaveable(clarify.text) { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.chat_clarify_title)) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = clarify.text)
|
||||
|
||||
if (clarify.options.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_clarify_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
clarify.options.forEach { option ->
|
||||
Surface(
|
||||
onClick = { typedText = option },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("clarify_option"),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
onClick = { typedText = option },
|
||||
onLongClick = { onOptionSelected(option) },
|
||||
).padding(horizontal = 24.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = option,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = typedText,
|
||||
onValueChange = { typedText = it },
|
||||
label = { Text(stringResource(R.string.chat_clarify_response)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (typedText.isNotBlank()) {
|
||||
onOptionSelected(typedText)
|
||||
}
|
||||
},
|
||||
enabled = typedText.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.chat_send))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.chat_dismiss))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure password dialog for a pending `sudo.request` (issue #524).
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.Psychology
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
|
||||
/**
|
||||
* Animated "🤔 Thinking…" card shown while the assistant is thinking
|
||||
* but has not yet produced visible text.
|
||||
*/
|
||||
@Composable
|
||||
fun ThinkingIndicator(thinkingText: String) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "thinking_alpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "🤔",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.graphicsLayer { this.alpha = alpha },
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
if (thinkingText.isNotBlank()) {
|
||||
val display = thinkingText.takeLast(100)
|
||||
stringResource(R.string.chat_thinking_param, display)
|
||||
} else {
|
||||
stringResource(R.string.chat_thinking)
|
||||
},
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = LocalHermesStatusColors.current.info,
|
||||
),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.animateContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card showing the assistant's reasoning trace
|
||||
* (reasoning-model thinking steps) before the final answer.
|
||||
*/
|
||||
@Composable
|
||||
fun ReasoningIndicator(reasoningText: String) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClick = { expanded = !expanded },
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = statusColors.infoContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Psychology,
|
||||
contentDescription = stringResource(R.string.chat_reasoning),
|
||||
tint = statusColors.info,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_reasoning),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.info,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand,
|
||||
),
|
||||
tint = statusColors.info,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = statusColors.info,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row showing a subagent task progress chip in the message list.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentIndicatorRow(
|
||||
indicator: SubagentIndicator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "🔀",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
val taskProgressText =
|
||||
if (indicator.taskIndex != null && indicator.taskCount != null) {
|
||||
" (${indicator.taskIndex}/${indicator.taskCount})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val goalText = indicator.goal ?: "Subagent task"
|
||||
val textPreview =
|
||||
if (!indicator.text.isNullOrEmpty()) {
|
||||
": ${indicator.text}"
|
||||
} else if (!indicator.summary.isNullOrEmpty()) {
|
||||
": ${indicator.summary}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "$goalText$taskProgressText$textPreview",
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,15 +168,6 @@ fun ChatLifecycleEffects(
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify dialog
|
||||
clarifyRequest?.let { clarify ->
|
||||
ClarifyDialog(
|
||||
clarify = clarify,
|
||||
onOptionSelected = viewModel::respondToClarify,
|
||||
onDismiss = viewModel::dismissClarify,
|
||||
)
|
||||
}
|
||||
|
||||
// Sudo / secret prompt dialogs (issue #524)
|
||||
sudoPrompt?.let { prompt ->
|
||||
SudoPromptDialog(
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -19,17 +21,13 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.ChatBubble
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
|
||||
/**
|
||||
* The chat message list.
|
||||
*
|
||||
* Lay children out vertically so the search bar occupies real layout space
|
||||
* ABOVE the message list. Without this container the call site is a Box,
|
||||
* which overlays the LazyColumn on top of the search AnimatedVisibility and
|
||||
* swallows every tap on the bar (bar visible but not clickable).
|
||||
*/
|
||||
@Composable
|
||||
fun ChatMessageList(
|
||||
@@ -51,6 +49,9 @@ fun ChatMessageList(
|
||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||
clarifyRequest: ClarifyUi? = null,
|
||||
onRespondClarify: ((String) -> Unit)? = null,
|
||||
onDismissClarify: (() -> Unit)? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
@@ -91,8 +92,14 @@ fun ChatMessageList(
|
||||
val isLastMessage = index == messages.lastIndex
|
||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||
|
||||
// Reasoning card — rendered inline in the same list item so it
|
||||
// stays visible during AND after streaming
|
||||
if (isAssistant && message.reasoningText.isNotBlank()) {
|
||||
ReasoningIndicator(message.reasoningText)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
ReasoningCard(
|
||||
reasoningText = message.reasoningText,
|
||||
isStreaming = message.isStreaming,
|
||||
)
|
||||
}
|
||||
|
||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||
@@ -120,9 +127,15 @@ fun ChatMessageList(
|
||||
// Streaming message
|
||||
streamingMessage?.let { streaming ->
|
||||
item(key = "streaming-${streaming.id}") {
|
||||
// Reasoning card — rendered inline in the same streaming item
|
||||
if (streaming.reasoningText.isNotBlank()) {
|
||||
ReasoningIndicator(streaming.reasoningText)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
ReasoningCard(
|
||||
reasoningText = streaming.reasoningText,
|
||||
isStreaming = streaming.isStreaming,
|
||||
)
|
||||
}
|
||||
|
||||
if (typingEffectEnabled && streaming.isStreaming) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = streaming,
|
||||
@@ -140,19 +153,31 @@ fun ChatMessageList(
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
// Typing indicator — bouncing dots
|
||||
if (isThinking) {
|
||||
item(key = "thinking") {
|
||||
ThinkingIndicator(thinkingText)
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Subagent indicators
|
||||
// Subagent indicators — SubagentCard replaces SubagentIndicatorRow
|
||||
items(
|
||||
items = subagentIndicators,
|
||||
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
|
||||
) { indicator ->
|
||||
SubagentIndicatorRow(indicator = indicator)
|
||||
SubagentCard(indicator = indicator)
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
item(key = "clarify_bubble") {
|
||||
ClarifyBubble(
|
||||
text = clarifyRequest.text,
|
||||
options = clarifyRequest.options,
|
||||
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
||||
onDismiss = { onDismissClarify?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.CodeComment
|
||||
import com.m57.hermescontrol.theme.CodeKeyword
|
||||
import com.m57.hermescontrol.theme.CodeNumber
|
||||
import com.m57.hermescontrol.theme.CodePunctuation
|
||||
import com.m57.hermescontrol.theme.CodeString
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBg
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
|
||||
// ── ReasoningCard ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dark collapsible card showing the assistant's reasoning trace
|
||||
* (reasoning-model thinking steps) before the final answer.
|
||||
*
|
||||
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
|
||||
* Expanded: full reasoning text in monospace bodySmall.
|
||||
* Streaming: pulsing indicator at bottom while [isStreaming].
|
||||
*/
|
||||
@Composable
|
||||
fun ReasoningCard(
|
||||
reasoningText: String,
|
||||
isStreaming: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
|
||||
.testTag("reasoning_card"),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { expanded = !expanded },
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "🧠",
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Reasoning · $stepCount steps",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (isStreaming) {
|
||||
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small pulsing dot shown at the bottom of [ReasoningCard] while streaming.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReasoningPulsingDot(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse")
|
||||
val pulseSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember {
|
||||
infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = pulseSpec,
|
||||
label = "reasoning_dot_alpha",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer { this.alpha = alpha }
|
||||
.testTag("reasoning_pulsing_dot"),
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CodeBlockCard ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Terminal-style card for a fenced code block with syntax highlighting,
|
||||
* a language badge, and a copy button.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeBlockCard(
|
||||
code: String,
|
||||
language: String?,
|
||||
onCopy: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("code_block"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CodeTerminalBg,
|
||||
border = BorderStroke(1.dp, CodeTerminalBorder),
|
||||
) {
|
||||
Column {
|
||||
// Header row: language badge (left) + copy button (right)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (!language.isNullOrBlank()) {
|
||||
Text(
|
||||
text = language.uppercase(),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CodeTerminalMuted,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
copyToClipboard(context, code)
|
||||
copied = true
|
||||
onCopy(code)
|
||||
},
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
tint = CodeTerminalMuted,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Code content with syntax highlighting
|
||||
val highlighted = remember(code) { highlightSyntax(code) }
|
||||
Text(
|
||||
text = highlighted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hoisted token patterns for syntax highlighting — compiled once.
|
||||
private val HIGHLIGHT_TOKENS =
|
||||
listOf(
|
||||
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
|
||||
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
|
||||
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
|
||||
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
|
||||
TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber),
|
||||
TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber),
|
||||
TokenPattern(
|
||||
Regex(
|
||||
"""\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" +
|
||||
"""override|private|protected|public|internal|import|package|""" +
|
||||
"""if|else|when|for|while|do|return|throw|try|catch|finally|""" +
|
||||
"""true|false|null|this|super|is|in|as|typealias|companion|""" +
|
||||
"""init|constructor|by|get|set|field|value|suspend|inline|""" +
|
||||
"""infix|operator|tailrec|external|annotation)\b""",
|
||||
),
|
||||
CodeKeyword,
|
||||
),
|
||||
TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an [AnnotatedString] from [code] with syntax highlighting colours
|
||||
* applied via token regexes. Covers keywords, strings, comments, numbers,
|
||||
* and punctuation — everything else remains the default light-grey.
|
||||
*/
|
||||
internal fun highlightSyntax(code: String): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
val tokens = HIGHLIGHT_TOKENS
|
||||
var lastIndex = 0
|
||||
val matches = mutableListOf<Pair<IntRange, Color>>()
|
||||
|
||||
for (pattern in tokens) {
|
||||
for (match in pattern.regex.findAll(code)) {
|
||||
matches.add(match.range to pattern.color)
|
||||
}
|
||||
}
|
||||
|
||||
matches.sortBy { it.first.first }
|
||||
// Resolve overlaps: later-in-text wins for same-pos, else first-match
|
||||
val resolved = mutableListOf<Pair<IntRange, Color>>()
|
||||
for (m in matches) {
|
||||
if (resolved.isEmpty()) {
|
||||
resolved.add(m)
|
||||
} else {
|
||||
val last = resolved.last()
|
||||
if (m.first.first >= last.first.last + 1) {
|
||||
// Non-overlapping — safe add
|
||||
resolved.add(m)
|
||||
} else if (m.first.first > last.first.first) {
|
||||
// Overlap, this match starts later — it wins the overlapping portion
|
||||
// Keep the last match's pre-overlap, then replace
|
||||
resolved.removeLast()
|
||||
// Split: keep text before overlap from last match
|
||||
if (last.first.first < m.first.first) {
|
||||
resolved.add(last.first.first..<m.first.first to last.second)
|
||||
}
|
||||
resolved.add(m)
|
||||
// If last match extends beyond this match, add remainder
|
||||
if (last.first.last > m.first.last) {
|
||||
resolved.add(m.first.last + 1..last.first.last to last.second)
|
||||
}
|
||||
}
|
||||
// else same start position — first match wins, skip this one
|
||||
}
|
||||
}
|
||||
|
||||
var pos = 0
|
||||
for ((range, color) in resolved) {
|
||||
if (range.first > pos) {
|
||||
append(code.substring(pos, range.first))
|
||||
}
|
||||
withStyle(SpanStyle(color = color)) {
|
||||
append(code.substring(range.first, range.last + 1))
|
||||
}
|
||||
pos = range.last + 1
|
||||
}
|
||||
if (pos < code.length) {
|
||||
append(code.substring(pos))
|
||||
}
|
||||
}
|
||||
|
||||
private data class TokenPattern(val regex: Regex, val color: Color)
|
||||
|
||||
private fun copyToClipboard(
|
||||
context: Context,
|
||||
text: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, text))
|
||||
}
|
||||
|
||||
// ── ClarifyBubble ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Centered dashed-border bubble showing a clarify question with
|
||||
* selectable option chips and a dismiss button.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ClarifyBubble(
|
||||
text: String,
|
||||
options: List<String>,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var typedText by remember { mutableStateOf("") }
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.testTag("clarify_bubble"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (options.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
SuggestionChip(
|
||||
onClick = { onOptionSelected(option) },
|
||||
label = { Text(option) },
|
||||
colors =
|
||||
SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = typedText,
|
||||
onValueChange = { typedText = it },
|
||||
label = { Text("Your response") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (typedText.isNotBlank()) {
|
||||
onOptionSelected(typedText)
|
||||
typedText = ""
|
||||
}
|
||||
},
|
||||
enabled = typedText.isNotBlank(),
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SubagentCard ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inline card showing subagent task progress.
|
||||
*
|
||||
* - While running: [CircularProgressIndicator] + goal text on [tertiaryContainer].
|
||||
* - On completion: ✅ checkmark + summary text.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentCard(
|
||||
indicator: SubagentIndicator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isComplete = indicator.type == "subagent.complete"
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp).testTag("subagent_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isComplete) {
|
||||
Text(text = "✅", fontSize = 14.sp)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val displayText =
|
||||
if (isComplete && !indicator.summary.isNullOrBlank()) {
|
||||
indicator.summary
|
||||
} else if (!indicator.goal.isNullOrBlank()) {
|
||||
indicator.goal
|
||||
} else {
|
||||
"Subagent task"
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TypingIndicator ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Three bouncing dots shown while the assistant is typing / thinking.
|
||||
* Staggered animation: 0ms, 150ms, 300ms delay per dot.
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
for (i in 0 until 3) {
|
||||
TypingDot(delayMs = i * 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingDot(delayMs: Int) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs")
|
||||
val typingSpec: InfiniteRepeatableSpec<Float> =
|
||||
remember(delayMs) {
|
||||
infiniteRepeatable(
|
||||
animation = tween(400, delayMillis = delayMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
)
|
||||
}
|
||||
val offset by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = -6f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_offset_$delayMs",
|
||||
)
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.4f,
|
||||
targetValue = 1f,
|
||||
animationSpec = typingSpec,
|
||||
label = "typing_dot_alpha_$delayMs",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.graphicsLayer {
|
||||
this.translationY = offset
|
||||
this.alpha = alpha
|
||||
},
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user