mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 23:14:31 +00:00
style(android-chat): refine thread shell and empty states
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user