mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): add chat message actions (#100879)
* fix(android): add chat message actions * chore(android): sync native i18n inventory * fix(i18n): collect Android action labels * fix(i18n): preserve Android placeholders
This commit is contained in:
parent
c3501761da
commit
876ab9bb0b
13 changed files with 869 additions and 103 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
Android chat history now excludes internal, reasoning, and tool-result rows from rendered messages and the offline transcript cache.
|
||||
|
||||
Android chat messages now expose long-press actions for whole-message copy, selective text copy, sharing, and quoted replies.
|
||||
|
||||
The OpenClaw mascot now comes alive across onboarding and the app headers with the same float, blink, antenna-wiggle, and claw-snap animation as openclaw.ai.
|
||||
|
||||
Adds read-only Cron Job details in Settings, including schedule, payload and delivery state, job ID copy, refresh, and nested back navigation.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@ import kotlinx.coroutines.flow.flowOf
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class ChatDraftPlacement {
|
||||
Replace,
|
||||
BeforeExisting,
|
||||
}
|
||||
|
||||
data class ChatDraft(
|
||||
val text: String,
|
||||
val placement: ChatDraftPlacement,
|
||||
)
|
||||
|
||||
/**
|
||||
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
|
||||
*/
|
||||
|
|
@ -47,8 +57,8 @@ class MainViewModel(
|
|||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
private val _startOnboardingAtGatewaySetup = MutableStateFlow(false)
|
||||
val startOnboardingAtGatewaySetup: StateFlow<Boolean> = _startOnboardingAtGatewaySetup
|
||||
private val _chatDraft = MutableStateFlow<String?>(null)
|
||||
val chatDraft: StateFlow<String?> = _chatDraft
|
||||
private val _chatDraft = MutableStateFlow<ChatDraft?>(null)
|
||||
val chatDraft: StateFlow<ChatDraft?> = _chatDraft
|
||||
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
|
||||
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
|
||||
private val _assistantAutoSendInFlight = MutableStateFlow(false)
|
||||
|
|
@ -415,7 +425,7 @@ class MainViewModel(
|
|||
return
|
||||
}
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = request.prompt
|
||||
_chatDraft.value = request.prompt?.let { ChatDraft(text = it, placement = ChatDraftPlacement.Replace) }
|
||||
}
|
||||
|
||||
fun clearRequestedHomeDestination() {
|
||||
|
|
@ -430,6 +440,11 @@ class MainViewModel(
|
|||
_chatDraft.value = null
|
||||
}
|
||||
|
||||
fun setChatReplyDraft(value: String) {
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = ChatDraft(text = value, placement = ChatDraftPlacement.BeforeExisting)
|
||||
}
|
||||
|
||||
/** Claims an assistant prompt before sending so Compose effect restarts cannot dispatch it twice. */
|
||||
fun dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt: String,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.ChatDraft
|
||||
import ai.openclaw.app.ChatDraftPlacement
|
||||
import ai.openclaw.app.chat.ChatCommandEntry
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
|
|
@ -70,6 +72,17 @@ internal data class DraftApplication(
|
|||
val consumed: Boolean,
|
||||
)
|
||||
|
||||
internal fun mergeChatDraft(
|
||||
draft: ChatDraft?,
|
||||
currentInput: String,
|
||||
): String? {
|
||||
val text = draft?.text?.takeIf { it.isNotBlank() } ?: return null
|
||||
return when (draft.placement) {
|
||||
ChatDraftPlacement.Replace -> text
|
||||
ChatDraftPlacement.BeforeExisting -> text + currentInput
|
||||
}
|
||||
}
|
||||
|
||||
internal data class SheetSlashCommandSelection(
|
||||
val input: String,
|
||||
)
|
||||
|
|
@ -86,17 +99,24 @@ internal fun resolveSheetComposerSendAction(input: String): SheetComposerSendAct
|
|||
|
||||
/** Applies a draft exactly once so restored prompts do not overwrite user edits. */
|
||||
internal fun applyDraftText(
|
||||
draftText: String?,
|
||||
draft: ChatDraft?,
|
||||
currentInput: String,
|
||||
lastAppliedDraft: String?,
|
||||
): DraftApplication {
|
||||
val draft =
|
||||
draftText?.trim()?.ifEmpty { null } ?: return DraftApplication(
|
||||
val appliedDraft =
|
||||
draft ?: return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = null,
|
||||
consumed = false,
|
||||
)
|
||||
if (draft == lastAppliedDraft) {
|
||||
val nextInput =
|
||||
mergeChatDraft(appliedDraft, currentInput) ?: return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = null,
|
||||
consumed = false,
|
||||
)
|
||||
val draftText = appliedDraft.text
|
||||
if (draftText == lastAppliedDraft) {
|
||||
return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = lastAppliedDraft,
|
||||
|
|
@ -104,8 +124,8 @@ internal fun applyDraftText(
|
|||
)
|
||||
}
|
||||
return DraftApplication(
|
||||
input = draft,
|
||||
lastAppliedDraft = draft,
|
||||
input = nextInput,
|
||||
lastAppliedDraft = draftText,
|
||||
consumed = true,
|
||||
)
|
||||
}
|
||||
|
|
@ -113,7 +133,7 @@ internal fun applyDraftText(
|
|||
/** Chat input surface for text, image attachments, thinking level, and run controls. */
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
draftText: String?,
|
||||
draftText: ChatDraft?,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
|
|
@ -138,7 +158,7 @@ fun ChatComposer(
|
|||
}
|
||||
|
||||
LaunchedEffect(draftText) {
|
||||
val next = applyDraftText(draftText = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
|
||||
val next = applyDraftText(draft = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
|
||||
input = next.input
|
||||
lastAppliedDraft = next.lastAppliedDraft
|
||||
if (next.consumed) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal fun chatMessagePlainText(content: List<ChatMessageContent>): String =
|
||||
content
|
||||
.asSequence()
|
||||
.filter { it.type == "text" }
|
||||
.mapNotNull(ChatMessageContent::text)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString("\n\n")
|
||||
|
||||
internal fun quoteChatMessage(text: String): String {
|
||||
val quoted =
|
||||
text
|
||||
.lineSequence()
|
||||
.joinToString("\n") { line -> if (line.isEmpty()) ">" else "> $line" }
|
||||
return "$quoted\n\n"
|
||||
}
|
||||
|
||||
/** Long-press message actions shared by the full Chat tab and compact chat sheet. */
|
||||
@Composable
|
||||
internal fun ChatMessageActionHost(
|
||||
text: String,
|
||||
onReply: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!enabled || text.isBlank()) {
|
||||
Box(modifier = modifier) { content() }
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var selectText by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { menuExpanded = true },
|
||||
onLongClickLabel = "Message actions",
|
||||
),
|
||||
) {
|
||||
content()
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
MessageActionItem(label = "Copy") {
|
||||
copyChatMessage(context, text)
|
||||
menuExpanded = false
|
||||
}
|
||||
MessageActionItem(label = "Select text") {
|
||||
menuExpanded = false
|
||||
selectText = true
|
||||
}
|
||||
MessageActionItem(label = "Share") {
|
||||
shareChatMessage(context, text)
|
||||
menuExpanded = false
|
||||
}
|
||||
MessageActionItem(label = "Reply") {
|
||||
onReply(quoteChatMessage(text))
|
||||
menuExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectText) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectText = false },
|
||||
title = { Text("Select text") },
|
||||
text = {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { selectText = false }) {
|
||||
Text("Done")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageActionItem(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(text = { Text(label) }, onClick = onClick)
|
||||
}
|
||||
|
||||
private fun copyChatMessage(
|
||||
context: Context,
|
||||
text: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw chat message", text))
|
||||
Toast.makeText(context, "Message copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun shareChatMessage(
|
||||
context: Context,
|
||||
text: String,
|
||||
) {
|
||||
val sendIntent =
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text)
|
||||
val chooser = Intent.createChooser(sendIntent, "Share message")
|
||||
if (context !is Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(chooser) }
|
||||
.onFailure {
|
||||
Toast.makeText(context, "No app can share this message", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ fun ChatMessageListCard(
|
|||
outboxItems: List<ChatOutboxItem> = emptyList(),
|
||||
onRetryOutbox: (String) -> Unit = {},
|
||||
onDeleteOutbox: (String) -> Unit = {},
|
||||
onReplyMessage: (String) -> Unit = {},
|
||||
) {
|
||||
val timeline =
|
||||
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText, outboxItems) {
|
||||
|
|
@ -76,7 +77,11 @@ fun ChatMessageListCard(
|
|||
) {
|
||||
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message -> ChatMessageBubble(message = item.message)
|
||||
is ChatTimelineItem.Message ->
|
||||
ChatMessageBubble(
|
||||
message = item.message,
|
||||
onReplyMessage = onReplyMessage,
|
||||
)
|
||||
is ChatTimelineItem.OutboxCommand ->
|
||||
ChatOutboxBubble(
|
||||
item = item.item,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ private data class ChatBubbleStyle(
|
|||
|
||||
/** Renders one persisted chat message as text and image parts. */
|
||||
@Composable
|
||||
fun ChatMessageBubble(message: ChatMessage) {
|
||||
fun ChatMessageBubble(
|
||||
message: ChatMessage,
|
||||
onReplyMessage: (String) -> Unit = {},
|
||||
) {
|
||||
val role = normalizeVisibleChatMessageRole(message.role) ?: return
|
||||
val style = bubbleStyle(role)
|
||||
|
||||
|
|
@ -76,8 +79,15 @@ fun ChatMessageBubble(message: ChatMessage) {
|
|||
|
||||
if (displayableContent.isEmpty()) return
|
||||
|
||||
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
|
||||
ChatMessageBody(content = displayableContent, textColor = mobileText)
|
||||
val messageText = chatMessagePlainText(displayableContent)
|
||||
ChatMessageActionHost(
|
||||
text = messageText,
|
||||
onReply = onReplyMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
|
||||
ChatMessageBody(content = displayableContent, textColor = mobileText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,8 +178,7 @@ fun ChatScreen(
|
|||
var input by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(chatDraft) {
|
||||
val draft = chatDraft?.trim()?.ifEmpty { null } ?: return@LaunchedEffect
|
||||
input = draft
|
||||
input = mergeChatDraft(chatDraft, input) ?: return@LaunchedEffect
|
||||
viewModel.clearChatDraft()
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +256,7 @@ fun ChatScreen(
|
|||
onRetryOutbox = viewModel::retryChatOutboxCommand,
|
||||
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
|
||||
onStarterPrompt = { prompt -> input = prompt },
|
||||
onReplyMessage = viewModel::setChatReplyDraft,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
|
|
@ -557,6 +557,7 @@ private fun ChatMessageList(
|
|||
onRetryOutbox: (String) -> Unit,
|
||||
onDeleteOutbox: (String) -> Unit,
|
||||
onStarterPrompt: (String) -> Unit,
|
||||
onReplyMessage: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val timeline =
|
||||
|
|
@ -592,6 +593,7 @@ private fun ChatMessageList(
|
|||
live = false,
|
||||
content = item.message.content,
|
||||
timestampMs = item.message.timestampMs,
|
||||
onReplyMessage = onReplyMessage,
|
||||
)
|
||||
is ChatTimelineItem.OutboxCommand ->
|
||||
ChatOutboxBubble(
|
||||
|
|
@ -606,6 +608,7 @@ private fun ChatMessageList(
|
|||
live = true,
|
||||
content = listOf(ChatMessageContent(text = item.text)),
|
||||
timestampMs = null,
|
||||
onReplyMessage = onReplyMessage,
|
||||
)
|
||||
ChatTimelineItem.Thinking -> ChatThinkingBubble()
|
||||
}
|
||||
|
|
@ -760,6 +763,7 @@ private fun ChatBubble(
|
|||
live: Boolean,
|
||||
content: List<ChatMessageContent>,
|
||||
timestampMs: Long?,
|
||||
onReplyMessage: (String) -> Unit,
|
||||
) {
|
||||
val normalizedRole = role.trim().lowercase(Locale.US)
|
||||
val isUser = normalizedRole == "user"
|
||||
|
|
@ -777,41 +781,49 @@ private fun ChatBubble(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
val messageText = chatMessagePlainText(displayableContent)
|
||||
ChatMessageActionHost(
|
||||
text = messageText,
|
||||
onReply = onReplyMessage,
|
||||
enabled = !live,
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
live -> "OpenClaw · Live"
|
||||
isUser -> "You"
|
||||
normalizedRole == "system" -> "System"
|
||||
else -> "OpenClaw"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
displayableContent.forEach { part ->
|
||||
if (part.type == "text") {
|
||||
ChatText(text = part.text.orEmpty(), textColor = ClawTheme.colors.text, isStreaming = live)
|
||||
} else {
|
||||
Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
timestampMs?.let {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = formatChatTimestamp(it),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text =
|
||||
when {
|
||||
live -> "OpenClaw · Live"
|
||||
isUser -> "You"
|
||||
normalizedRole == "system" -> "System"
|
||||
else -> "OpenClaw"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
displayableContent.forEach { part ->
|
||||
if (part.type == "text") {
|
||||
ChatText(text = part.text.orEmpty(), textColor = ClawTheme.colors.text, isStreaming = live)
|
||||
} else {
|
||||
Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
timestampMs?.let {
|
||||
Text(
|
||||
text = formatChatTimestamp(it),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||
),
|
||||
onRetryOutbox = viewModel::retryChatOutboxCommand,
|
||||
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
|
||||
onReplyMessage = viewModel::setChatReplyDraft,
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,41 @@
|
|||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.ChatDraft
|
||||
import ai.openclaw.app.ChatDraftPlacement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatComposerDraftTest {
|
||||
@Test
|
||||
fun replyDraftPreservesExistingComposerText() {
|
||||
val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting)
|
||||
|
||||
assertEquals("> quoted\n\nmy reply", mergeChatDraft(draft, "my reply"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesReplySeparatorWhitespace() {
|
||||
val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting)
|
||||
|
||||
val applied =
|
||||
applyDraftText(
|
||||
draft = draft,
|
||||
currentInput = "",
|
||||
lastAppliedDraft = null,
|
||||
)
|
||||
|
||||
assertEquals(draft.text, applied.input)
|
||||
assertEquals(draft.text, applied.lastAppliedDraft)
|
||||
assertTrue(applied.consumed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearsLastAppliedDraftWhenViewModelDraftResets() {
|
||||
val consumed =
|
||||
applyDraftText(
|
||||
draftText = "repeat this",
|
||||
draft = ChatDraft(text = "repeat this", placement = ChatDraftPlacement.Replace),
|
||||
currentInput = "",
|
||||
lastAppliedDraft = null,
|
||||
)
|
||||
|
|
@ -21,7 +46,7 @@ class ChatComposerDraftTest {
|
|||
|
||||
val cleared =
|
||||
applyDraftText(
|
||||
draftText = null,
|
||||
draft = null,
|
||||
currentInput = consumed.input,
|
||||
lastAppliedDraft = consumed.lastAppliedDraft,
|
||||
)
|
||||
|
|
@ -32,7 +57,7 @@ class ChatComposerDraftTest {
|
|||
|
||||
val repeated =
|
||||
applyDraftText(
|
||||
draftText = "repeat this",
|
||||
draft = ChatDraft(text = "repeat this", placement = ChatDraftPlacement.Replace),
|
||||
currentInput = cleared.input,
|
||||
lastAppliedDraft = cleared.lastAppliedDraft,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatMessageActionsTest {
|
||||
@Test
|
||||
fun plainTextJoinsTextPartsAndIgnoresAttachments() {
|
||||
val content =
|
||||
listOf(
|
||||
ChatMessageContent(type = "text", text = "First paragraph"),
|
||||
ChatMessageContent(type = "image", fileName = "photo.png", base64 = "AAAA"),
|
||||
ChatMessageContent(type = "text", text = "Second paragraph"),
|
||||
)
|
||||
|
||||
assertEquals("First paragraph\n\nSecond paragraph", chatMessagePlainText(content))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyQuotesEveryLineAndLeavesComposerSpace() {
|
||||
assertEquals("> first\n>\n> second\n\n", quoteChatMessage("first\n\nsecond"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyAndReplyPreserveWhitespaceSensitiveContent() {
|
||||
val text = " indented code\nnext line "
|
||||
val content = listOf(ChatMessageContent(type = "text", text = text))
|
||||
|
||||
assertEquals(text, chatMessagePlainText(content))
|
||||
assertEquals("> indented code\n> next line \n\n", quoteChatMessage(text))
|
||||
}
|
||||
}
|
||||
|
|
@ -86,10 +86,10 @@ const APPLE_MODIFIER_MULTILINE_CALLS =
|
|||
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"""([\s\S]*?)"""/gu;
|
||||
const ANDROID_CALLS =
|
||||
/\b(?:Text|OutlinedTextField|BasicTextField|Button|IconButton|TopAppBar|Snackbar|AlertDialog)\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_NAMED_LITERALS =
|
||||
/\b(?:contentDescription|label|placeholder|title|message|supportingText|text)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_NAMED_LITERALS = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_TOAST_ARGS =
|
||||
/\b(?:Toast\.makeText|Snackbar\.make)\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_CHOOSER_ARGS = /\bIntent\.createChooser\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_DIALOG_CALLS =
|
||||
/\.(?:setTitle|setMessage|setPositiveButton|setNegativeButton|setNeutralButton)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_UI_STATE_TEXT =
|
||||
|
|
@ -104,6 +104,7 @@ const ANDROID_BUILTIN_UI_CALLS = new Set([
|
|||
"Card",
|
||||
"Checkbox",
|
||||
"Column",
|
||||
"combinedClickable",
|
||||
"DropdownMenuItem",
|
||||
"Icon",
|
||||
"IconButton",
|
||||
|
|
@ -127,7 +128,7 @@ const CONDITIONAL_BRANCHES = [
|
|||
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
];
|
||||
const UI_STRING_NAME_RE =
|
||||
/(?:title|subtitle|body|message|label|text|description|detail|prompt|help)$/iu;
|
||||
/(?:title|subtitle|body|message|label|text|description|detail|prompt|placeholder|help)$/iu;
|
||||
const APPLE_STRING_PROPERTY = /\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*\{/gu;
|
||||
const APPLE_SWITCH_BRANCH =
|
||||
/(?:\bcase\b[^:\n]+|\bdefault)\s*:\s*(?:return\s+)?"((?:\\.|[^"\\])*)"/gu;
|
||||
|
|
@ -658,6 +659,7 @@ function extractCandidates(
|
|||
: [
|
||||
[ANDROID_CALLS, "ui-call"],
|
||||
[ANDROID_TOAST_ARGS, "ui-toast"],
|
||||
[ANDROID_CHOOSER_ARGS, "ui-chooser"],
|
||||
[ANDROID_DIALOG_CALLS, "ui-dialog"],
|
||||
[ANDROID_UI_STATE_TEXT, "ui-state-text"],
|
||||
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
|
||||
|
|
@ -795,15 +797,22 @@ function extractCandidates(
|
|||
}
|
||||
}
|
||||
for (const match of source.matchAll(ANDROID_NAMED_LITERALS)) {
|
||||
const argumentName = match[1];
|
||||
const callName = enclosingCallName(source, match.index ?? 0);
|
||||
if (!callName || !uiCallNames.has(callName) || !match[1]) {
|
||||
if (
|
||||
!argumentName ||
|
||||
!UI_STRING_NAME_RE.test(argumentName) ||
|
||||
!callName ||
|
||||
!uiCallNames.has(callName) ||
|
||||
!match[2]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
addCandidate(
|
||||
entries,
|
||||
surface,
|
||||
repoPath,
|
||||
match[1],
|
||||
match[2],
|
||||
"ui-named-argument",
|
||||
lineNumber(source, match.index ?? 0),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,38 @@ describe("native app i18n inventory", () => {
|
|||
expect(entries.some((entry) => entry.source === "Searching…")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Run now")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.surface === "android" &&
|
||||
entry.kind === "ui-named-argument" &&
|
||||
entry.source === "Search OpenClaw",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.path.endsWith("/ChatMessageActions.kt") &&
|
||||
entry.kind === "ui-named-argument" &&
|
||||
entry.source === "Message actions",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.path.endsWith("/ChatMessageActions.kt") &&
|
||||
entry.kind === "ui-named-argument" &&
|
||||
entry.source === "Reply",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.path.endsWith("/ChatMessageActions.kt") &&
|
||||
entry.kind === "ui-chooser" &&
|
||||
entry.source === "Share message",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "What would you like to work on?")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Check OpenClaw status")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "What can I control here?")).toBe(true);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue