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:
Peter Steinberger 2026-07-06 13:50:16 +01:00 committed by GitHub
parent c3501761da
commit 876ab9bb0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 869 additions and 103 deletions

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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,

View file

@ -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) {

View file

@ -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()
}
}

View file

@ -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,

View file

@ -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)
}
}
}

View file

@ -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),
)
}
}
}
}

View file

@ -173,6 +173,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
),
onRetryOutbox = viewModel::retryChatOutboxCommand,
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
onReplyMessage = viewModel::setChatReplyDraft,
)
Row(modifier = Modifier.fillMaxWidth().imePadding()) {

View file

@ -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,
)

View file

@ -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))
}
}

View file

@ -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),
);

View file

@ -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);