feat(android): simplify chat input and unify attachment selection (#143099)

Unify the Android chat composer and attachment picker while preserving
conversation-bound drafts, media ownership and location precision.

Retain selection and page navigation, explicit voice controls, updated
Android guidance and regression coverage for the corrected boundaries.

Closes #143044.

Co-authored-by: Ivan Kuznetsov <ivan@ikuznetsov.com>
Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
This commit is contained in:
Ivan Kuznetsov 2026-09-20 16:13:46 +01:00 • committed by GitHub
parent 75b463e1c8
commit 4a39ca6539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1220 additions and 297 deletions

View file

@ -139,6 +139,7 @@
{"id":"native.android.d116cefbb5d344ab","source":"Add attachment","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.f2a6acfa3cdc099b","source":"Add one wake word or phrase per field. Then say one before your command.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.6ee85e83929b808f","source":"Add wake phrase","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.6c1dbafd33c7b8e9","source":"Add your current location to the draft. Review it before sending.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.45ef19160aa90e3f","source":"Additional images hidden: ${omittedImageCount}","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.26eeb49a3ab31d30","source":"Address","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.7271a2beeb0f5d46","source":"Admin","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt"}]},
@ -312,7 +313,7 @@
{"id":"native.android.0a774596d8810f70","source":"Choose and inspect the assistants available on this gateway.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.8fa35b45a2badc3f","source":"Choose another image","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.8446b9497ac41a24","source":"Choose device permissions","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.b4aa1d6dd4f70982","source":"Choose from gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.b4aa1d6dd4f70982","source":"Choose from gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.0c642036b88b0c1c","source":"Choose what reaches OpenClaw.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.efe2ac7561682d4d","source":"Choose what this phone can share.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.3c87c390a52ad030","source":"ClawHub did not return an installable version for ${skill.reference}.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
@ -404,7 +405,10 @@
{"id":"native.android.6179bf99c7c727da","source":"Consecutive Errors","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.6ee64e9492085d54","source":"Consecutive Skips","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.2654611c5a5a572c","source":"Contacts","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.6f72735e3fd76d72","source":"Context","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.fc78f76570e54463","source":"Context $percent","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.0c6684020b20cf89","source":"Context window","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.d7ca7ec0ab428568","source":"Context –","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.990da7a25dabb002","source":"Context: $detail","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.d6d703d18a7af3cf","source":"Continue","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ProviderSignInDialog.kt"}]},
{"id":"native.android.f87c89d944f56c38","source":"Control other apps","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt"}]},
@ -427,6 +431,7 @@
{"id":"native.android.e74d1437a5e050af","source":"Could not copy widget image","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatInlineWidgetView.kt"}]},
{"id":"native.android.0a354500882ec0d6","source":"Could not disable skill.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
{"id":"native.android.f79aec3ad54a77c4","source":"Could not enable skill.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
{"id":"native.android.c93bb38744c9ad16","source":"Could not get your location. Check device location settings and try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.b23629d72bfebc10","source":"Could not inspect Skill Workshop proposal.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
{"id":"native.android.d37c22282a1c0b5c","source":"Could not install ${slug} from ClawHub.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
{"id":"native.android.cee7ae53f86ff1c8","source":"Could not load ClawHub details for ${skill.reference}.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]},
@ -539,7 +544,7 @@
{"id":"native.android.a6e7139eff76416a","source":"Diagram temporarily unavailable. Open Diagram options and choose Retry diagram. You can still read or copy its source.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMermaidBlock.kt"}]},
{"id":"native.android.d2ac215d97d143cf","source":"Diagram unavailable. Check the syntax or simplify the diagram. You can still read or copy its source.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMermaidBlock.kt"}]},
{"id":"native.android.d7f735c1c846153f","source":"Dictate","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.5762cf2e05f1b649","source":"Dictation","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"},{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]},
{"id":"native.android.5762cf2e05f1b649","source":"Dictation","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"},{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]},
{"id":"native.android.20dc6eb26a60b243","source":"Dictation failed. Tap the microphone to try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"}]},
{"id":"native.android.1bbbdb0fb0e3a5a3","source":"Disable","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt"}]},
{"id":"native.android.08b941c014e915d8","source":"Disabled","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]},
@ -608,7 +613,8 @@
{"id":"native.android.fccd9ea812fcb9a7","source":"Fast mode","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.fb4539c805474a4a","source":"Faster","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.f74ff12138dba28c","source":"Faster responses, higher usage of limits.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.a39fdbef34bb94f7","source":"Files","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.d931ca172950d3af","source":"File","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.a39fdbef34bb94f7","source":"Files","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.76e464cbc35b12dc","source":"Files unavailable","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"}]},
{"id":"native.android.df6908aa0d65572b","source":"Find a model","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.98780014fd21c179","source":"Find a session","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
@ -636,6 +642,7 @@
{"id":"native.android.1254c17b35dde7ec","source":"Full access","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.54d03602f88f7164","source":"Full access requires operator.admin access.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.d5e39f78304400ca","source":"Full text is no longer available.","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.06a4b6cd363a8cf6","source":"Gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.44f6b5c185ccdd8a","source":"Gateway","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.74392ebabe49e1f4","source":"Gateway Pending","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.3eed634369e3fad1","source":"Gateway Required","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SystemAgentSettingsScreen.kt"}]},
@ -686,6 +693,7 @@
{"id":"native.android.514ae6a832c165e5","source":"Gateways","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarGatewayControl.kt"}]},
{"id":"native.android.a14a2fa261e5da6d","source":"Generate a QR code.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.07b388c4170d3d9a","source":"Generating voice…","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt"}]},
{"id":"native.android.4374b928762021ee","source":"Getting location…","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.e9a44c20e8be81a6","source":"Git commit %1$s","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]},
{"id":"native.android.d290ea075b936750","source":"Git commit copied","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]},
{"id":"native.android.f626602bddb5e2d8","source":"Go back","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
@ -794,7 +802,8 @@
{"id":"native.android.f72c3674b897b26b","source":"Loading thread","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.39d0f39e7577de93","source":"Lobstering","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]},
{"id":"native.android.50d7b864e3049c14","source":"Local command center","surface":"android","sites":[{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]},
{"id":"native.android.26ead13667e80461","source":"Location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.26ead13667e80461","source":"Location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.bc2e4b23ec27a152","source":"Location permission is required. Allow it in Android settings or try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.969edab95fef7601","source":"Locked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]},
{"id":"native.android.4a16f82863c7c846","source":"Locked session model","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.41683a66dca7c816","source":"Log Entry","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt"}]},
@ -1114,7 +1123,7 @@
{"id":"native.android.d5d4090ede4afee1","source":"Phone ready","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.fbd5c43d1da211e8","source":"Phone unavailable. Tap Reply to try again.","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.053f7882b3d40f54","source":"Phone-controlled","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
{"id":"native.android.1fb9e44a74c5b89c","source":"Photos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.1fb9e44a74c5b89c","source":"Photos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.d9963d6e679fad17","source":"Pin","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]},
{"id":"native.android.c6a60daf61c82d06","source":"Pin model","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.3f461a5a2955e4cb","source":"Pinching","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]},
@ -1673,6 +1682,7 @@
{"id":"native.android.6852e1c8b2caa7a7","source":"Use Refresh chat to check Gateway health.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.5b1e5d258bb4cfac","source":"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt"}]},
{"id":"native.android.ac59b3725ddbc6df","source":"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.b0e4b3b47eea5d1a","source":"Use current location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.bb84dc61d88cb8ad","source":"Use only on a trusted private network.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt"}]},
{"id":"native.android.36237c9a6f58c8fd","source":"Use setup code","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]},
{"id":"native.android.d9842759169543c8","source":"Use system trust","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayTrustDialog.kt"}]},
@ -1690,7 +1700,7 @@
{"id":"native.android.4726d7b86dae14c2","source":"Version %1$s","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]},
{"id":"native.android.950f4d664cfa14ce","source":"Version and update","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]},
{"id":"native.android.3e463214b65f9187","source":"Video","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.6baba6d73b10b7dd","source":"Videos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.6baba6d73b10b7dd","source":"Videos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]},
{"id":"native.android.c81431ac22026d3c","source":"View","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]},
{"id":"native.android.422eebe3d68182c2","source":"View a machine screen","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]},
{"id":"native.android.a7563fca20d2df98","source":"View all","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageDisclosure.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
@ -1701,6 +1711,7 @@
{"id":"native.android.d01c87fe40aa4a44","source":"Voice Wake","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
{"id":"native.android.5d7610fd72c5cf1b","source":"Voice note","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt"}]},
{"id":"native.android.21399aeaaf3f5db1","source":"Voice note · ${formatVoiceNoteDuration(duration)}","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]},
{"id":"native.android.4e6ec17cd2dd01fc","source":"Voice options","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"}]},
{"id":"native.android.de8a2316f9635d74","source":"Voice reply timed out; retrying queued turn","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt"}]},
{"id":"native.android.b722b22c96e92d3c","source":"Voice request failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt"}]},
{"id":"native.android.5e79bb0182a8ee02","source":"Wait for the current response to finish before starting a new chat.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt"}]},

View file

@ -5,7 +5,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
### App features
- Pair with a Gateway using a QR code, setup code, or manual connection. Gateway credentials are stored encrypted.
- Stream chat replies, choose models and reasoning effort, manage session permissions, and expand task progress. The compact composer keeps one control row; tap the model name for permissions and usage details, or the effort dial for Fast mode. Dictation, voice messages, and Talk are part of Chat, not a separate Voice tab.
- Stream chat replies, choose models and reasoning effort, manage session permissions, and expand task progress. The composer keeps attachments to the left of the draft and voice input to the right, with model, effort, and context controls below. Tap the microphone for dictation. While listening, a Stop icon replaces the microphone; tap it to finish listening. While starting or transcribing, a Close icon cancels that attempt. Long-press for voice messages or Talk. Tap the model name for permissions and usage details, or the effort dial for Fast mode.
- Select agents, pin sessions, and browse available native session catalogs from the sidebar. Connecting creates or adopts a dedicated Android session without resetting its history. Native sessions keep their runtime-owned model: Android shows that ownership instead of offering a model change. New session starts independently of the current native thread. Generic child-session forks and new worktrees are unavailable for those sessions; supported message-level forks remain available.
- Search from Overview or Settings to find settings by their displayed name or category, alongside quick actions and recent threads. Local destinations such as Appearance, Profile, and Licenses work without connecting a Gateway. Back from a settings detail returns to the screen that opened search; Desktop appears only when the connected Gateway supports it.
- Choose a theme family, color mode, accent, and app language in **Settings → Appearance**. Theme and accent edits sync with a connected writable profile. Read-only or unknown-profile edits, including new edits after restarting offline, stay on the device; choose them again after connecting to sync. Already profile-bound edits wait for that profile to reconnect, without discarding or replacing newer device-local choices.
@ -120,14 +120,24 @@ space is limited; opening the keyboard does not move them to another region.
If the keyboard covers that region entirely, dismiss the keyboard to reach the
prompt again.
Chat actions and Add attachment (Photos, Videos, Files) menus stay in the safe
Chat actions and voice options menus stay in the safe
region containing their trigger. These popups remain focusable without becoming
keyboard (IME) targets. If folds, insets, or layout changes invalidate an open
menu, it closes without choosing an action. Reopen it explicitly when space
permits; it does not reopen automatically when the layout recovers. Dismissing
the menu does not reset Chat's draft, editor, or reader state.
Chat's Model picker, its Permissions page, Thinking effort, Background tasks, and Switch branch sheets initially
Chat's attachment picker opens on Gallery, with File and Location tabs below.
Gallery opens Android's system photo picker without whole-library permission.
The embedded preview is not used: its Done callback can precede pending URI grants
and revocations, so it cannot supply a reliable final selection. File also provides
video selection. Location requests foreground permission and adds a map link to
the draft for review before sending. It respects the app's Precise Location toggle,
including changes made while capture is pending, using the same approximate grid
as node location responses. Closing a picker preserves the draft; switching
conversations retires the opening.
Chat's attachment picker, Model picker, its Permissions page, Thinking effort, Background tasks, and Switch branch sheets initially
use the largest safe region with usable sheet space, not the trigger's region.
They keep that region while it remains usable. Valid geometry changes retain
the same sheet and local state. An invalid opening closes without selecting an

