style(android-chat): align bubbles and markdown with RN

This commit is contained in:
Ayaan Zaidi
2026-02-24 21:34:12 +05:30
committed by Ayaan Zaidi
parent b658000bf7
commit 81ff074a51
2 changed files with 188 additions and 118 deletions

View File

@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -28,13 +27,19 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileTextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ChatMarkdown(text: String, textColor: Color) {
val blocks = remember(text) { splitMarkdown(text) }
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
val inlineCodeBg = mobileCodeBg
val inlineCodeColor = mobileCodeText
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (b in blocks) {
@@ -43,8 +48,8 @@ fun ChatMarkdown(text: String, textColor: Color) {
val trimmed = b.text.trimEnd()
if (trimmed.isEmpty()) continue
Text(
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
style = MaterialTheme.typography.bodyMedium,
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor),
style = mobileCallout,
color = textColor,
)
}
@@ -126,7 +131,11 @@ private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
return out
}
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
private fun parseInlineMarkdown(
text: String,
inlineCodeBg: androidx.compose.ui.graphics.Color,
inlineCodeColor: androidx.compose.ui.graphics.Color,
): AnnotatedString {
if (text.isEmpty()) return AnnotatedString("")
val out = buildAnnotatedString {
@@ -150,6 +159,7 @@ private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.
SpanStyle(
fontFamily = FontFamily.Monospace,
background = inlineCodeBg,
color = inlineCodeColor,
),
) {
append(text.substring(i + 1, end))
@@ -208,8 +218,8 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
Text(
text = "Image unavailable",
modifier = Modifier.padding(vertical = 2.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = mobileCaption1,
color = mobileTextSecondary,
)
}
}

View File

@@ -2,7 +2,8 @@ package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,55 +24,93 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.ui.unit.sp
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatMessageContent
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.tools.ToolDisplayRegistry
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileAccentSoft
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
private data class ChatBubbleStyle(
val alignEnd: Boolean,
val containerColor: Color,
val borderColor: Color,
val roleColor: Color,
)
@Composable
fun ChatMessageBubble(message: ChatMessage) {
val isUser = message.role.lowercase() == "user"
val role = message.role.trim().lowercase(Locale.US)
val style = bubbleStyle(role)
// Filter to only displayable content parts (text with content, or base64 images)
val displayableContent = message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
// Filter to only displayable content parts (text with content, or base64 images).
val displayableContent =
message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
}
}
// Skip rendering entirely if no displayable content
if (displayableContent.isEmpty()) return
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
ChatMessageBody(content = displayableContent, textColor = mobileText)
}
}
@Composable
private fun ChatBubbleContainer(
style: ChatBubbleStyle,
roleLabel: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, style.borderColor),
color = style.containerColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
color = Color.Transparent,
modifier = Modifier.fillMaxWidth(0.92f),
modifier = Modifier.fillMaxWidth(0.90f),
) {
Box(
modifier =
Modifier
.background(bubbleBackground(isUser))
.padding(horizontal = 12.dp, vertical = 10.dp),
Column(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
val textColor = textColorOverBubble(isUser)
ChatMessageBody(content = displayableContent, textColor = textColor)
Text(
text = roleLabel,
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = style.roleColor,
)
content()
}
}
}
@@ -80,7 +118,7 @@ fun ChatMessageBubble(message: ChatMessage) {
@Composable
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (part in content) {
when (part.type) {
"text" -> {
@@ -98,19 +136,16 @@ private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color)
@Composable
fun ChatTypingIndicatorBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = roleLabel("assistant"),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
DotPulse()
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
DotPulse(color = mobileTextSecondary)
Text("Thinking...", style = mobileCallout, color = mobileTextSecondary)
}
}
}
@@ -122,38 +157,37 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Running tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = "TOOLS",
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = mobileCallout,
color = mobileTextSecondary,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
detail,
style = mobileCaption1,
color = mobileTextSecondary,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (toolCalls.size > 6) {
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (toolCalls.size > 6) {
Text(
text = "... +${toolCalls.size - 6} more",
style = mobileCaption1,
color = mobileTextSecondary,
)
}
}
}
@@ -161,37 +195,47 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
@Composable
fun ChatStreamingAssistantBubble(text: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
}
}
ChatBubbleContainer(
style = bubbleStyle("assistant").copy(borderColor = mobileAccent),
roleLabel = "ASSISTANT · LIVE",
) {
ChatMarkdown(text = text, textColor = mobileText)
}
}
@Composable
private fun bubbleBackground(isUser: Boolean): Brush {
return if (isUser) {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
)
} else {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
)
private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) {
"user" ->
ChatBubbleStyle(
alignEnd = true,
containerColor = mobileAccentSoft,
borderColor = mobileAccent,
roleColor = mobileAccent,
)
"system" ->
ChatBubbleStyle(
alignEnd = false,
containerColor = mobileWarningSoft,
borderColor = mobileWarning.copy(alpha = 0.45f),
roleColor = mobileWarning,
)
else ->
ChatBubbleStyle(
alignEnd = false,
containerColor = Color.White,
borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary,
)
}
}
@Composable
private fun textColorOverBubble(isUser: Boolean): Color {
return if (isUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
private fun roleLabel(role: String): String {
return when (role) {
"user" -> "USER"
"system" -> "SYSTEM"
else -> "ASSISTANT"
}
}
@@ -216,48 +260,64 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
}
if (image != null) {
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = Color.White,
modifier = Modifier.fillMaxWidth(),
)
) {
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
}
} else if (failed) {
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
}
}
@Composable
private fun DotPulse() {
private fun DotPulse(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
PulseDot(alpha = 0.38f)
PulseDot(alpha = 0.62f)
PulseDot(alpha = 0.90f)
PulseDot(alpha = 0.38f, color = color)
PulseDot(alpha = 0.62f, color = color)
PulseDot(alpha = 0.90f, color = color)
}
}
@Composable
private fun PulseDot(alpha: Float) {
private fun PulseDot(alpha: Float, color: Color) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = color,
) {}
}
@Composable
fun ChatCodeBlock(code: String, language: String?) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = code.trimEnd(),
modifier = Modifier.padding(10.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(Locale.US),
style = mobileCaption2.copy(letterSpacing = 0.4.sp),
color = mobileTextSecondary,
)
}
Text(
text = code.trimEnd(),
fontFamily = FontFamily.Monospace,
style = mobileCallout,
color = mobileCodeText,
)
}
}
}