From 876ab9bb0b4681bbda5083ded6c805e616ad2983 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 13:50:16 +0100 Subject: [PATCH] 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 --- apps/.i18n/native-source.json | 542 ++++++++++++++++-- apps/android/CHANGELOG.md | 2 + .../java/ai/openclaw/app/MainViewModel.kt | 21 +- .../ai/openclaw/app/ui/chat/ChatComposer.kt | 36 +- .../app/ui/chat/ChatMessageActions.kt | 154 +++++ .../app/ui/chat/ChatMessageListCard.kt | 7 +- .../openclaw/app/ui/chat/ChatMessageViews.kt | 16 +- .../ai/openclaw/app/ui/chat/ChatScreen.kt | 78 +-- .../openclaw/app/ui/chat/ChatSheetContent.kt | 1 + .../app/ui/chat/ChatComposerDraftTest.kt | 31 +- .../app/ui/chat/ChatMessageActionsTest.kt | 33 ++ scripts/native-app-i18n.ts | 19 +- test/scripts/native-app-i18n.test.ts | 32 ++ 13 files changed, 869 insertions(+), 103 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index cf3ff50c6fa..d15aa82de12 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -139,7 +139,7 @@ }, { "kind": "ui-state-text", - "line": 103, + "line": 113, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -147,7 +147,7 @@ }, { "kind": "ui-state-text", - "line": 192, + "line": 202, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -155,7 +155,7 @@ }, { "kind": "ui-state-text", - "line": 202, + "line": 212, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -465,6 +465,14 @@ "surface": "android", "id": "native.android.c4e39b5b40211e29" }, + { + "kind": "ui-named-argument", + "line": 99, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt", + "source": "Mac Studio - Tailnet", + "surface": "android", + "id": "native.android.d6a820ac673a7e99" + }, { "kind": "ui-named-argument", "line": 100, @@ -529,6 +537,14 @@ "surface": "android", "id": "native.android.d1d417e09d159175" }, + { + "kind": "ui-named-argument", + "line": 144, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt", + "source": "Listening on device", + "surface": "android", + "id": "native.android.6b036c7f4efe72a0" + }, { "kind": "ui-named-argument", "line": 144, @@ -561,6 +577,14 @@ "surface": "android", "id": "native.android.e263b68101dc6920" }, + { + "kind": "ui-named-argument", + "line": 152, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt", + "source": "Shared with your gateway", + "surface": "android", + "id": "native.android.addea052bcd9ea96" + }, { "kind": "ui-named-argument", "line": 153, @@ -681,6 +705,14 @@ "surface": "android", "id": "native.android.82ab699f88638b8a" }, + { + "kind": "ui-named-argument", + "line": 59, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt", + "source": "Current screen output and interactive app surface.", + "surface": "android", + "id": "native.android.d5938abf7b7a7ee9" + }, { "kind": "conditional-branch", "line": 66, @@ -777,6 +809,14 @@ "surface": "android", "id": "native.android.624fec3507aca6ed" }, + { + "kind": "ui-named-argument", + "line": 48, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt", + "source": "Messaging surfaces connected to this gateway.", + "surface": "android", + "id": "native.android.269dcd676377df1a" + }, { "kind": "conditional-branch", "line": 63, @@ -873,6 +913,14 @@ "surface": "android", "id": "native.android.7c4288229860edf1" }, + { + "kind": "ui-named-argument", + "line": 118, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt", + "source": "Try Chat, Voice, Sessions, Providers, or Settings.", + "surface": "android", + "id": "native.android.48ad0ae0e930e02b" + }, { "kind": "ui-named-argument", "line": 127, @@ -1089,6 +1137,14 @@ "surface": "android", "id": "native.android.b735fb6c7d284ecf" }, + { + "kind": "ui-named-argument", + "line": 53, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt", + "source": "Memory consolidation and dream diary.", + "surface": "android", + "id": "native.android.4f96e0e1568d3220" + }, { "kind": "conditional-branch", "line": 60, @@ -1369,6 +1425,14 @@ "surface": "android", "id": "native.android.d5902896d7b92cc9" }, + { + "kind": "ui-named-argument", + "line": 71, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt", + "source": "Gateway status, phone node readiness, and recent log stream.", + "surface": "android", + "id": "native.android.6c1eb72c4b19c43a" + }, { "kind": "conditional-branch", "line": 78, @@ -1449,6 +1513,14 @@ "surface": "android", "id": "native.android.3abbc8d1a0faf105" }, + { + "kind": "ui-named-argument", + "line": 122, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt", + "source": "Readable gateway log detail.", + "surface": "android", + "id": "native.android.dd65a0b8f32f1441" + }, { "kind": "ui-named-argument", "line": 136, @@ -1553,6 +1625,14 @@ "surface": "android", "id": "native.android.7eda627dad7e16cf" }, + { + "kind": "ui-named-argument", + "line": 56, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt", + "source": "Live nodes, paired phones, and pending device requests.", + "surface": "android", + "id": "native.android.006a92f5811c5406" + }, { "kind": "conditional-branch", "line": 71, @@ -1729,6 +1809,14 @@ "surface": "android", "id": "native.android.64c8db13ef91513c" }, + { + "kind": "ui-named-argument", + "line": 868, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.", + "surface": "android", + "id": "native.android.c38e106cc8f1e9ff" + }, { "kind": "ui-named-argument", "line": 893, @@ -1785,6 +1873,14 @@ "surface": "android", "id": "native.android.63c31ee564d63347" }, + { + "kind": "ui-named-argument", + "line": 1017, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Scan a QR code or use the setup code from your OpenClaw Gateway.", + "surface": "android", + "id": "native.android.f50a9c1ee6d47055" + }, { "kind": "ui-toast", "line": 1026, @@ -1825,6 +1921,14 @@ "surface": "android", "id": "native.android.9cfcf68aae2c05c6" }, + { + "kind": "ui-named-argument", + "line": 1069, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Have a terminal open on the device running OpenClaw.", + "surface": "android", + "id": "native.android.c8a43dbba02941f0" + }, { "kind": "ui-named-argument", "line": 1072, @@ -1833,6 +1937,14 @@ "surface": "android", "id": "native.android.71ce00f359981a0b" }, + { + "kind": "ui-named-argument", + "line": 1073, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Use the same network, or a secure remote Gateway URL.", + "surface": "android", + "id": "native.android.08aa33afe19b0c9c" + }, { "kind": "ui-named-argument", "line": 1079, @@ -1857,6 +1969,14 @@ "surface": "android", "id": "native.android.6a45e2dae35ab5a7" }, + { + "kind": "ui-named-argument", + "line": 1129, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "openclaw gateway", + "surface": "android", + "id": "native.android.d4b295b5146af4b8" + }, { "kind": "ui-named-argument", "line": 1134, @@ -1865,6 +1985,14 @@ "surface": "android", "id": "native.android.c6f9da7008df05a6" }, + { + "kind": "ui-named-argument", + "line": 1135, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "openclaw qr", + "surface": "android", + "id": "native.android.db24e4be7a1b5547" + }, { "kind": "ui-named-argument", "line": 1155, @@ -2185,6 +2313,14 @@ "surface": "android", "id": "native.android.53bcd45eeb574d74" }, + { + "kind": "ui-named-argument", + "line": 1974, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Checking approval…", + "surface": "android", + "id": "native.android.0d4dd8ed358a6fb2" + }, { "kind": "ui-named-argument", "line": 1986, @@ -2377,6 +2513,14 @@ "surface": "android", "id": "native.android.0a5b541f486e6760" }, + { + "kind": "ui-named-argument", + "line": 115, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt", + "source": "Connect your Gateway to load provider readiness.", + "surface": "android", + "id": "native.android.c76ebb6f80eaad93" + }, { "kind": "ui-named-argument", "line": 115, @@ -2641,6 +2785,14 @@ "surface": "android", "id": "native.android.6ab7c2c111f866ba" }, + { + "kind": "ui-named-argument", + "line": 214, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Provider limits and quota health.", + "surface": "android", + "id": "native.android.dcb7d5956029bbc4" + }, { "kind": "ui-named-argument", "line": 214, @@ -2681,6 +2833,14 @@ "surface": "android", "id": "native.android.962a15bdb26cb6e1" }, + { + "kind": "ui-named-argument", + "line": 275, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Scheduled OpenClaw work from your gateway.", + "surface": "android", + "id": "native.android.5e3e4cd42004cb28" + }, { "kind": "ui-named-argument", "line": 286, @@ -2713,6 +2873,14 @@ "surface": "android", "id": "native.android.fe85be29a00c5e58" }, + { + "kind": "ui-named-argument", + "line": 337, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Inspect scheduled gateway work.", + "surface": "android", + "id": "native.android.f6dd71419863574c" + }, { "kind": "ui-named-argument", "line": 351, @@ -2745,6 +2913,14 @@ "surface": "android", "id": "native.android.6da97c8c6e1a3d3a" }, + { + "kind": "ui-named-argument", + "line": 381, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Choose and inspect the assistants available on this gateway.", + "surface": "android", + "id": "native.android.0f33ca43db97b2a9" + }, { "kind": "ui-named-argument", "line": 392, @@ -2769,6 +2945,14 @@ "surface": "android", "id": "native.android.1ba6a31da5ad58e7" }, + { + "kind": "ui-named-argument", + "line": 422, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Review actions that need your attention.", + "surface": "android", + "id": "native.android.b53cdcaacd0eb0f2" + }, { "kind": "conditional-branch", "line": 433, @@ -2833,6 +3017,14 @@ "surface": "android", "id": "native.android.8551e02b060bd7d8" }, + { + "kind": "ui-named-argument", + "line": 476, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "How this phone appears to OpenClaw.", + "surface": "android", + "id": "native.android.fea93eda5bf97d9e" + }, { "kind": "ui-named-argument", "line": 476, @@ -2857,6 +3049,14 @@ "surface": "android", "id": "native.android.18cd027f897c1ba8" }, + { + "kind": "ui-named-argument", + "line": 499, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Configure voice, transport, and playback.", + "surface": "android", + "id": "native.android.19f0834b4dbcc85a" + }, { "kind": "ui-named-argument", "line": 499, @@ -2969,6 +3169,14 @@ "surface": "android", "id": "native.android.333ae4741b8f80fe" }, + { + "kind": "ui-named-argument", + "line": 685, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Choose what reaches OpenClaw.", + "surface": "android", + "id": "native.android.9feed8722db5d2cc" + }, { "kind": "ui-named-argument", "line": 685, @@ -3073,6 +3281,14 @@ "surface": "android", "id": "native.android.e267498ebcb5245a" }, + { + "kind": "ui-named-argument", + "line": 781, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Include Android and background packages.", + "surface": "android", + "id": "native.android.4859d7f773be5e98" + }, { "kind": "ui-named-argument", "line": 788, @@ -3089,6 +3305,14 @@ "surface": "android", "id": "native.android.7c979f32e6ea115f" }, + { + "kind": "ui-named-argument", + "line": 934, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Choose what this phone can share.", + "surface": "android", + "id": "native.android.ae3122660b0489fc" + }, { "kind": "ui-named-argument", "line": 934, @@ -3169,6 +3393,14 @@ "surface": "android", "id": "native.android.c6c8e0c4b8a9a7cf" }, + { + "kind": "ui-named-argument", + "line": 1033, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Connection between this phone and OpenClaw.", + "surface": "android", + "id": "native.android.9d658ff26e59274b" + }, { "kind": "conditional-branch", "line": 1037, @@ -3321,6 +3553,22 @@ "surface": "android", "id": "native.android.d11061d5b8123994" }, + { + "kind": "ui-named-argument", + "line": 1113, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Enter a valid setup code or gateway address.", + "surface": "android", + "id": "native.android.633d0dcf4b6d6ed8" + }, + { + "kind": "ui-named-argument", + "line": 1136, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "A calm, high-contrast OpenClaw interface.", + "surface": "android", + "id": "native.android.6f19c75742af5126" + }, { "kind": "ui-named-argument", "line": 1136, @@ -3353,6 +3601,14 @@ "surface": "android", "id": "native.android.2df2dec704f91977" }, + { + "kind": "ui-named-argument", + "line": 1199, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "OpenClaw for Android.", + "surface": "android", + "id": "native.android.c2d37ac2ad4c9e3c" + }, { "kind": "ui-named-argument", "line": 1211, @@ -4185,6 +4441,14 @@ "surface": "android", "id": "native.android.a5c0f8b3112aa58c" }, + { + "kind": "ui-named-argument", + "line": 455, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "Start a chat and your active OpenClaw conversations will appear here.", + "surface": "android", + "id": "native.android.2e33a4fd5f60b550" + }, { "kind": "ui-named-argument", "line": 456, @@ -4577,6 +4841,14 @@ "surface": "android", "id": "native.android.4590ee99228ab55f" }, + { + "kind": "ui-named-argument", + "line": 68, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", + "source": "Installed capabilities available to OpenClaw.", + "surface": "android", + "id": "native.android.73a666b0a4a0e617" + }, { "kind": "conditional-branch", "line": 82, @@ -4617,6 +4889,14 @@ "surface": "android", "id": "native.android.ab93e3d2d6496eda" }, + { + "kind": "ui-named-argument", + "line": 121, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", + "source": "Inspect installed skill capability and setup state.", + "surface": "android", + "id": "native.android.81d900273232ce39" + }, { "kind": "ui-named-argument", "line": 144, @@ -5139,7 +5419,7 @@ }, { "kind": "ui-call", - "line": 181, + "line": 201, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Type a message…", "surface": "android", @@ -5147,7 +5427,7 @@ }, { "kind": "ui-named-argument", - "line": 191, + "line": 211, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Gateway is offline. Text messages are queued and sent after reconnecting.", "surface": "android", @@ -5155,7 +5435,7 @@ }, { "kind": "ui-named-argument", - "line": 220, + "line": 240, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Select thinking level", "surface": "android", @@ -5163,7 +5443,7 @@ }, { "kind": "ui-named-argument", - "line": 244, + "line": 264, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Attach", "surface": "android", @@ -5171,7 +5451,7 @@ }, { "kind": "ui-named-argument", - "line": 252, + "line": 272, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Refresh", "surface": "android", @@ -5179,7 +5459,7 @@ }, { "kind": "ui-named-argument", - "line": 260, + "line": 280, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Abort", "surface": "android", @@ -5187,7 +5467,7 @@ }, { "kind": "ui-named-argument", - "line": 305, + "line": 325, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Send", "surface": "android", @@ -5195,12 +5475,20 @@ }, { "kind": "ui-named-argument", - "line": 331, + "line": 351, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "No commands found", "surface": "android", "id": "native.android.ff4e568bce360d5f" }, + { + "kind": "ui-named-argument", + "line": 315, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt", + "source": "$index.", + "surface": "android", + "id": "native.android.cd2033b0a82a72dd" + }, { "kind": "ui-named-argument", "line": 718, @@ -5209,9 +5497,89 @@ "surface": "android", "id": "native.android.bef360f847488651" }, + { + "kind": "conditional-branch", + "line": 44, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "$quoted\n\n", + "surface": "android", + "id": "native.android.e64a6673728dadcb" + }, + { + "kind": "ui-named-argument", + "line": 70, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Message actions", + "surface": "android", + "id": "native.android.7bf61a1da504c271" + }, + { + "kind": "ui-named-argument", + "line": 78, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Copy", + "surface": "android", + "id": "native.android.fab176fe78fc2500" + }, + { + "kind": "ui-named-argument", + "line": 82, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Select text", + "surface": "android", + "id": "native.android.319b4a29df279bb3" + }, + { + "kind": "ui-named-argument", + "line": 86, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Share", + "surface": "android", + "id": "native.android.fc50d4d9ec3b5a0b" + }, + { + "kind": "ui-named-argument", + "line": 90, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Reply", + "surface": "android", + "id": "native.android.7b896902a024d85f" + }, { "kind": "ui-call", "line": 116, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Done", + "surface": "android", + "id": "native.android.3f40011c9f02a65a" + }, + { + "kind": "ui-toast", + "line": 137, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Message copied", + "surface": "android", + "id": "native.android.833dec4c78ab5eae" + }, + { + "kind": "ui-chooser", + "line": 148, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "Share message", + "surface": "android", + "id": "native.android.7fecaa645b302769" + }, + { + "kind": "ui-toast", + "line": 152, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", + "source": "No app can share this message", + "surface": "android", + "id": "native.android.d06711f88020d74e" + }, + { + "kind": "ui-call", + "line": 121, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt", "source": "Jump to latest", "surface": "android", @@ -5219,7 +5587,7 @@ }, { "kind": "ui-call", - "line": 137, + "line": 142, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt", "source": "Loading session", "surface": "android", @@ -5227,7 +5595,7 @@ }, { "kind": "ui-call", - "line": 157, + "line": 162, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt", "source": "No messages yet", "surface": "android", @@ -5235,15 +5603,23 @@ }, { "kind": "ui-call", - "line": 151, + "line": 161, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Thinking...", "surface": "android", "id": "native.android.a50e44d8f380f9ad" }, + { + "kind": "ui-named-argument", + "line": 177, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", + "source": "Tools", + "surface": "android", + "id": "native.android.ff4a092a02bd5d17" + }, { "kind": "ui-call", - "line": 170, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Running tools...", "surface": "android", @@ -5251,7 +5627,7 @@ }, { "kind": "ui-call", - "line": 173, + "line": 183, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "${display.emoji} ${display.label}", "surface": "android", @@ -5259,7 +5635,7 @@ }, { "kind": "ui-named-argument", - "line": 191, + "line": 201, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "... +${toolCalls.size - 6} more", "surface": "android", @@ -5267,7 +5643,15 @@ }, { "kind": "ui-named-argument", - "line": 236, + "line": 232, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", + "source": "You", + "surface": "android", + "id": "native.android.b3ff30d4e0b3ce58" + }, + { + "kind": "ui-named-argument", + "line": 246, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Retry", "surface": "android", @@ -5275,23 +5659,23 @@ }, { "kind": "ui-named-argument", - "line": 239, + "line": 249, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Delete", "surface": "android", "id": "native.android.b728b15914267f57" }, { - "kind": "conditional-branch", - "line": 307, + "kind": "ui-named-argument", + "line": 281, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", - "source": "You", + "source": "OpenClaw · Live", "surface": "android", - "id": "native.android.1d0bb431f08154a6" + "id": "native.android.dfbd755eb43b4d26" }, { "kind": "conditional-branch", - "line": 308, + "line": 318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "System", "surface": "android", @@ -5299,7 +5683,7 @@ }, { "kind": "conditional-branch", - "line": 309, + "line": 319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw", "surface": "android", @@ -5307,7 +5691,7 @@ }, { "kind": "ui-call", - "line": 335, + "line": 345, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Unsupported attachment", "surface": "android", @@ -5315,7 +5699,7 @@ }, { "kind": "ui-named-argument", - "line": 237, + "line": 236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -5371,7 +5755,7 @@ }, { "kind": "ui-named-argument", - "line": 617, + "line": 620, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading session", "surface": "android", @@ -5379,7 +5763,7 @@ }, { "kind": "ui-named-argument", - "line": 644, + "line": 647, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -5387,7 +5771,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 667, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -5395,7 +5779,7 @@ }, { "kind": "ui-named-argument", - "line": 695, + "line": 698, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -5403,7 +5787,7 @@ }, { "kind": "ui-named-argument", - "line": 696, + "line": 699, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -5411,7 +5795,7 @@ }, { "kind": "ui-named-argument", - "line": 834, + "line": 846, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -5419,7 +5803,15 @@ }, { "kind": "ui-named-argument", - "line": 839, + "line": 848, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "OpenClaw is working", + "surface": "android", + "id": "native.android.d851a32f367f8b31" + }, + { + "kind": "ui-named-argument", + "line": 851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -5427,7 +5819,7 @@ }, { "kind": "ui-named-argument", - "line": 849, + "line": 861, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Thinking", "surface": "android", @@ -5435,7 +5827,7 @@ }, { "kind": "ui-named-argument", - "line": 850, + "line": 862, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -5443,7 +5835,7 @@ }, { "kind": "ui-named-argument", - "line": 969, + "line": 981, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -5451,7 +5843,7 @@ }, { "kind": "ui-named-argument", - "line": 986, + "line": 998, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -5459,7 +5851,7 @@ }, { "kind": "ui-named-argument", - "line": 1048, + "line": 1060, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -5467,7 +5859,7 @@ }, { "kind": "ui-named-argument", - "line": 1138, + "line": 1150, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -5475,7 +5867,7 @@ }, { "kind": "ui-named-argument", - "line": 1153, + "line": 1165, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -5483,7 +5875,7 @@ }, { "kind": "ui-named-argument", - "line": 1168, + "line": 1180, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Voice", "surface": "android", @@ -5491,7 +5883,7 @@ }, { "kind": "ui-named-argument", - "line": 1206, + "line": 1218, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -5499,7 +5891,7 @@ }, { "kind": "conditional-branch", - "line": 1227, + "line": 1239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -5507,7 +5899,7 @@ }, { "kind": "ui-named-argument", - "line": 1283, + "line": 1295, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -5515,7 +5907,7 @@ }, { "kind": "conditional-branch", - "line": 1322, + "line": 1334, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -5523,12 +5915,20 @@ }, { "kind": "ui-named-argument", - "line": 269, + "line": 270, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt", "source": "CHAT ERROR", "surface": "android", "id": "native.android.826804c0189e03d0" }, + { + "kind": "ui-named-argument", + "line": 537, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", + "source": "Local command center", + "surface": "android", + "id": "native.android.64459adde73e63f2" + }, { "kind": "ui-named-argument", "line": 538, @@ -5585,6 +5985,14 @@ "surface": "android", "id": "native.android.e4dc7610b0898e37" }, + { + "kind": "ui-named-argument", + "line": 567, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", + "source": "14 messages · Android", + "surface": "android", + "id": "native.android.9ffcbda6cdb9bc00" + }, { "kind": "ui-named-argument", "line": 571, @@ -5593,6 +6001,14 @@ "surface": "android", "id": "native.android.5c84ed8a78a5c5d5" }, + { + "kind": "ui-named-argument", + "line": 572, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", + "source": "OpenClaw gateway", + "surface": "android", + "id": "native.android.794873efdfcdff4f" + }, { "kind": "ui-named-argument", "line": 577, @@ -5649,6 +6065,14 @@ "surface": "android", "id": "native.android.994ac738d819f2e6" }, + { + "kind": "ui-named-argument", + "line": 592, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", + "source": "OpenClaw will surface approvals, failed jobs, and channel issues here.", + "surface": "android", + "id": "native.android.3457b4ee28d964ee" + }, { "kind": "conditional-branch", "line": 221, @@ -15905,6 +16329,14 @@ "surface": "apple", "id": "native.apple.2e33f01115fd73dd" }, + { + "kind": "ui-named-argument", + "line": 450, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "/Users/you/.ssh/id_ed25519", + "surface": "apple", + "id": "native.apple.5eaffd822af43300" + }, { "kind": "ui-call", "line": 452, @@ -15913,6 +16345,14 @@ "surface": "apple", "id": "native.apple.8bd4eccc71e5668f" }, + { + "kind": "ui-named-argument", + "line": 454, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "/home/you/Projects/openclaw", + "surface": "apple", + "id": "native.apple.c663b8a50de8a7d8" + }, { "kind": "ui-call", "line": 456, @@ -15921,6 +16361,14 @@ "surface": "apple", "id": "native.apple.938ee646aac4ce66" }, + { + "kind": "ui-named-argument", + "line": 458, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "/Applications/OpenClaw.app/.../openclaw", + "surface": "apple", + "id": "native.apple.09f99e863e081f9f" + }, { "kind": "ui-call", "line": 463, diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index 449ba9daa39..b22ae96c742 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index d734ad20a61..cd339e64a28 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -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 = _requestedHomeDestination private val _startOnboardingAtGatewaySetup = MutableStateFlow(false) val startOnboardingAtGatewaySetup: StateFlow = _startOnboardingAtGatewaySetup - private val _chatDraft = MutableStateFlow(null) - val chatDraft: StateFlow = _chatDraft + private val _chatDraft = MutableStateFlow(null) + val chatDraft: StateFlow = _chatDraft private val _pendingAssistantAutoSend = MutableStateFlow(null) val pendingAssistantAutoSend: StateFlow = _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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 58e22bc0495..351b591d7d5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -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) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt new file mode 100644 index 00000000000..be8ae2a9a6d --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt @@ -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): 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() + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index 64ede48e667..c2384a45a15 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -46,6 +46,7 @@ fun ChatMessageListCard( outboxItems: List = 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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 71e8932368a..3e3471c2b70 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -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) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 41cd18acac6..307b9bf71fe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -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, 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), + ) + } } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 2296d4abb7b..b33dd2957c1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -173,6 +173,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { ), onRetryOutbox = viewModel::retryChatOutboxCommand, onDeleteOutbox = viewModel::deleteChatOutboxCommand, + onReplyMessage = viewModel::setChatReplyDraft, ) Row(modifier = Modifier.fillMaxWidth().imePadding()) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt index 052a481069f..5ebdabcfcad 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt @@ -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, ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt new file mode 100644 index 00000000000..1c7260721eb --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt @@ -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)) + } +} diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index 5a8f2a2b57f..5b59a62984c 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -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), ); diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index 82679124aea..80859704835 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -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);