style(android-chat): refine thread shell and empty states

This commit is contained in:
Ayaan Zaidi
2026-02-24 21:34:08 +05:30
committed by Ayaan Zaidi
parent 02e3fbef77
commit b658000bf7
2 changed files with 100 additions and 68 deletions

View File

@@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ArrowCircleDown import androidx.compose.material3.Surface
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
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.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatPendingToolCall import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
@Composable @Composable
fun ChatMessageListCard( fun ChatMessageListCard(
@@ -29,6 +29,7 @@ fun ChatMessageListCard(
pendingRunCount: Int, pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>, pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?, streamingAssistantText: String?,
healthOk: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -38,73 +39,70 @@ fun ChatMessageListCard(
listState.animateScrollToItem(index = 0) listState.animateScrollToItem(index = 0)
} }
Card( Box(modifier = modifier.fillMaxWidth()) {
modifier = modifier.fillMaxWidth(), LazyColumn(
shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxSize(),
colors = state = listState,
CardDefaults.cardColors( reverseLayout = true,
containerColor = MaterialTheme.colorScheme.surfaceContainer, verticalArrangement = Arrangement.spacedBy(10.dp),
), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) {
) { // With reverseLayout = true, index 0 renders at the BOTTOM.
Box(modifier = Modifier.fillMaxSize()) { // So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim() val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) { if (!stream.isNullOrEmpty()) {
item(key = "stream") { item(key = "stream") {
ChatStreamingAssistantBubble(text = stream) ChatStreamingAssistantBubble(text = stream)
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
} }
} }
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { if (pendingToolCalls.isNotEmpty()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center)) item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
} }
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
}
}
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk)
} }
} }
} }
@Composable @Composable
private fun EmptyChatHint(modifier: Modifier = Modifier) { private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Row( Surface(
modifier = modifier.alpha(0.7f), modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, shape = RoundedCornerShape(14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
) { ) {
Icon( androidx.compose.foundation.layout.Column(
imageVector = Icons.Default.ArrowCircleDown, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
contentDescription = null, verticalArrangement = Arrangement.spacedBy(4.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, ) {
) Text("No messages yet", style = mobileHeadline, color = mobileText)
Text( Text(
text = "Message OpenClaw…", text =
style = MaterialTheme.typography.bodyMedium, if (healthOk) {
color = MaterialTheme.colorScheme.onSurfaceVariant, "Send the first prompt to start this session."
) } else {
"Connect gateway first, then return to chat."
},
style = mobileCallout,
color = mobileTextSecondary,
)
}
} }
} }

View File

@@ -8,7 +8,11 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -19,8 +23,15 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.android.MainViewModel import ai.openclaw.android.MainViewModel
import ai.openclaw.android.chat.OutgoingAttachment import ai.openclaw.android.chat.OutgoingAttachment
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileDanger
import ai.openclaw.android.ui.mobileText
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -72,14 +83,19 @@ fun ChatSheetContent(viewModel: MainViewModel) {
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 12.dp, vertical = 12.dp), .padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (!errorText.isNullOrBlank()) {
ChatErrorRail(errorText = errorText!!)
}
ChatMessageListCard( ChatMessageListCard(
messages = messages, messages = messages,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls, pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText, streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
modifier = Modifier.weight(1f, fill = true), modifier = Modifier.weight(1f, fill = true),
) )
@@ -90,7 +106,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
healthOk = healthOk, healthOk = healthOk,
thinkingLevel = thinkingLevel, thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
errorText = errorText,
attachments = attachments, attachments = attachments,
onPickImages = { pickImages.launch("image/*") }, onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
@@ -118,6 +133,25 @@ fun ChatSheetContent(viewModel: MainViewModel) {
} }
} }
@Composable
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = androidx.compose.ui.graphics.Color.White,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "CHAT ERROR",
style = mobileCaption2.copy(letterSpacing = 0.6.sp),
color = mobileDanger,
)
Text(text = errorText, style = mobileCallout, color = mobileText)
}
}
}
data class PendingImageAttachment( data class PendingImageAttachment(
val id: String, val id: String,
val fileName: String, val fileName: String,