View file

@ -0,0 +1,40 @@
package ai.openclaw.app.node
import android.location.Location
import android.location.LocationManager
/** Shared disclosure boundary for node responses and user-requested chat attachments. */
internal class LocationDisclosure(
private val preciseEnabled: () -> Boolean,
private val hasFinePermission: () -> Boolean,
private val capture: suspend (List<String>, Long?, Long) -> Location,
) {
data class Fix(
val location: Location,
val isPrecise: Boolean,
)
suspend fun getLocation(
maxAgeMs: Long?,
timeoutMs: Long,
allowPrecise: Boolean = true,
): Fix {
val initiallyPrecise = allowPrecise && preciseEnabled() && hasFinePermission()
val providers =
if (initiallyPrecise) {
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
} else {
listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
val fix = capture(providers, maxAgeMs, timeoutMs)
// Recheck after suspension, and never upgrade an operation that began approximate.
val isPrecise = initiallyPrecise && preciseEnabled() && hasFinePermission()
return Fix(if (isPrecise) fix else coarsener.coarsen(fix), isPrecise)
}
private companion object {
// One process-wide grid across every disclosure surface and reopening. Fresh random
// offsets per request or per sheet could be averaged to recover a precise position.
val coarsener by lazy { LocationCoarsener() }
}
}

View file

@ -6,7 +6,6 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
@ -70,7 +69,12 @@ class LocationHandler private constructor(
private val backgroundLocationEnabled: () -> Boolean,
private val locationPreciseEnabled: () -> Boolean,
) {
private val coarsener by lazy { LocationCoarsener() }
private val disclosure =
LocationDisclosure(
preciseEnabled = locationPreciseEnabled,
hasFinePermission = { dataSource.hasFinePermission(appContext) },
capture = dataSource::fetchLocation,
)
constructor(
appContext: Context,
@ -135,21 +139,8 @@ class LocationHandler private constructor(
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
// A request may ask for less precision, but cannot override the user's limits.
val initiallyPrecise =
desiredAccuracy != "coarse" && locationPreciseEnabled() && dataSource.hasFinePermission(appContext)
val providers =
if (initiallyPrecise) {
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
} else {
listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
try {
val fix = dataSource.fetchLocation(providers, maxAgeMs, timeoutMs)
// Capture can suspend and switch dispatchers. Recheck at the response producer,
// without another suspension, and never upgrade a request that began approximate.
val isPrecise = initiallyPrecise && locationPreciseEnabled() && dataSource.hasFinePermission(appContext)
val location = if (isPrecise) fix else coarsener.coarsen(fix)
val (location, isPrecise) = disclosure.getLocation(maxAgeMs, timeoutMs, allowPrecise = desiredAccuracy != "coarse")
val payload =
buildJsonObject {
put("lat", location.latitude)

View file

@ -0,0 +1,237 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.NodeApp
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.node.LocationCaptureManager
import ai.openclaw.app.node.LocationDisclosure
import ai.openclaw.app.ui.AppModalBottomSheet
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.foldAwareSheet
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import java.util.Locale
private enum class AttachmentTab { Gallery, File, Location }
/** One owner-bound opening; external results still pass through the composer's media leases. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ChatAttachmentSheet(
opening: ChatModelPickerSession,
admit: () -> Boolean,
onDismiss: () -> Unit,
onBrowseGallery: () -> Unit,
onPickFile: () -> Unit,
onPickVideo: () -> Unit,
onLocation: (String) -> Unit,
) {
var tab by remember { mutableStateOf(AttachmentTab.Gallery) }
AppModalBottomSheet(
modifier = Modifier.foldAwareSheet(opening.geometry),
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
) {
BackHandler { onDismiss() }
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
Text(
text =
when (tab) {
AttachmentTab.Gallery -> nativeString("Gallery")
AttachmentTab.File -> nativeString("File")
AttachmentTab.Location -> nativeString("Location")
},
style = ClawTheme.type.label,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
Box(Modifier.fillMaxWidth().weight(1f).heightIn(min = 96.dp)) {
when (tab) {
AttachmentTab.Gallery -> {
// Embedded completion is not a settled URI selection; the activity result is.
AttachmentActions {
Button(onClick = { if (admit()) onBrowseGallery() }) { Text(nativeString("Choose from gallery")) }
}
}
AttachmentTab.File -> {
AttachmentActions {
Button(onClick = { if (admit()) onPickFile() }) { Text(nativeString("Files")) }
TextButton(onClick = { if (admit()) onPickVideo() }) { Text(nativeString("Videos")) }
}
}
AttachmentTab.Location -> {
LocationAttachment(admit = admit, onLocation = onLocation)
}
}
}
Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AttachmentTab.entries.forEach { item ->
Surface(
onClick = { if (admit()) tab = item },
modifier =
Modifier.weight(1f).semantics {
selected = tab == item
role = Role.Tab
},
shape = RoundedCornerShape(20.dp),
color = if (tab == item) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (tab == item) ClawTheme.colors.primaryText else ClawTheme.colors.text,
) {
Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
when (item) {
AttachmentTab.Gallery -> Icons.Default.Photo
AttachmentTab.File -> Icons.Default.Description
AttachmentTab.Location -> Icons.Default.LocationOn
},
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Text(
when (item) {
AttachmentTab.Gallery -> nativeString("Gallery")
AttachmentTab.File -> nativeString("File")
AttachmentTab.Location -> nativeString("Location")
},
style = ClawTheme.type.caption,
)
}
}
}
}
}
}
}
@Composable
private fun AttachmentActions(content: @Composable () -> Unit) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { content() }
}
@Composable
internal fun LocationAttachment(
admit: () -> Boolean,
onLocation: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val app = context.applicationContext as NodeApp
val disclosure =
remember(app) {
LocationDisclosure(
preciseEnabled = { app.prefs.locationPreciseEnabled.value },
hasFinePermission = {
ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
},
capture = LocationCaptureManager(app)::getLocation,
)
}
var busy by remember { mutableStateOf(false) }
var failed by remember { mutableStateOf(false) }
var permissionDenied by remember { mutableStateOf(false) }
fun capture() {
if (busy || !admit()) return
busy = true
failed = false
permissionDenied = false
scope.launch {
try {
val location =
disclosure
.getLocation(
maxAgeMs = 60_000,
timeoutMs = 15_000,
).location
if (admit()) {
onLocation(String.format(Locale.ROOT, "https://www.google.com/maps?q=%.6f,%.6f", location.latitude, location.longitude))
}
} catch (_: TimeoutCancellationException) {
currentCoroutineContext().ensureActive()
failed = true
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
failed = true
} finally {
busy = false
}
}
}
val permission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result.values.any { it }) capture() else permissionDenied = true
}
AttachmentActions {
Text(nativeString("Add your current location to the draft. Review it before sending."))
if (failed) Text(nativeString("Could not get your location. Check device location settings and try again."), color = ClawTheme.colors.warning)
if (permissionDenied) Text(nativeString("Location permission is required. Allow it in Android settings or try again."), color = ClawTheme.colors.warning)
Button(
enabled = !busy,
onClick = {
if (admit()) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
capture()
} else {
permission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION))
}
}
},
) { Text(if (busy) nativeString("Getting location…") else nativeString("Use current location")) }
}
}

View file

@ -467,10 +467,11 @@ internal fun ChatComposerMicButton(
onToggleDictation: () -> Unit,
onStartVoiceNote: () -> Unit,
modifier: Modifier = Modifier,
onOpenVoiceOptions: (() -> Unit)? = null,
) {
val dictationActive = dictationState.isActive
val hapticFeedback = LocalHapticFeedback.current
val interactionEnabled = dictationActive || dictationEnabled || voiceNoteEnabled
val interactionEnabled = dictationActive || dictationEnabled || voiceNoteEnabled || onOpenVoiceOptions != null
val longPressAction: (() -> Unit)? =
if (voiceNoteEnabled) {
{
@ -497,23 +498,30 @@ internal fun ChatComposerMicButton(
enabled = interactionEnabled,
onClickLabel = dictationActionLabel,
role = Role.Button,
onLongClickLabel = if (voiceNoteEnabled) voiceNoteRecordLabel() else null,
onLongClick = longPressAction,
onLongClickLabel =
if (onOpenVoiceOptions != null) {
nativeString("Voice options")
} else if (voiceNoteEnabled) {
voiceNoteRecordLabel()
} else {
null
},
onLongClick = if (dictationActive) null else onOpenVoiceOptions ?: longPressAction,
onClick = {
if (dictationActive || dictationEnabled) onToggleDictation()
if (dictationActive || dictationEnabled) onToggleDictation() else onOpenVoiceOptions?.invoke()
},
),
shape = CircleShape,
color = Color.Transparent,
contentColor =
when {
dictationActive -> ClawTheme.colors.primaryText
dictationActive -> ClawTheme.colors.primary
dictationEnabled || voiceNoteEnabled -> ClawTheme.colors.textMuted
else -> ClawTheme.colors.textSubtle
},
) {
Box(
modifier = Modifier.padding(8.dp).background(if (dictationActive) ClawTheme.colors.primary else Color.Transparent, CircleShape),
modifier = Modifier.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Icon(

View file

@ -44,7 +44,7 @@ internal fun loadPickedMediaOrDocumentAttachment(
val mimeType = normalizeSharedAttachmentMimeType(resolver.getType(uri))
if (!isStageableSharedAttachmentMimeType(mimeType)) throw IllegalStateException("unsupported attachment")
val kind = sharedAttachmentKindForMimeType(mimeType)
if (kind == null || kind == SharedAttachmentKind.Image) throw IllegalStateException("unsupported attachment")
if (kind == null) throw IllegalStateException("unsupported attachment")
return loadSharedAttachment(resolver, SharedAttachment(uri = uri, kind = kind, mimeType = requireNotNull(mimeType)))
}

View file

@ -275,7 +275,6 @@ internal fun resolvePendingAssistantAutoSend(
}
internal enum class ChatComposerPrimaryAction {
StartTalk,
None,
Stop,
Send,
@ -291,7 +290,7 @@ internal fun resolveChatComposerPrimaryAction(
hasContent && !talkActive -> ChatComposerPrimaryAction.Send
runActive -> ChatComposerPrimaryAction.Stop
talkActive -> ChatComposerPrimaryAction.None
else -> ChatComposerPrimaryAction.StartTalk
else -> ChatComposerPrimaryAction.None
}
internal object ChatUserMessageDisclosurePolicy {
@ -580,6 +579,12 @@ internal fun ChatScreen(
viewModel.isCurrentChatComposerOwner(expected)
}
}
val attachmentPicker =
remember(viewModel, pickerActivity, pickerView, lifecycleOwner) {
ChatModelPickerSessionOwner(pickerActivity, pickerView, lifecycleOwner.lifecycle) { expected ->
viewModel.isCurrentChatComposerOwner(expected)
}
}
val branchPicker =
remember(viewModel, pickerActivity, pickerView, lifecycleOwner) {
ChatModelPickerSessionOwner(pickerActivity, pickerView, lifecycleOwner.lifecycle) { expected ->
@ -603,6 +608,7 @@ internal fun ChatScreen(
backgroundTasks.publishFeatures(publication)
reviewDiff.publishFeatures(publication)
branchPicker.publishFeatures(publication)
attachmentPicker.publishFeatures(publication)
}
SideEffect {
modelPicker.refreshTarget()
@ -610,15 +616,17 @@ internal fun ChatScreen(
backgroundTasks.refreshTarget()
reviewDiff.refreshTarget()
branchPicker.refreshTarget()
attachmentPicker.refreshTarget()
branchOpening?.let { isCurrentBranchOpening(it) }
}
DisposableEffect(modelPicker, effortPicker, backgroundTasks, reviewDiff, branchPicker) {
DisposableEffect(modelPicker, effortPicker, backgroundTasks, reviewDiff, branchPicker, attachmentPicker) {
onDispose {
modelPicker.dispose()
effortPicker.dispose()
backgroundTasks.dispose()
reviewDiff.dispose()
branchPicker.dispose()
attachmentPicker.dispose()
}
}
var detailsExpanded by rememberSaveable { mutableStateOf(false) }
@ -678,38 +686,39 @@ internal fun ChatScreen(
val dictationState by dictationController.state.collectAsState()
val dictationPartialTranscript by dictationController.partialTranscript.collectAsState()
val dictationActive = dictationState.isActive
val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult
if (uris.isNullOrEmpty()) {
composerState.cancelMediaAcquisition(lease.authorizationId)
return@rememberLauncherForActivityResult
}
val importOwner =
if (shouldMigrateComposerDraft(lease.owner, currentPickerOwner, currentPickerMainSessionKey)) {
currentPickerOwner
} else {
lease.owner
fun importGalleryMedia(
lease: ChatComposerMediaLease,
uris: List<android.net.Uri>,
) {
if (uris.isEmpty()) {
composerState.cancelMediaAcquisition(lease.authorizationId)
return
}
val importOwner =
if (shouldMigrateComposerDraft(lease.owner, currentPickerOwner, currentPickerMainSessionKey)) currentPickerOwner else lease.owner
viewModel.importChatComposerAttachments(
owner = importOwner,
mediaAuthorizationId = lease.authorizationId,
mainSessionKey = currentPickerMainSessionKey,
expectedCount = uris.size,
) {
uris.take(CHAT_COMPOSER_MAX_ATTACHMENTS).mapNotNull { uri ->
try {
loadPickedMediaOrDocumentAttachment(resolver, uri)
} catch (err: CancellationException) {
throw err
} catch (_: Exception) {
null
}
val selectedUris = uris.take(8)
viewModel.importChatComposerAttachments(
owner = importOwner,
mediaAuthorizationId = lease.authorizationId,
mainSessionKey = currentPickerMainSessionKey,
expectedCount = uris.size,
) {
selectedUris
.mapNotNull { uri ->
try {
loadSizedImageAttachment(resolver, uri)
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
null
}
}
}
}
}
val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(CHAT_COMPOSER_MAX_ATTACHMENTS)) { uris ->
val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult
importGalleryMedia(lease, uris)
}
val pickMediaOrDocument =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val lease = filePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult
@ -1105,24 +1114,7 @@ internal fun ChatScreen(
commands = chatCommands,
onOpenEffortPicker = { effortPicker.open(composerOwner, sessionKey) },
onOpenModelPicker = { modelPicker.open(composerOwner, sessionKey) },
onPickImages = {
if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer
imagePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
pickImages.launch("image/*")
},
onPickAudioOrDocument = {
if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer
filePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
pickMediaOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES)
},
onPickVideo = {
if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer
filePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
pickMediaOrDocument.launch(SHARED_VIDEO_MIME_TYPES)
},
onOpenAttachments = { attachmentPicker.open(composerOwner, sessionKey) },
onRemoveAttachment = { id -> composerState.removeAttachments(composerOwner, setOf(id)) },
voiceNoteState = voiceNoteState,
voiceNoteElapsedMs = voiceNoteElapsedMs,
@ -1222,6 +1214,56 @@ internal fun ChatScreen(
)
}
attachmentPicker.visible?.let { opening ->
key(opening) {
ChatAttachmentSheet(
opening = opening,
admit = { attachmentPicker.admit(opening) },
onDismiss = { attachmentPicker.retire(opening) },
onBrowseGallery = {
if (attachmentPicker.admit(opening)) {
val owner = opening.composerOwner
val authorizationId = composerState.beginMediaAcquisition(owner)
if (authorizationId != null) {
imagePickerOwnerCheckpoint.begin(owner, authorizationId)
pickImages.launch(androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
}
attachmentPicker.retire(opening)
}
},
onPickFile = {
if (attachmentPicker.admit(opening)) {
val owner = opening.composerOwner
val authorizationId = composerState.beginMediaAcquisition(owner)
if (authorizationId != null) {
filePickerOwnerCheckpoint.begin(owner, authorizationId)
pickMediaOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES)
}
attachmentPicker.retire(opening)
}
},
onPickVideo = {
if (attachmentPicker.admit(opening)) {
val owner = opening.composerOwner
val authorizationId = composerState.beginMediaAcquisition(owner)
if (authorizationId != null) {
filePickerOwnerCheckpoint.begin(owner, authorizationId)
pickMediaOrDocument.launch(SHARED_VIDEO_MIME_TYPES)
}
attachmentPicker.retire(opening)
}
},
onLocation = { location ->
if (attachmentPicker.admit(opening)) {
val owner = opening.composerOwner
inputDrafts[owner] = mergeSharedChatText(location, inputDrafts[owner])
attachmentPicker.retire(opening)
}
},
)
}
}
effortPicker.visible?.let { opening ->
key(opening) {
ChatEffortSheet(
@ -3155,7 +3197,10 @@ private fun minimumChatLineHeight(style: TextStyle): Int {
@Composable
private fun minimumChatInputHeight(): Dp {
val lineHeight = minimumChatLineHeight(chatDraftStyle())
val style = chatDraftStyle()
val wrapped = rememberTextMeasurer().measure("H\nH", style = style)
// Multiline paragraph rounding can make a line one pixel taller than a single-line measurement.
val lineHeight = maxOf(minimumChatLineHeight(style), ceil(wrapped.getLineBottom(0) - wrapped.getLineTop(0)).toInt())
return with(LocalDensity.current) {
// Match each separately rounded editor/action padding and the text's full pixel line.
(
@ -3198,9 +3243,7 @@ private fun ChatComposer(
commands: List<ChatCommandEntry>,
onOpenEffortPicker: () -> Unit,
onOpenModelPicker: () -> Unit,
onPickImages: () -> Unit,
onPickAudioOrDocument: () -> Unit,
onPickVideo: () -> Unit,
onOpenAttachments: () -> Unit,
onRemoveAttachment: (String) -> Unit,
voiceNoteState: VoiceNoteRecorderState,
voiceNoteElapsedMs: Long,
@ -3347,9 +3390,7 @@ private fun ChatComposer(
onOpenDetails = if (compactHeight) ({ onDetailsExpandedChange(true) }) else null,
value = value,
onValueChange = onValueChange,
onPickImages = onPickImages,
onPickAudioOrDocument = onPickAudioOrDocument,
onPickVideo = onPickVideo,
onOpenAttachments = onOpenAttachments,
onStartVoiceNote = onStartVoiceNote,
recordVoiceNoteEnabled = ownerReady && recordVoiceNoteEnabled,
dictationState = dictationState,
@ -3444,7 +3485,10 @@ internal fun resolveChatEffortPosition(
return ChatEffortPosition(optionIndex = selectedIndex, fraction = fraction)
}
internal fun chatEffortNeedleAngle(position: ChatEffortPosition): Float? = position.fraction?.let { 150f + it * 240f }
internal fun chatEffortNeedleAngle(
position: ChatEffortPosition,
fastMode: Boolean = false,
): Float? = if (fastMode) 330f else position.fraction?.let { 180f + it * 120f }
internal fun chatEffortVisualFraction(
fraction: Float,
@ -3467,6 +3511,7 @@ private fun ChatThinkingLevelPicker(
val description = nativeString("Thinking")
val dialColor = if (enabled) ClawTheme.colors.textMuted else ClawTheme.colors.textSubtle
val needleColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle
val fastZoneColor = ClawTheme.colors.danger.copy(alpha = if (enabled) 1f else 0.5f)
Surface(
onClick = onOpen,
enabled = enabled,
@ -3479,40 +3524,28 @@ private fun ChatThinkingLevelPicker(
color = Color.Transparent,
) {
Box(contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(20.dp).testTag("chat-thinking-gauge")) {
Canvas(modifier = Modifier.matchParentSize()) {
val dialStrokeWidth = 1.5.dp.toPx()
val needleStrokeWidth = 2.dp.toPx()
// The dial omits its bottom arc; center the visible ink with the other controls.
translate(top = size.height / 8f) {
drawArc(color = dialColor, startAngle = 150f, sweepAngle = 240f, useCenter = false, style = Stroke(width = dialStrokeWidth, cap = StrokeCap.Round))
// An unadvertised effective level is not the minimum/Off position.
chatEffortNeedleAngle(position)?.let { angle ->
rotate(angle) {
drawLine(
color = needleColor,
start = center,
end = Offset(size.width * 0.82f, center.y),
strokeWidth = needleStrokeWidth,
cap = StrokeCap.Round,
)
}
drawCircle(color = needleColor, radius = 1.25.dp.toPx(), center = center)
}
}
Canvas(modifier = Modifier.size(22.dp).testTag("chat-thinking-gauge")) {
val radius = size.width * 0.43f
val hub = Offset(center.x, size.height * 0.72f)
val bounds = Offset(hub.x - radius, hub.y - radius)
val dialSize = Size(radius * 2, radius * 2)
val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Butt)
for (start in listOf(180f, 225f, 270f)) {
drawArc(dialColor, start, 39f, false, bounds, dialSize, style = stroke)
}
if (fastMode) {
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(8.dp)
.background(ClawTheme.colors.surface, CircleShape)
.testTag("chat-fast-mode-badge"),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(7.dp), tint = ClawTheme.colors.primary)
drawArc(fastZoneColor, 315f, 45f, false, bounds, dialSize, style = stroke)
// Fast mode occupies the red zone; otherwise the needle reflects advertised effort.
chatEffortNeedleAngle(position, fastMode)?.let { angle ->
rotate(angle, pivot = hub) {
drawLine(
color = needleColor,
start = hub,
end = Offset(hub.x + radius * 0.83f, hub.y),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
}
drawCircle(color = needleColor, radius = 1.5.dp.toPx(), center = hub)
}
}
}
@ -4247,9 +4280,7 @@ private fun ChatInputPill(
onOpenDetails: (() -> Unit)?,
value: String,
onValueChange: (String) -> Unit,
onPickImages: () -> Unit,
onPickAudioOrDocument: () -> Unit,
onPickVideo: () -> Unit,
onOpenAttachments: () -> Unit,
onStartVoiceNote: () -> Unit,
recordVoiceNoteEnabled: Boolean,
dictationState: ChatDictationState,
@ -4279,7 +4310,7 @@ private fun ChatInputPill(
modifier: Modifier = Modifier,
) {
val hardwareEnterHandler = remember { PhysicalChatSendKeyHandler() }
var attachmentMenuExpanded by remember { mutableStateOf(false) }
var voiceOptionsExpanded by remember { mutableStateOf(false) }
val draftStyle = chatDraftStyle()
Surface(
@ -4291,47 +4322,119 @@ private fun ChatInputPill(
shadowElevation = 1.dp,
) {
Column {
ChatTextFieldValueAdapter(
value = value,
onValueChange = onValueChange,
keyHandler = hardwareEnterHandler,
) { textFieldValue, updateTextFieldValue ->
BasicTextField(
value = textFieldValue,
enabled = inputEnabled,
// A pending IME callback must not edit the draft behind Details.
onValueChange = { if (inputEnabled) updateTextFieldValue(it) },
textStyle = draftStyle.copy(color = ClawTheme.colors.text),
cursorBrush = SolidColor(ClawTheme.colors.primary),
minLines = 1,
maxLines = 6,
modifier =
Modifier
.fillMaxWidth()
// Reserve the action row before measuring the draft in the IME viewport.
.weight(1f, fill = false)
.heightIn(min = ClawTheme.spacing.touchTarget)
.padding(start = 14.dp, end = 14.dp, top = 8.dp, bottom = 4.dp)
.onPreInterceptKeyBeforeSoftKeyboard { event ->
inputEnabled &&
hardwareEnterHandler.handle(
event = event,
sendEnabled = sendEnabled,
textEmpty = textFieldValue.text.isEmpty(),
compositionActive = textFieldValue.composition != null,
onSend = onSend,
)
},
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) {
// BasicTextField's line limit does not constrain its decoration.
Text(text = nativeString("Message OpenClaw"), style = draftStyle, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
innerTextField()
Row(
modifier = Modifier.fillMaxWidth().weight(1f, fill = false).padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onOpenAttachments, enabled = inputEnabled, modifier = Modifier.size(ClawTheme.spacing.touchTarget)) {
Icon(Icons.Default.Add, contentDescription = nativeString("Add attachment"), modifier = Modifier.size(20.dp))
}
ChatTextFieldValueAdapter(
value = value,
onValueChange = onValueChange,
keyHandler = hardwareEnterHandler,
) { textFieldValue, updateTextFieldValue ->
val scroll = rememberScrollState()
var textLayout by remember { mutableStateOf<androidx.compose.ui.text.TextLayoutResult?>(null) }
val selection = textFieldValue.selection
// Keep the moved endpoint through unrelated recomposition and manual reading.
var selectionFollow by remember { mutableStateOf(selection to selection.end) }
if (selection != selectionFollow.first) {
val offset = if (selection.start != selectionFollow.first.start) selection.start else selection.end
selectionFollow = selection to offset
}
val caret = textLayout?.takeIf { it.layoutInput.text.text == textFieldValue.text }?.getCursorRect(selectionFollow.second)
val viewportHeight = scroll.viewportSize
val sixLines = rememberTextMeasurer().measure("H\nH\nH\nH\nH\nH", style = draftStyle).size.height
// Keep the native field bounded for paging; only its decoration contents scroll.
// The unbounded inner text has no private overflow competing with this viewport.
// Scroll changes themselves are deliberately not effect keys: manual reading
// stays put until the selection or available geometry actually changes.
LaunchedEffect(selectionFollow, caret, viewportHeight, inputEnabled) {
val cursor = caret
if (inputEnabled && cursor != null && viewportHeight > 0) {
val top = scroll.value
val target =
when {
cursor.bottom > top + viewportHeight -> ceil(cursor.bottom - viewportHeight).toInt()
cursor.top < top -> kotlin.math.floor(cursor.top).toInt()
else -> top
}
if (target != top) scroll.scrollTo(target)
}
},
)
}
Box(
Modifier
.weight(1f)
.heightIn(min = ClawTheme.spacing.touchTarget, max = with(LocalDensity.current) { sixLines.toDp() } + 12.dp)
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 4.dp),
) {
BasicTextField(
value = textFieldValue,
enabled = inputEnabled,
// A pending IME callback must not edit the draft behind Details.
onValueChange = { if (inputEnabled) updateTextFieldValue(it) },
textStyle = draftStyle.copy(color = ClawTheme.colors.text),
cursorBrush = SolidColor(ClawTheme.colors.primary),
minLines = 1,
maxLines = Int.MAX_VALUE,
onTextLayout = { textLayout = it },
modifier =
Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {}
.onPreInterceptKeyBeforeSoftKeyboard { event ->
inputEnabled &&
hardwareEnterHandler.handle(
event = event,
sendEnabled = sendEnabled,
textEmpty = textFieldValue.text.isEmpty(),
compositionActive = textFieldValue.composition != null,
onSend = onSend,
)
},
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth().verticalScroll(scroll, enabled = inputEnabled), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) {
// BasicTextField's line limit does not constrain its decoration.
Text(text = nativeString("Message OpenClaw"), style = draftStyle, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
innerTextField()
}
},
)
}
}
if (talkActive) {
LiveTalkButton(active = true, onClick = onToggleTalk)
} else {
Box {
ChatComposerMicButton(
dictationState = dictationState,
dictationEnabled = dictationEnabled,
voiceNoteEnabled = recordVoiceNoteEnabled,
onToggleDictation = onToggleDictation,
onStartVoiceNote = onStartVoiceNote,
onOpenVoiceOptions = { voiceOptionsExpanded = true },
)
FoldAwareDropdownMenu(
expanded = voiceOptionsExpanded,
onDismissRequest = { voiceOptionsExpanded = false },
items =
buildList {
if (dictationEnabled) add(FoldAwareMenuItem("dictation", nativeString("Dictation"), onToggleDictation, Icons.Default.Mic))
if (recordVoiceNoteEnabled) add(FoldAwareMenuItem("voice-note", voiceNoteRecordLabel(), onStartVoiceNote, Icons.Default.Mic))
add(FoldAwareMenuItem("talk", nativeString("Start Talk"), onToggleTalk, Icons.Default.Mic))
},
)
}
}
when (resolveChatComposerPrimaryAction(talkActive = talkActive, runActive = runActive, hasContent = hasContent)) {
ChatComposerPrimaryAction.Send -> SendButton(enabled = inputEnabled && sendEnabled, onClick = onSend)
ChatComposerPrimaryAction.Stop -> StopButton(onClick = onAbort)
ChatComposerPrimaryAction.None -> Unit
}
}
ChatComposerActivity(
dictationState = dictationState,
@ -4341,7 +4444,7 @@ private fun ChatInputPill(
modifier = Modifier.padding(horizontal = 14.dp),
)
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (onOpenDetails != null) {
@ -4349,24 +4452,10 @@ private fun ChatInputPill(
Icon(Icons.Default.MoreVert, contentDescription = nativeString("Details"))
}
}
Box {
Surface(onClick = { attachmentMenuExpanded = true }, enabled = inputEnabled, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.textMuted) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.Add, contentDescription = nativeString("Add attachment"), modifier = Modifier.size(20.dp))
}
}
FoldAwareDropdownMenu(
expanded = attachmentMenuExpanded && inputEnabled,
onDismissRequest = { attachmentMenuExpanded = false },
items =
listOf(
FoldAwareMenuItem("photos", nativeString("Photos"), onPickImages, Icons.Default.Photo),
FoldAwareMenuItem("videos", nativeString("Videos"), onPickVideo, Icons.Default.Videocam),
FoldAwareMenuItem("files", nativeString("Files"), onPickAudioOrDocument, Icons.Default.AttachFile),
),
)
}
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
ChatComposerModelPicker(
label = selectedModelLabel,
contextUsage = contextUsage,
@ -4386,22 +4475,28 @@ private fun ChatInputPill(
)
}
}
if (talkActive) {
LiveTalkButton(active = true, onClick = onToggleTalk)
} else {
ChatComposerMicButton(
dictationState = dictationState,
dictationEnabled = dictationEnabled,
voiceNoteEnabled = recordVoiceNoteEnabled,
onToggleDictation = onToggleDictation,
onStartVoiceNote = onStartVoiceNote,
val fraction = contextMeterWidth(contextUsage)
val summary = chatContextSummary(contextUsage)
Row(
Modifier.weight(1f).padding(horizontal = 8.dp).semantics {
contentDescription = nativeString("Context")
summary?.let { stateDescription = it.detail }
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = fraction?.let { nativeString("Context \$percent", "${(it * 100).toInt()}%") } ?: nativeString("Context –"),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
}
when (resolveChatComposerPrimaryAction(talkActive = talkActive, runActive = runActive, hasContent = hasContent)) {
ChatComposerPrimaryAction.Send -> SendButton(enabled = inputEnabled && sendEnabled, onClick = onSend)
ChatComposerPrimaryAction.StartTalk -> LiveTalkButton(active = false, onClick = onToggleTalk)
ChatComposerPrimaryAction.Stop -> StopButton(onClick = onAbort)
ChatComposerPrimaryAction.None -> Unit
Box(Modifier.width(36.dp).height(3.dp).background(ClawTheme.colors.borderStrong, CircleShape)) {
if (fraction != null) Box(Modifier.fillMaxWidth(fraction).height(3.dp).background(ClawTheme.colors.primary, CircleShape))
}
}
}
}

View file

@ -67,6 +67,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCompositionContext
@ -271,6 +272,18 @@ class ChatComposerLayoutTest {
WindowInfoTracker.reset()
}
@Test
@Config(qualifiers = "w1000dp-h1000dp-hdpi")
fun attachmentButtonSharesTheInputRowAndModelStaysBelowIt() {
showChat()
val editor = composeRule.onNode(hasSetTextAction()).getUnclippedBoundsInRoot()
val add = composeRule.onNodeWithContentDescription(nativeString("Add attachment")).getUnclippedBoundsInRoot()
val model = composeRule.onNodeWithContentDescription(nativeString("Model")).getUnclippedBoundsInRoot()
assertTrue("Attachment button must be left of the editor", add.right <= editor.left)
assertTrue("Attachment button must overlap the input row vertically", add.top < editor.bottom && add.bottom > editor.top)
assertTrue("Model selector must remain below the editor", model.top >= editor.bottom)
}
@Test
@Config(qualifiers = "w1000dp-h1000dp-hdpi")
fun tabletopEnforcesPixelFloorsAndUsesTranslatedPostInsetBoundsOnce() {
@ -369,12 +382,12 @@ class ChatComposerLayoutTest {
"The complete real text line fits at the exact floor: font=$font density=$density editor=$bounds textSize=${line.size} line=${line.getLineTop(0)}..${line.getLineBottom(0)} lower=$lowerFloor touch=$touch",
bounds.height >= ceil(line.getLineBottom(0) - line.getLineTop(0)).toInt(),
)
val caret = line.getCursorRect(editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end)
val caret = visibleCaret(editor, line)
assertTrue("The complete caret fits at equality: $caret in $bounds", caret.top >= 0 && caret.bottom <= bounds.height && caret.left >= 0 && caret.right <= bounds.width)
val send = chatWindowBounds(composeRule.onNodeWithContentDescription(nativeString("Send")))
assertTrue("The full target fits at equality", send.height >= touch && send.top >= hinge.bottom && send.bottom <= offset.value.y + with(density) { height.value.roundToPx() })
} else {
assertTrue("One physical pixel below a floor must use the larger safe upper pane", bounds.bottom <= hinge.top)
assertTrue("One physical pixel below a floor must use the larger safe upper pane: font=$font bounds=$bounds hinge=$hinge panes=$upper,$lower,$paneWidth floors=$upperFloor,$lowerFloor,$widthFloor", bounds.bottom <= hinge.top)
}
}
}
@ -1020,7 +1033,7 @@ class ChatComposerLayoutTest {
}
@Test
fun settledRunShowsSendForTextAndTalkForAnEmptyDraft() {
fun settledRunShowsSendForTextAndOneVoiceControlForAnEmptyDraft() {
showChat(viewportWidth = 320.dp)
composeRule.runOnIdle {
controller.handleGatewayEvent(
@ -1028,13 +1041,14 @@ class ChatComposerLayoutTest {
"""{"sessionKey":"${AndroidScreenshotFixture.mainSessionKey}","runId":"android-screenshot-active-run","seq":1,"stream":"lifecycle","data":{"phase":"end"}}""",
)
}
assertComposerControlsVisible(primaryAction = "Start Talk")
assertComposerControlsVisible(primaryAction = null)
composeRule.onNodeWithContentDescription(nativeString("Start Talk")).assertDoesNotExist()
val editor = composeRule.onNode(hasSetTextAction())
editor.performTextReplacement("A short status update")
assertComposerControlsVisible(primaryAction = "Send")
composeRule.onNodeWithContentDescription(nativeString("Start Talk")).assertDoesNotExist()
editor.performTextReplacement("")
assertComposerControlsVisible(primaryAction = "Start Talk")
assertComposerControlsVisible(primaryAction = null)
composeRule.onNodeWithContentDescription(nativeString("Send")).assertDoesNotExist()
}
@ -1155,7 +1169,9 @@ class ChatComposerLayoutTest {
dictation.performClick()
composeRule.onNodeWithText(nativeString("On-device speech recognition is unavailable.")).assertIsDisplayed()
composeRule.onNodeWithText(nativeString("Record voice note")).assertDoesNotExist()
dictation.assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnLongClick))
dictation.performSemanticsAction(SemanticsActions.OnLongClick) { action -> action() }
composeRule.onNodeWithText(nativeString("Record voice note")).assertDoesNotExist()
composeRule.onNodeWithText(nativeString("Start Talk")).assertIsDisplayed()
editor.assertTextEquals("Draft after forgetting")
} finally {
ShadowSpeechRecognizer.setIsOnDeviceRecognitionAvailable(recognitionAvailable)
@ -1210,6 +1226,187 @@ class ChatComposerLayoutTest {
assertComposerControlsVisible(talkActive = true)
}
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi")
fun composerResizeScreenshotProof() {
val height = mutableStateOf(720.dp)
showChat(viewportWidth = 320.dp, viewportHeight = { height.value }, fontScale = { 2f }, useChatShell = true)
val editor = composeRule.onNode(hasSetTextAction())
editor.performClick().performTextReplacement("Short viewport draft")
applyChatImeInsets()
composeRule.runOnIdle { height.value = 360.dp }
composeRule.waitForIdle()
System.getenv("OPENCLAW_CHAT_WORK_PROOF_DIR")?.let { path ->
val folder = File(path).apply { mkdirs() }
val image = composeRule.onNodeWithTag("chat-viewport").captureToImage().asAndroidBitmap()
assertTrue(image.width >= 320 && image.height >= 360)
File(folder, "composer-resize.png").outputStream().use { assertTrue(image.compress(Bitmap.CompressFormat.PNG, 100, it)) }
}
assertCompleteComposerLineAboveIme()
}
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi")
fun composerViewportPreservesManualScrollAndRevealsSelectionAfterResize() {
val height = mutableStateOf(720.dp)
val offset = mutableStateOf(IntOffset.Zero)
showChat(viewportWidth = 360.dp, viewportHeight = { height.value }, fontScale = { 2f }, useChatShell = true, viewportOffset = { offset.value })
val editor = composeRule.onNode(hasSetTextAction())
val draft = (1..20).joinToString("\n") { "Draft line $it" }
editor.performClick().performTextReplacement(draft)
val identity = editor.fetchSemanticsNode().id
val range = { editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] }
val atEnd = range().value()
assertTrue("Long draft scrolls to its caret", atEnd > 0f)
editor.performTouchInput { swipeDown() }
composeRule.waitForIdle()
val reading = range().value()
assertTrue("Manual scroll moves away from the caret", reading < atEnd)
composeRule.runOnIdle { offset.value = IntOffset(0, 1) }
composeRule.waitForIdle()
assertEquals("Unrelated placement must not snap manual reading back", reading, range().value(), 1f)
editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(0, 0, false)) }
composeRule.waitForIdle()
assertEquals("Moving selection reveals the start, not the bottom", 0f, range().value(), 1f)
applyChatImeInsets()
composeRule.runOnIdle { height.value = 360.dp }
composeRule.waitForIdle()
assertCompleteComposerLineAboveIme()
editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(draft.length, draft.length, false)) }
composeRule.waitForIdle()
assertCompleteComposerLineAboveIme()
assertTrue("Moving selection reveals the final line", range().value() > 0f)
assertEquals(identity, editor.fetchSemanticsNode().id)
editor.assertTextEquals(draft).assertIsFocused()
}
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi")
fun expandedSelectionFollowsTheMovedHandleWithoutResettingManualReading() {
showChat(viewportWidth = 360.dp, viewportHeight = { 720.dp }, useChatShell = true)
val editor = composeRule.onNode(hasSetTextAction())
val draft = (1..20).joinToString("\n") { "Draft line $it" }
editor.performClick().performTextReplacement(draft)
val identity = editor.fetchSemanticsNode().id
val end = draft.length
fun select(
start: Int,
finish: Int,
) {
editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(start, finish, false)) }
composeRule.waitForIdle()
assertEquals(TextRange(start, finish), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange])
}
fun assertVisible(offset: Int) {
val layouts = mutableListOf<TextLayoutResult>()
editor.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) }
val caret = visibleCaret(editor, layouts.single(), offset)
val height = editor.fetchSemanticsNode().boundsInRoot.height
assertTrue("Moved selection offset $offset must be wholly visible: $caret in height=$height", caret.top >= 0f && caret.bottom <= height)
}
select(end - 4, end)
select(0, end)
captureComposerProof("editor-selection-start")
assertVisible(0)
assertEquals("Moving the start must preserve the end", end, editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end)
// Reading away from the chosen handle is intentional. A settings event recomposes
// the actual input pill without changing its text, selection, or viewport geometry.
editor.performTouchInput { swipeUp() }
composeRule.waitForIdle()
val reading = editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
assertTrue("Manual reading must move away from the first line", reading > 0f)
composeRule.runOnIdle {
controller.handleGatewayEvent(
"sessions.changed",
"""{"reason":"patch","session":{"key":"${AndroidScreenshotFixture.mainSessionKey}","thinkingLevel":"high","thinkingLevels":[{"id":"high","label":"high"}]}}""",
)
}
composeRule.waitForIdle()
assertEquals("high", controller.thinkingLevel.value)
assertEquals("Unrelated recomposition must preserve manual reading", reading, editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value(), 0f)
assertEquals(TextRange(0, end), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange])
select(0, 0)
assertVisible(0)
select(0, end)
assertVisible(end)
select(end / 2, end)
assertVisible(end / 2)
select(end / 2, 0)
assertVisible(0)
select(end, 0)
assertVisible(end)
select(end, end - 4)
assertVisible(end - 4)
assertEquals("Selection changes must keep the same editor", identity, editor.fetchSemanticsNode().id)
editor.assertTextEquals(draft).assertIsFocused()
}
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class])
fun nativePageDownMovesOneVisibleEditorPage() = assertNativeEditorPage(direction = 1, extend = false)
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class])
fun nativePageUpMovesOneVisibleEditorPage() = assertNativeEditorPage(direction = -1, extend = false)
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class])
fun nativeShiftPageDownExtendsOneVisibleEditorPage() = assertNativeEditorPage(direction = 1, extend = true)
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class])
fun nativeShiftPageUpExtendsOneVisibleEditorPage() = assertNativeEditorPage(direction = -1, extend = true)
private fun assertNativeEditorPage(
direction: Int,
extend: Boolean,
) {
showChat(viewportWidth = 360.dp, viewportHeight = { 720.dp }, useChatShell = true)
val editor = composeRule.onNode(hasSetTextAction())
val draft = (1..20).joinToString("\n") { "Draft line $it" }
editor.performClick().performTextReplacement(draft)
val identity = editor.fetchSemanticsNode().id
val layouts = mutableListOf<TextLayoutResult>()
editor.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) }
val layout = layouts.single()
assertEquals(20, layout.lineCount)
val anchor = layout.getLineStart(10) + 4
editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(anchor, anchor, false)) }
composeRule.waitForIdle()
val visibleHeight = editor.fetchSemanticsNode().boundsInRoot.height
assertTrue("The field must be a viewport, not the whole buffer", visibleHeight < layout.size.height)
val cursor = layout.getCursorRect(anchor)
val expected = layout.getOffsetForPosition(Offset(cursor.left, cursor.top + direction * visibleHeight))
assertTrue("An interior page must not jump to either buffer edge", expected > 0 && expected < draft.length)
assertTrue("Paging must move in the requested direction", (expected - anchor) * direction > 0)
composeRule.runOnIdle {
if (extend) insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT))
val meta = if (extend) KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON else 0
val key = if (direction > 0) KeyEvent.KEYCODE_PAGE_DOWN else KeyEvent.KEYCODE_PAGE_UP
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
val event = KeyEvent(0L, 0L, action, key, 0, meta)
if (!insetView.dispatchKeyEventPreIme(event)) insetView.dispatchKeyEvent(event)
}
if (extend) insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT))
}
composeRule.waitForIdle()
captureComposerProof("editor-page-${if (direction > 0) "down" else "up"}-${if (extend) "shift" else "caret"}")
assertEquals(
"Native paging must use the visible field height=$visibleHeight rather than buffer height=${layout.size.height}",
if (extend) TextRange(anchor, expected) else TextRange(expected),
editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange],
)
val visible = visibleCaret(editor, layout)
assertTrue("The paged endpoint must remain visible", visible.top >= 0f && visible.bottom <= visibleHeight)
assertEquals(identity, editor.fetchSemanticsNode().id)
editor.assertTextEquals(draft).assertIsFocused()
}
@Test
fun multilineDraftGrowsThroughSixLinesAndStopsGrowingAtTheSeventh() {
showChat(viewportWidth = 360.dp, viewportHeight = { 640.dp })
@ -1348,11 +1545,23 @@ class ChatComposerLayoutTest {
assertCompleteComposerLineAboveIme()
}
assertTrue("The edited draft must still have a routable owner", controller.isCurrentComposerOwner(owner))
composeRule.onNodeWithContentDescription(nativeString("Send")).assertIsEnabled().performClick()
editor.performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) }
val send =
composeRule
.onNodeWithContentDescription(nativeString("Send"))
.assertIsEnabled()
.fetchSemanticsNode()
.config[SemanticsActions.OnClick]
.action!!
// An IME commit and send may arrive before the next recomposition or flow collection.
composeRule.runOnIdle {
checkNotNull(insetView.onCreateInputConnection(EditorInfo())).commitText(" final", 1)
assertTrue(send())
}
composeRule.waitUntil {
composeRule.runOnIdle { sent.size == 1 }
}
assertEquals(JsonPrimitive(edited), sent.single()["message"])
assertEquals(JsonPrimitive(edited.substring(0, 13) + " final" + edited.substring(13)), sent.single()["message"])
editor.assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")))
}
}
@ -1383,6 +1592,81 @@ class ChatComposerLayoutTest {
}
}
@Test
@Config(shadows = [ShortcutKeyCharacterMap::class])
fun undoRedoPublishesCanonicalDraftBeforeSendAndRestore() {
prefs.gatewayRegistry.upsert(
GatewayRegistryEntry(stableId = AndroidScreenshotFixture.gatewayId, kind = GatewayRegistryEntryKind.MANUAL, name = "Test gateway"),
)
prefs.gatewayRegistry.setActive(AndroidScreenshotFixture.gatewayId)
val savedDrafts = SavedStateHandle()
val viewModel = showChat(useChatShell = true, savedStateHandle = savedDrafts)
val owner = viewModel.captureChatShareOwner()
val otherOwner = owner.copy(sessionKey = "agent:main:other-draft")
val store = viewModel.chatComposerState
composeRule.runOnIdle { store.textDrafts[otherOwner] = "Other conversation" }
val editor = composeRule.onNode(hasSetTextAction())
editor.performClick().performTextReplacement("Base")
// Move away and back so the legacy field recomposes a forced undo snapshot.
composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_DPAD_LEFT) }
composeRule.waitForIdle()
composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_DPAD_RIGHT) }
composeRule.waitForIdle()
editor.performTextInput(" suffix")
editor.assertTextEquals("Base suffix")
fun command(key: Int) {
insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_CTRL_LEFT))
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
insetView.dispatchKeyEvent(KeyEvent(0L, 0L, action, key, 0, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON))
}
insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_CTRL_LEFT))
}
composeRule.runOnIdle {
command(KeyEvent.KEYCODE_Z)
assertEquals("Undo must synchronously update the canonical draft", "Base", store.textDrafts[owner])
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(savedDrafts["chat-composer-text-drafts"]))
assertEquals("Base", restored[owner])
assertEquals("Other conversation", restored[otherOwner])
}
editor.assertTextEquals("Base")
composeRule.runOnIdle {
command(KeyEvent.KEYCODE_Y)
assertEquals("Redo must synchronously update the canonical draft", "Base suffix", store.textDrafts[owner])
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(savedDrafts["chat-composer-text-drafts"]))
assertEquals("Redo must synchronously persist the draft", "Base suffix", restored[owner])
}
editor.assertTextEquals("Base suffix")
composeRule.runOnIdle {
command(KeyEvent.KEYCODE_Z)
val send = store.beginSend(owner)
assertEquals(ChatComposerSendStartResult.Started, send.result)
assertEquals("Base", checkNotNull(send.request).message)
assertEquals("Other conversation", store.textDrafts[otherOwner])
}
}
// Robolectric 4.16.1 only handles Shift in its character map. Real Android
// reports control characters for Ctrl+letters, allowing Compose's shortcut path.
@org.robolectric.annotation.Implements(android.view.KeyCharacterMap::class)
class ShortcutKeyCharacterMap : org.robolectric.shadows.ShadowKeyCharacterMap() {
companion object {
@JvmStatic
@org.robolectric.annotation.Implementation(methodName = "nativeGetCharacter")
fun shortcutCharacter(
ptr: Long,
keyCode: Int,
metaState: Int,
): Char =
if (metaState and KeyEvent.META_CTRL_ON != 0 && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) {
(keyCode - KeyEvent.KEYCODE_A + 1).toChar()
} else {
org.robolectric.shadows.ShadowKeyCharacterMap
.nativeGetCharacter(ptr, keyCode, metaState)
}
}
}
private fun applyChatImeInsets() {
val keyboard = with(composeRule.density) { 220.dp.roundToPx() }
val navigation = with(composeRule.density) { 24.dp.roundToPx() }
@ -1454,7 +1738,9 @@ class ChatComposerLayoutTest {
editor.assertIsFocused().assertTextEquals(draft)
}
val attemptInput = composeRule.runOnIdle { prepareInput(insetView) }
composeRule.onNodeWithContentDescription(nativeString("Details")).performClick()
// Robolectric cannot dismiss its native magnifier when a pointer click disables the field.
// Open Details through accessibility; the assertions exercise real queued IME/key input.
composeRule.onNodeWithContentDescription(nativeString("Details")).performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) }
composeRule.runOnIdle { attemptInput() }
composeRule.waitForIdle()
composeRule.runOnIdle {
@ -1466,7 +1752,7 @@ class ChatComposerLayoutTest {
editor.assertTextEquals(draft)
assertEquals("Details must retain the same editor", editorId, editor.fetchSemanticsNode().id)
assertEquals(TextRange(draft.length), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange])
editor.performClick()
editor.performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) }
composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_X) }
editor.assertTextEquals(draft + "x")
composeRule.runOnIdle {
@ -1498,13 +1784,57 @@ class ChatComposerLayoutTest {
val send = composeRule.onNodeWithContentDescription(nativeString("Send")).getUnclippedBoundsInRoot()
assertTrue("The complete action target must remain above the real IME", send.bottom <= visibleBottom)
val node = editor.fetchSemanticsNode()
val selection = node.config[SemanticsProperties.TextSelectionRange]
val caret = layout.getCursorRect(selection.end).translate(node.positionInRoot)
val caret = visibleCaret(editor, layout).translate(node.positionInRoot)
val caretTop = with(composeRule.density) { caret.top.toDp() }
val caretBottom = with(composeRule.density) { caret.bottom.toDp() }
assertTrue("The whole caret must be visible inside the editor: $caret within $bounds", caretTop >= bounds.top && caretBottom <= bounds.bottom)
}
private fun visibleCaret(
editor: SemanticsNodeInteraction,
layout: TextLayoutResult,
offset: Int = editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end,
): androidx.compose.ui.geometry.Rect {
var displacement = Offset.Zero
// GetTextLayoutResult is unscrolled. Measure and restore the field's actual scroll offset
// before comparing its cursor with viewport bounds, including wrapped drafts at large fonts.
// Compose omits scroll actions when the full text already fits.
val scroll =
editor.fetchSemanticsNode().config.getOrNull(SemanticsActions.ScrollByOffset)
?: run {
// Legacy value fields expose their scrolled text through layout coordinates,
// not ScrollByOffset semantics. Measure actual placement without assuming
// the cursor is visible; this also catches resize-only scroll regressions.
val root = editor.fetchSemanticsNode().layoutInfo
fun textLeaf(info: androidx.compose.ui.layout.LayoutInfo): androidx.compose.ui.layout.LayoutInfo? {
val children =
info.javaClass.methods
.first { it.name.startsWith("getChildren$") && it.parameterCount == 0 }
.invoke(info) as List<*>
if (children.isEmpty() && info.width == layout.size.width && info.height == layout.size.height) return info
return children.filterIsInstance<androidx.compose.ui.layout.LayoutInfo>().firstNotNullOfOrNull(::textLeaf)
}
val leaf = checkNotNull(textLeaf(root)) { "Missing placed text layout" }
val origin = leaf.coordinates.localToRoot(Offset.Zero) - editor.fetchSemanticsNode().positionInRoot
return layout.getCursorRect(offset).translate(origin)
}
composeRule.runOnIdle {
val clock =
object : MonotonicFrameClock {
private var time = 0L
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R = onFrame(time.also { time += 16_000_000L })
}
runBlocking(clock) {
displacement = scroll(Offset(0f, -layout.size.height.toFloat()))
scroll(-displacement)
}
}
// ScrollState places text at integer pixels; animation consumption can retain a fractional remainder.
return layout.getCursorRect(offset).translate(Offset(displacement.x.roundToInt().toFloat(), displacement.y.roundToInt().toFloat()))
}
@Test
@Config(qualifiers = "w800dp-h800dp-mdpi")
fun backgroundTasksInitialReadStartsBeforeNativePlacement() {
@ -3474,7 +3804,7 @@ class ChatComposerLayoutTest {
}
@Test
fun fastModeBadgeBelongsToTheGaugeGeometry() {
fun fastModeGaugeRetainsAccessibleStateWithoutAnOverlayBadge() {
showChat(viewportWidth = 360.dp, viewportHeight = { 640.dp })
composeRule.runOnIdle {
controller.handleGatewayEvent(
@ -3491,18 +3821,13 @@ class ChatComposerLayoutTest {
}
composeRule.onNodeWithContentDescription(nativeString("Thinking")).assertIsDisplayed()
val gauge = composeRule.onNodeWithTag("chat-thinking-gauge", useUnmergedTree = true).getUnclippedBoundsInRoot()
val badge = composeRule.onNodeWithTag("chat-fast-mode-badge", useUnmergedTree = true).getUnclippedBoundsInRoot()
val badgeCenterX = (badge.left.value + badge.right.value) / 2f
val badgeCenterY = (badge.top.value + badge.bottom.value) / 2f
val gaugeCenterX = (gauge.left.value + gauge.right.value) / 2f
val gaugeCenterY = (gauge.top.value + gauge.bottom.value) / 2f
assertTrue("The Fast mode badge center must stay inside the gauge: $badge in $gauge", badgeCenterX in gauge.left.value..gauge.right.value)
assertTrue("The Fast mode badge center must stay inside the gauge: $badge in $gauge", badgeCenterY in gauge.top.value..gauge.bottom.value)
assertFalse(
"The Fast mode badge must not cover the needle hub: $badge over $gauge",
gaugeCenterX in badge.left.value..badge.right.value && gaugeCenterY in badge.top.value..badge.bottom.value,
composeRule.onNodeWithTag("chat-thinking-gauge", useUnmergedTree = true).assertIsDisplayed()
composeRule.onNodeWithTag("chat-fast-mode-badge", useUnmergedTree = true).assertDoesNotExist()
composeRule.onNodeWithContentDescription(nativeString("Thinking")).assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
chatThinkingChipStateDescription(true, "high", listOf(ChatThinkingLevelOption("high", "high"))),
),
)
}
@ -4060,20 +4385,19 @@ class ChatComposerLayoutTest {
showChat(viewportHeight = { 640.dp }, restorationTester = restoration)
composeRule.onNode(hasSetTextAction()).performTextInput("retained menu draft")
composeRule.onNodeWithContentDescription(nativeString("Add attachment")).performClick()
composeRule.onNodeWithText(nativeString("Photos")).assertIsDisplayed()
val old =
WindowInspector.getGlobalWindowViews().single {
it.isAttachedToWindow && (it.layoutParams as? WindowManager.LayoutParams)?.type == WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL
}
composeRule.onNode(isDialog()).assertIsDisplayed()
composeRule.onNode(hasText(nativeString("Gallery")) and hasClickAction()).assertIsSelected()
restoration.emulateSavedInstanceStateRestore()
composeRule.waitForIdle()
assertFalse(old.isAttachedToWindow)
composeRule.onNode(isPopup()).assertDoesNotExist()
composeRule.onNode(isDialog()).assertDoesNotExist()
composeRule.onNode(hasSetTextAction()).assertTextEquals("retained menu draft")
composeRule.onNodeWithContentDescription(nativeString("Add attachment")).performClick()
for (label in listOf("Photos", "Videos", "Files")) {
composeRule.onNodeWithText(nativeString(label)).assertIsDisplayed()
for (label in listOf("Gallery", "File", "Location")) {
composeRule.onNode(hasText(nativeString(label)) and hasClickAction()).assertIsDisplayed()
}
composeRule.onNode(hasText(nativeString("File")) and hasClickAction()).performClick()
composeRule.onNodeWithText(nativeString("Files")).assertIsDisplayed()
composeRule.onNodeWithText(nativeString("Videos")).assertIsDisplayed()
}
@Test
@ -4401,9 +4725,11 @@ class ChatComposerLayoutTest {
composeRule
.onNode(
SemanticsMatcher("voice-note long press") { node ->
node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Record voice note")
node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Voice options")
},
).performSemanticsAction(SemanticsActions.OnLongClick) { action -> action() }
composeRule.onNodeWithText(nativeString("Record voice note")).performClick()
composeRule.onNodeWithContentDescription(nativeString("Cancel voice note")).assertIsDisplayed()
captureComposerProof("voice-controls-320-2.0")
assertCompactComposerCircle(composeRule.onNodeWithContentDescription(nativeString("Cancel voice note")))
assertCompactComposerCircle(composeRule.onNodeWithContentDescription(nativeString("Finish voice note")))
@ -5042,7 +5368,12 @@ class ChatComposerLayoutTest {
ChatMessage("bubble-proof-answer", "assistant", listOf(ChatMessageContent(text = "Two reviews remain before release.")), null)
}
composeRule.onNodeWithText("Two reviews remain before release.", useUnmergedTree = true).assertIsDisplayed()
composeRule.onNodeWithContentDescription("Start Talk").assertIsDisplayed()
composeRule
.onNode(
SemanticsMatcher("voice options remain available") { node ->
node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Voice options")
},
).assertIsDisplayed()
capture("confirmed")
verifyGeometry("confirmed")
assertTrue(geometryFailures.joinToString("\n"), geometryFailures.isEmpty())
@ -5147,8 +5478,9 @@ class ChatComposerLayoutTest {
layoutDirection: () -> LayoutDirection = { LayoutDirection.Ltr },
restorationTester: StateRestorationTester? = null,
scene: AndroidScreenshotScene = AndroidScreenshotScene.Chat,
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): MainViewModel {
val viewModel = MainViewModel(app, prefs, SavedStateHandle())
val viewModel = MainViewModel(app, prefs, savedStateHandle)
viewModelStore.put("chat", viewModel)
viewModel.enterScreenshotFixtureMode(scene)
val setContent = restorationTester?.let { it::setContent } ?: composeRule::setContent
@ -5241,14 +5573,14 @@ class ChatComposerLayoutTest {
talkActive: Boolean = false,
thinkingLabel: String = nativeString("Low"),
modelLabel: String = "GPT-5.2",
primaryAction: String = "Stop",
primaryAction: String? = "Stop",
) {
val viewport = composeRule.onNodeWithTag("chat-viewport").getUnclippedBoundsInRoot()
val editorNode = composeRule.onNode(hasSetTextAction()).assertIsDisplayed()
val editor = editorNode.getUnclippedBoundsInRoot()
assertTrue("Editor must retain a visible line: $editor inside $viewport", editor.bottom > editor.top)
val controls =
(listOf(primaryAction) + if (talkActive) listOf("End Talk") else emptyList()).map { label ->
(listOfNotNull(primaryAction) + if (talkActive) listOf("End Talk") else emptyList()).map { label ->
composeRule.onNodeWithContentDescription(nativeString(label)).assertIsDisplayed().assertHasClickAction()
} +
listOf(
@ -5278,7 +5610,7 @@ class ChatComposerLayoutTest {
if (composeRule.onAllNodesWithContentDescription(nativeString("Details")).fetchSemanticsNodes().isNotEmpty()) {
controlBounds += composeRule.onNodeWithContentDescription(nativeString("Details")).assertIsDisplayed().getUnclippedBoundsInRoot()
}
val primary = controlBounds.first()
val primary = primaryAction?.let { composeRule.onNodeWithContentDescription(nativeString(it)).getUnclippedBoundsInRoot() }
val dictation =
composeRule.onNode(
SemanticsMatcher("dictation control") { node ->
@ -5288,21 +5620,19 @@ class ChatComposerLayoutTest {
val voice =
if (talkActive) {
dictation.assertDoesNotExist()
controlBounds[1]
composeRule.onNodeWithContentDescription(nativeString("End Talk")).getUnclippedBoundsInRoot()
} else {
dictation.assertIsDisplayed().getUnclippedBoundsInRoot().also { controlBounds += it }
}
assertTrue("Voice stays before the primary action", voice.right <= primary.left)
controlBounds.drop(1).forEach { bounds ->
assertEquals(
"Every control, including voice, must share the action row: $bounds versus $primary",
(primary.top.value + primary.bottom.value) / 2,
(bounds.top.value + bounds.bottom.value) / 2,
1f,
)
}
controlBounds.sortedBy { it.left }.zipWithNext().forEach { (left, right) ->
assertTrue("Adjacent touch targets must not overlap: $left and $right", left.right <= right.left)
primary?.let { assertTrue("Voice stays before the primary action", voice.right <= it.left) }
assertTrue("Voice stays beside the editor", voice.left >= editor.right && voice.top < editor.bottom && voice.bottom > editor.top)
for ((index, first) in controlBounds.withIndex()) {
for (second in controlBounds.drop(index + 1)) {
assertTrue(
"Touch targets must not overlap: $first and $second",
first.right <= second.left || second.right <= first.left || first.bottom <= second.top || second.bottom <= first.top,
)
}
}
controlBounds.forEach { bounds ->
val retainsTouchTarget =

View file

@ -17,7 +17,7 @@ class ChatEffortPositionTest {
@Test
fun advertisedLevelsShareOneEvenSliderAndNeedleScale() {
val expectedFractions = listOf(0f, 0.25f, 0.5f, 0.75f, 1f)
val expectedAngles = listOf(150f, 210f, 270f, 330f, 390f)
val expectedAngles = listOf(180f, 210f, 240f, 270f, 300f)
options.forEachIndexed { index, option ->
val position = resolveChatEffortPosition(option.id, options)
@ -39,6 +39,13 @@ class ChatEffortPositionTest {
assertNull(chatEffortNeedleAngle(position))
}
@Test
fun fastModeMovesNeedleIntoRedZoneRegardlessOfEffort() {
for (level in listOf("off", "high", "future-effort")) {
assertEquals(330f, chatEffortNeedleAngle(resolveChatEffortPosition(level, options), fastMode = true))
}
}
@Test
fun rtlMirrorsEveryVisualStop() {
val fractions = chatEffortStopFractions(options.size)

View file

@ -134,6 +134,8 @@ class ChatImageCodecTest {
val attachment = loadProviderImage(displayName = "vacation-photo.png")
assertEquals("vacation-photo.jpg", attachment.fileName)
assertEquals("image/jpeg", attachment.mimeType)
assertTrue(requireNotNull(decodeBase64Bitmap(attachment.base64)).width > 0)
}
@Test
@ -191,7 +193,7 @@ class ChatImageCodecTest {
val provider = TestImageContentProvider(image, displayName, failQuery)
provider.attachInfo(RuntimeEnvironment.getApplication(), ProviderInfo().apply { this.authority = authority })
ShadowContentResolver.registerProviderInternal(authority, provider)
return loadSizedImageAttachment(
return loadPickedMediaOrDocumentAttachment(
RuntimeEnvironment.getApplication().contentResolver,
Uri.parse("content://$authority/images/42"),
)

View file

@ -154,7 +154,7 @@ class ChatScreenTest {
resolveChatComposerPrimaryAction(talkActive = false, runActive = true, hasContent = false),
)
assertEquals(
ChatComposerPrimaryAction.StartTalk,
ChatComposerPrimaryAction.None,
resolveChatComposerPrimaryAction(talkActive = false, runActive = false, hasContent = false),
)
}

View file

@ -0,0 +1,150 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.ui.design.ClawDesignTheme
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LocationAttachmentTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun cachedAttachmentHonorsDisabledPrecisionWithRetainedFineGrant() {
val app = RuntimeEnvironment.getApplication()
(app as ai.openclaw.app.NodeApp).prefs.setLocationPreciseEnabled(false)
shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
shadowOf(manager).setProviderEnabled(LocationManager.GPS_PROVIDER, true)
val fix =
Location(LocationManager.GPS_PROVIDER).apply {
latitude = 12.345678
longitude = 45.678912
accuracy = 5f
time = System.currentTimeMillis()
}
shadowOf(manager).simulateLocation(LocationManager.GPS_PROVIDER, fix)
val links = mutableListOf<String>()
composeRule.setContent {
ClawDesignTheme { LocationAttachment(admit = { true }, onLocation = links::add) }
}
composeRule.onNodeWithText("Use current location").performClick()
composeRule.runOnIdle {
assertEquals(1, links.size)
org.junit.Assert.assertNotEquals("https://www.google.com/maps?q=12.345678,45.678912", links.single())
}
composeRule.onNodeWithText("Use current location").performClick()
composeRule.runOnIdle { assertEquals(links.first(), links.last()) }
}
@Test
fun pendingAttachmentRechecksPrecisionAndNeverUpgradesApproximateCapture() {
val app = RuntimeEnvironment.getApplication() as ai.openclaw.app.NodeApp
shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = shadowOf(manager)
provider.setProviderEnabled(LocationManager.NETWORK_PROVIDER, false)
provider.setProviderEnabled(LocationManager.GPS_PROVIDER, true)
val links = mutableListOf<String>()
composeRule.setContent {
ClawDesignTheme { LocationAttachment(admit = { true }, onLocation = { links.add(it) }) }
}
for ((initial, final) in listOf(true to false, false to true)) {
val fix =
Location(LocationManager.GPS_PROVIDER).apply {
latitude = 12.345678
longitude = 45.678912
accuracy = 5f
time = System.currentTimeMillis() - 120_000
}
composeRule.runOnIdle {
app.prefs.setLocationPreciseEnabled(initial)
provider.simulateLocation(LocationManager.GPS_PROVIDER, fix)
}
composeRule.onNodeWithText("Use current location").performClick()
composeRule.runOnIdle {
assertTrue(provider.getLocationRequests(LocationManager.GPS_PROVIDER).isNotEmpty())
app.prefs.setLocationPreciseEnabled(final)
fix.time = System.currentTimeMillis()
provider.simulateLocation(LocationManager.GPS_PROVIDER, fix)
}
composeRule.waitForIdle()
composeRule.runOnIdle {
assertTrue(links.isNotEmpty())
org.junit.Assert.assertNotEquals("https://www.google.com/maps?q=12.345678,45.678912", links.last())
assertTrue(provider.getLocationRequests(LocationManager.GPS_PROVIDER).isEmpty())
}
}
composeRule.runOnIdle { assertEquals(2, links.size) }
}
@Test
fun locationTimeoutShowsRecoveryMessageAndAllowsRetry() {
val app = RuntimeEnvironment.getApplication()
shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION)
val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
shadowOf(manager).setProviderEnabled(LocationManager.NETWORK_PROVIDER, true)
composeRule.setContent {
ClawDesignTheme {
LocationAttachment(admit = { true }, onLocation = { error("No location was supplied") })
}
}
composeRule.onNodeWithText("Use current location").performClick()
composeRule.onNodeWithText("Getting location…").assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(16_000)
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(16))
composeRule.onNodeWithText("Could not get your location. Check device location settings and try again.").assertIsDisplayed()
composeRule.onNodeWithText("Use current location").assertIsEnabled()
composeRule.onNodeWithText("Use current location").performClick()
composeRule.onNodeWithText("Getting location…").assertIsDisplayed()
}
@Test
fun closingLocationPanelCancelsProviderRequestAndIgnoresLateFix() {
val app = RuntimeEnvironment.getApplication()
shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION)
val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = shadowOf(manager)
provider.setProviderEnabled(LocationManager.NETWORK_PROVIDER, true)
val visible = mutableStateOf(true)
var insertions = 0
composeRule.setContent {
ClawDesignTheme {
if (visible.value) LocationAttachment(admit = { true }, onLocation = { insertions++ })
}
}
composeRule.onNodeWithText("Use current location").performClick()
composeRule.runOnIdle {
assertTrue(provider.getLocationRequests(LocationManager.NETWORK_PROVIDER).isNotEmpty())
visible.value = false
}
composeRule.waitForIdle()
composeRule.runOnIdle {
assertTrue(provider.getLocationRequests(LocationManager.NETWORK_PROVIDER).isEmpty())
provider.simulateLocation(Location(LocationManager.NETWORK_PROVIDER))
}
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(16))
composeRule.runOnIdle { assertEquals(0, insertions) }
}
}

View file

@ -437,15 +437,18 @@ Camera commands (foreground only; permission-gated): `camera.snap` (jpg), `camer
- Navigate through the sidebar's **Pages** menu. Voice input belongs to the Chat
composer; there is no separate Voice tab.
- Tap the composer microphone for on-device speech recognition that inserts a
transcript into the draft. Long-press the microphone to record a voice-note
attachment. The UI reports unavailable recognition, missing permission,
transcript into the draft. While listening, a Stop icon replaces the microphone;
tap it to finish listening. While starting or transcribing, a Close icon cancels
that attempt. Long-press the microphone to open **Voice options**,
then choose **Record voice note** to create an attachment. The UI reports
unavailable recognition, missing permission,
busy/network failures, and no-speech outcomes instead of silently dropping
the attempt. If dictation is unavailable and a Gateway is selected,
**Record voice note** offers a new recording while keeping the draft. It does
not recover speech from the failed dictation attempt or send anything
automatically.
- Start continuous **Talk** from the Chat waveform. Dictation, voice-note
recording, and Talk are mutually exclusive microphone paths.
- To start continuous **Talk**, long-press the microphone and choose **Start Talk**.
Dictation, voice-note recording, and Talk are mutually exclusive microphone paths.
- Your selected agent stays bound to Talk and the main chat when the same Gateway
reconnects, including while its agent list refreshes. Removing that agent falls
back to the Gateway default. Switching Gateways or restarting the app clears

View file

@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { Worker } from "node:worker_threads";
@ -9,6 +8,7 @@ import {
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { runCliProcessChild } from "../cli-process-child.test-helpers.js";
function runBackupCli(params: {
env: NodeJS.ProcessEnv;
@ -16,37 +16,21 @@ function runBackupCli(params: {
preloadPath?: string;
includeWorkspace?: boolean;
}): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
...(params.preloadPath ? ["--import", params.preloadPath] : []),
"--import",
"tsx",
path.resolve("src/entry.ts"),
"backup",
"create",
"--output",
params.outputPath,
...(params.includeWorkspace ? [] : ["--no-include-workspace"]),
"--verify",
"--json",
],
{
env: { ...params.env, OPENCLAW_TEST_RUNTIME_LOG: "1" },
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (code) => resolve({ code, stdout, stderr }));
return runCliProcessChild({
nodeArgs: [
...(params.preloadPath ? ["--import", params.preloadPath] : []),
"--import",
"tsx",
path.resolve("src/entry.ts"),
"backup",
"create",
"--output",
params.outputPath,
...(params.includeWorkspace ? [] : ["--no-include-workspace"]),
"--verify",
"--json",
],
env: { ...params.env, OPENCLAW_TEST_RUNTIME_LOG: "1" },
});
}

View file

@ -9,6 +9,7 @@ import { DatabaseSync } from "node:sqlite";
import { vi } from "vitest";
import { inspectManagedProcessGroup } from "../../scripts/lib/managed-child-process.mts";
import { resolveServiceManagerEnv } from "../daemon/service-process-env.js";
import { resolveSystemdUnitPath } from "../daemon/systemd-service-files.js";
import { buildCliRespawnPlan } from "../entry.respawn.js";
import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js";
import { resolveTestNodeExecPath } from "../test-utils/node-process.js";
@ -28,6 +29,13 @@ import { startManagedServiceUpdateHandoff } from "./update-managed-service-hando
const testNodeExecPath = resolveTestNodeExecPath();
// Readers may inspect either unit while another native controller publishes its state.
const nativeStatePublisher = `function publishState(file, value) {
const temporary = file + "." + process.pid + ".tmp";
fs.writeFileSync(temporary, JSON.stringify(value));
fs.renameSync(temporary, file);
}`;
export function triageRuntimeNodeOptions(): string {
// Prepared JavaScript does not need a source loader in every fixing descendant.
return resolveRuntimeWorkerUrl(triageTestRuntimeEntrypoints.continuation).pathname.endsWith(".ts")
@ -91,6 +99,7 @@ export async function createTriageBoundary(
JSON.stringify({ name: mode === "startup" ? scope : updateScope, active: true }),
);
const common = `const fs = require('node:fs');
${nativeStatePublisher}
const root = ${JSON.stringify(root)};
const scopeFile = ${JSON.stringify(scopeFile)};
const primaryFile = ${JSON.stringify(primaryFile)};
@ -173,10 +182,10 @@ if (action === 'show') {
event('restart-preserved', {scope:scope.name});
} else if (action === 'stop') {
if (!name.endsWith('.scope')) {
primary.active = false; fs.writeFileSync(primaryFile,JSON.stringify(primary));
primary.active = false; publishState(primaryFile,primary);
}
if (name.endsWith('.scope') || scope.name.startsWith('openclaw-triage-')) {
scope.active=false; fs.writeFileSync(scopeFile,JSON.stringify(scope));
scope.active=false; publishState(scopeFile,scope);
event('scope-stopped');
for (const member of fs.readdirSync(root+'/members')) {
try { process.kill(Number(member), 'SIGTERM'); } catch {}
@ -193,7 +202,7 @@ if (action === 'show') {
`
const args=process.argv.slice(2), index=args.findIndex(x=>!x.startsWith('--'));
const name=args.find(x=>x.startsWith('--unit=')).slice(7);
fs.writeFileSync(scopeFile,JSON.stringify({name,active:true}));
publishState(scopeFile,{name,active:true});
event('attached', {name});
process.execve(args[index],args.slice(index),process.env);
`,
@ -631,6 +640,13 @@ async function writeTriageMaintenanceProbe(params: {
events: string;
}): Promise<string> {
const { root, primaryFile, unit, events } = params;
// Container-aware Doctor also checks the installation reported by the native fixture.
const unitPath = resolveSystemdUnitPath({ HOME: root, OPENCLAW_SYSTEMD_UNIT: unit });
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(
unitPath,
`[Service]\nExecStart=${testNodeExecPath} ${root}/dist/index.js gateway run\n`,
);
await fs.mkdir(path.join(root, "dist"));
await fs.writeFile(path.join(root, "dist", "index.js"), "");
await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
@ -643,6 +659,7 @@ import os from 'node:os';
import { spawn } from 'node:child_process';
import { mock } from 'node:test';
const root=${JSON.stringify(root)}, primaryFile=${JSON.stringify(primaryFile)};
${nativeStatePublisher}
const event=(kind,data={})=>fs.appendFileSync(${JSON.stringify(events)},JSON.stringify({kind,pid:process.pid,...data})+'\\n');
const started=performance.now(); let sequence=0;
const phase=(phase,data={})=>event('maintenance-phase',{phase,ppid:process.ppid,sequence:++sequence,elapsedMs:performance.now()-started,...data});
@ -683,7 +700,7 @@ const {maybeStopManagedServiceBeforeMutableUpdate}=await import(${JSON.stringify
phase('update-import-end');
if(process.argv[2]==='inactive'){
const primary=JSON.parse(fs.readFileSync(primaryFile,'utf8'));
fs.writeFileSync(primaryFile,JSON.stringify({...primary,active:false}));
publishState(primaryFile,{...primary,active:false});
// Exercise Linux's inactive-unit policy on macOS too. No PID/native probes
// are needed on the corrected inactive branch; this is not native proof.
Object.defineProperty(process,'platform',{value:'linux'});

View file

@ -412,7 +412,31 @@ process.emit=function(kind,message,...args){
itUnix.each(["active", "inactive"] as const)(
"keeps the fixer alive during %s primary maintenance",
async (primary) => {
const boundary = await start("startup", undefined, primary);
const boundary = await start("startup", undefined, primary, async (root) => {
const file = path.join(root, "maintenance.mjs");
const source = await fs.readFile(file, "utf8");
await fs.writeFile(
file,
source.replace(
"if(process.argv[2]==='inactive'){",
`
const write = fs.writeFileSync;
fs.writeFileSync = function(file, ...args) {
if (typeof file === "string" && file.startsWith(primaryFile)) {
// Observe the real controller between truncate and write, not only after publication.
write(file, "");
const scope = JSON.parse(fs.readFileSync(root + "/scope.json", "utf8")).name;
const probe = process.getBuiltinModule("child_process").spawnSync(
process.execPath, [root + "/bin/systemctl", "--user", "show", scope], {encoding:"utf8"},
);
event("primary-snapshot-observed", {status:probe.status});
}
return write(file, ...args);
};
if(process.argv[2]==='inactive'){`,
),
);
});
await ready(boundary);
expect(await boundary.control("commit")).toBe("committed");
await fixing(boundary);
@ -432,6 +456,9 @@ process.emit=function(kind,message,...args){
"outside",
);
} else {
expect(events.find((event) => event.kind === "primary-snapshot-observed")).toMatchObject({
status: 0,
});
expect(events.find((event) => event.kind === "doctor-maintenance")).toMatchObject({
admitted: true,
});

View file

@ -15,6 +15,7 @@ import {
WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
} from "../../scripts/lib/worker-deploy-build-plugin.mts";
import { createWorkerBundleProducer } from "../../src/gateway/worker-environments/bundle.js";
import { WORKER_BUNDLE_ARTIFACT_PATHS } from "../../src/shared/worker-bundle-hash.js";
import { createFixtureLifetime } from "../helpers/fixture-lifetime.js";
import { runNodeScript } from "../helpers/run-node-script.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@ -56,6 +57,9 @@ describe("worker deploy build plugin", () => {
describe("portable output", () => {
const fixtureDirs = useAutoCleanupTempDirTracker(afterAll);
const fixtureLifetime = createFixtureLifetime();
const workerEntryNames = WORKER_BUNDLE_ARTIFACT_PATHS.map(
(artifact) => `worker/${artifact.replace(/\.mjs$/u, "")}`,
);
let preparedDist: string;
let preparedArchive: string;
@ -81,12 +85,14 @@ describe("worker deploy build plugin", () => {
const activationSource = fs.realpathSync(
path.resolve("src/plugin-sdk/facade-activation-check.runtime.ts"),
);
// Mixed runtime/declaration graphs also contain worker paths, but are not archived.
for (const sibling of configs.filter(
(candidate) =>
candidate !== config &&
typeof candidate.entry === "object" &&
!Array.isArray(candidate.entry) &&
Object.keys(candidate.entry).some((entry) => entry.startsWith("worker/")),
Object.keys(candidate.entry).length > 0 &&
Object.keys(candidate.entry).every((entry) => workerEntryNames.includes(entry)),
)) {
const { bundles } = await build({
...sibling,
@ -139,6 +145,11 @@ export { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";`;
],
});
try {
const builtEntries = vi
.mocked(build)
.mock.calls.flatMap(([options]) => Object.keys(options?.entry ?? {}));
expect(builtEntries.length).toBe(workerEntryNames.length);
expect(builtEntries.toSorted()).toEqual(workerEntryNames.toSorted());
// A dynamic import cycle can leave an unstaged root facade even with code splitting off.
expect(bundles.flatMap((bundle) => bundle.chunks.map((chunk) => chunk.fileName))).toEqual([
"worker/worker.mjs",