From 6fef79a28d0204f1a2d8ff68e1411a544937f930 Mon Sep 17 00:00:00 2001 From: snowzlmbot Date: Thu, 9 Jul 2026 19:07:04 +0800 Subject: [PATCH] feat(android): add Skill Workshop settings panel (#101911) * feat(android): add Skill Workshop settings * fix(android): scope Skill Workshop state by agent * fix(android): confirm Skill Workshop proposal actions * fix(android): strengthen Skill Workshop action proof gates * fix(android): satisfy Skill Workshop action ktlint * fix(android): serialize Skill Workshop lifecycle actions * chore(android): refresh native i18n inventory --------- Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com> Co-authored-by: Peter Steinberger --- apps/.i18n/native-source.json | 712 ++++++++++++------ apps/android/README.md | 1 + .../java/ai/openclaw/app/MainViewModel.kt | 48 ++ .../main/java/ai/openclaw/app/NodeRuntime.kt | 501 ++++++++++++ .../ai/openclaw/app/gateway/GatewaySession.kt | 8 + .../ai/openclaw/app/ui/SettingsScreens.kt | 2 + .../java/ai/openclaw/app/ui/ShellScreen.kt | 33 + .../app/ui/SkillWorkshopSettingsScreen.kt | 689 +++++++++++++++++ .../app/SkillWorkshopAgentScopeRuntimeTest.kt | 237 ++++++ .../openclaw/app/ui/ShellScreenLogicTest.kt | 137 ++++ 10 files changed, 2152 insertions(+), 216 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 328c0dddbe3..067d18e66c1 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -147,7 +147,7 @@ }, { "kind": "ui-state-text", - "line": 230, + "line": 238, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -155,7 +155,7 @@ }, { "kind": "ui-state-text", - "line": 240, + "line": 248, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -195,7 +195,7 @@ }, { "kind": "ui-state-text", - "line": 625, + "line": 640, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -203,7 +203,7 @@ }, { "kind": "conditional-branch", - "line": 2799, + "line": 2914, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -211,7 +211,7 @@ }, { "kind": "conditional-branch", - "line": 2801, + "line": 2916, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -219,7 +219,7 @@ }, { "kind": "conditional-branch", - "line": 2803, + "line": 2918, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -387,7 +387,7 @@ }, { "kind": "conditional-branch", - "line": 1293, + "line": 1301, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -395,7 +395,7 @@ }, { "kind": "conditional-branch", - "line": 1293, + "line": 1301, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -2587,7 +2587,7 @@ }, { "kind": "ui-named-argument", - "line": 220, + "line": 222, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -2595,7 +2595,7 @@ }, { "kind": "ui-named-argument", - "line": 220, + "line": 222, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -2603,7 +2603,7 @@ }, { "kind": "ui-named-argument", - "line": 240, + "line": 242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -2611,7 +2611,7 @@ }, { "kind": "ui-named-argument", - "line": 245, + "line": 247, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -2619,7 +2619,7 @@ }, { "kind": "ui-named-argument", - "line": 246, + "line": 248, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -2627,7 +2627,7 @@ }, { "kind": "ui-named-argument", - "line": 281, + "line": 283, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -2635,7 +2635,7 @@ }, { "kind": "ui-named-argument", - "line": 281, + "line": 283, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -2643,7 +2643,7 @@ }, { "kind": "ui-named-argument", - "line": 292, + "line": 294, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android shows scheduled work status. Create and edit schedules from the desktop app.", "surface": "android", @@ -2651,7 +2651,7 @@ }, { "kind": "ui-named-argument", - "line": 302, + "line": 304, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -2659,7 +2659,7 @@ }, { "kind": "ui-named-argument", - "line": 307, + "line": 309, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -2667,7 +2667,7 @@ }, { "kind": "ui-named-argument", - "line": 308, + "line": 310, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Create recurring OpenClaw work from the desktop app.", "surface": "android", @@ -2675,7 +2675,7 @@ }, { "kind": "ui-named-argument", - "line": 343, + "line": 345, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -2683,7 +2683,7 @@ }, { "kind": "ui-named-argument", - "line": 357, + "line": 359, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -2691,7 +2691,7 @@ }, { "kind": "conditional-branch", - "line": 365, + "line": 367, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -2699,7 +2699,7 @@ }, { "kind": "conditional-branch", - "line": 365, + "line": 367, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -2707,7 +2707,7 @@ }, { "kind": "ui-named-argument", - "line": 387, + "line": 389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -2715,7 +2715,7 @@ }, { "kind": "ui-named-argument", - "line": 387, + "line": 389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -2723,7 +2723,7 @@ }, { "kind": "ui-named-argument", - "line": 398, + "line": 400, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -2731,7 +2731,7 @@ }, { "kind": "ui-named-argument", - "line": 402, + "line": 404, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -2739,7 +2739,7 @@ }, { "kind": "ui-named-argument", - "line": 428, + "line": 430, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -2747,7 +2747,7 @@ }, { "kind": "ui-named-argument", - "line": 428, + "line": 430, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -2755,7 +2755,7 @@ }, { "kind": "conditional-branch", - "line": 439, + "line": 441, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -2763,7 +2763,7 @@ }, { "kind": "conditional-branch", - "line": 439, + "line": 441, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -2771,7 +2771,7 @@ }, { "kind": "ui-named-argument", - "line": 452, + "line": 454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -2779,7 +2779,7 @@ }, { "kind": "ui-named-argument", - "line": 453, + "line": 455, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -2787,7 +2787,7 @@ }, { "kind": "ui-named-argument", - "line": 459, + "line": 461, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -2795,7 +2795,7 @@ }, { "kind": "ui-named-argument", - "line": 460, + "line": 462, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -2803,7 +2803,7 @@ }, { "kind": "ui-named-argument", - "line": 467, + "line": 469, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -2811,7 +2811,7 @@ }, { "kind": "ui-named-argument", - "line": 468, + "line": 470, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -2819,7 +2819,7 @@ }, { "kind": "ui-named-argument", - "line": 482, + "line": 484, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -2827,7 +2827,7 @@ }, { "kind": "ui-named-argument", - "line": 482, + "line": 484, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -2835,7 +2835,7 @@ }, { "kind": "ui-named-argument", - "line": 485, + "line": 487, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -2843,7 +2843,7 @@ }, { "kind": "ui-named-argument", - "line": 486, + "line": 488, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -2851,7 +2851,7 @@ }, { "kind": "ui-named-argument", - "line": 505, + "line": 507, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -2859,7 +2859,7 @@ }, { "kind": "ui-named-argument", - "line": 505, + "line": 507, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -2867,7 +2867,7 @@ }, { "kind": "ui-named-argument", - "line": 508, + "line": 510, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -2875,7 +2875,7 @@ }, { "kind": "ui-named-argument", - "line": 509, + "line": 511, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -2883,7 +2883,7 @@ }, { "kind": "conditional-branch", - "line": 512, + "line": 514, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -2891,7 +2891,7 @@ }, { "kind": "conditional-branch", - "line": 512, + "line": 514, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -2899,7 +2899,7 @@ }, { "kind": "conditional-branch", - "line": 513, + "line": 515, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -2907,7 +2907,7 @@ }, { "kind": "conditional-branch", - "line": 513, + "line": 515, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -2915,7 +2915,7 @@ }, { "kind": "conditional-branch", - "line": 515, + "line": 517, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -2923,7 +2923,7 @@ }, { "kind": "conditional-branch", - "line": 515, + "line": 517, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -2931,7 +2931,7 @@ }, { "kind": "ui-named-argument", - "line": 519, + "line": 521, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -2939,7 +2939,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 531, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -2947,7 +2947,7 @@ }, { "kind": "ui-named-argument", - "line": 530, + "line": 532, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -2955,7 +2955,7 @@ }, { "kind": "conditional-branch", - "line": 658, + "line": 660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -2963,7 +2963,7 @@ }, { "kind": "conditional-branch", - "line": 658, + "line": 660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -2971,7 +2971,7 @@ }, { "kind": "ui-named-argument", - "line": 691, + "line": 693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -2979,7 +2979,7 @@ }, { "kind": "ui-named-argument", - "line": 691, + "line": 693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -2987,7 +2987,7 @@ }, { "kind": "conditional-branch", - "line": 695, + "line": 697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -2995,7 +2995,7 @@ }, { "kind": "conditional-branch", - "line": 695, + "line": 697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3003,7 +3003,7 @@ }, { "kind": "conditional-branch", - "line": 707, + "line": 709, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3011,7 +3011,7 @@ }, { "kind": "conditional-branch", - "line": 707, + "line": 709, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3019,7 +3019,7 @@ }, { "kind": "conditional-branch", - "line": 712, + "line": 714, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3027,7 +3027,7 @@ }, { "kind": "conditional-branch", - "line": 712, + "line": 714, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3035,7 +3035,7 @@ }, { "kind": "ui-named-argument", - "line": 722, + "line": 724, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3043,7 +3043,7 @@ }, { "kind": "ui-named-argument", - "line": 771, + "line": 773, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3051,7 +3051,7 @@ }, { "kind": "conditional-branch", - "line": 778, + "line": 780, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3059,7 +3059,7 @@ }, { "kind": "conditional-branch", - "line": 778, + "line": 780, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3067,7 +3067,7 @@ }, { "kind": "ui-named-argument", - "line": 783, + "line": 785, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3075,7 +3075,7 @@ }, { "kind": "ui-named-argument", - "line": 786, + "line": 788, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3083,7 +3083,7 @@ }, { "kind": "ui-named-argument", - "line": 787, + "line": 789, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3091,7 +3091,7 @@ }, { "kind": "ui-named-argument", - "line": 794, + "line": 796, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3099,7 +3099,7 @@ }, { "kind": "ui-named-argument", - "line": 805, + "line": 807, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3107,7 +3107,7 @@ }, { "kind": "ui-named-argument", - "line": 1044, + "line": 1046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3115,7 +3115,7 @@ }, { "kind": "ui-named-argument", - "line": 1044, + "line": 1046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3123,7 +3123,7 @@ }, { "kind": "conditional-branch", - "line": 1053, + "line": 1055, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3131,7 +3131,7 @@ }, { "kind": "conditional-branch", - "line": 1053, + "line": 1055, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3139,7 +3139,7 @@ }, { "kind": "conditional-branch", - "line": 1063, + "line": 1065, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3147,7 +3147,7 @@ }, { "kind": "conditional-branch", - "line": 1063, + "line": 1065, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3155,7 +3155,7 @@ }, { "kind": "ui-named-argument", - "line": 1074, + "line": 1076, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3163,7 +3163,7 @@ }, { "kind": "ui-named-argument", - "line": 1082, + "line": 1084, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3171,7 +3171,7 @@ }, { "kind": "ui-call", - "line": 1107, + "line": 1109, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3179,7 +3179,7 @@ }, { "kind": "ui-call", - "line": 1109, + "line": 1111, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3187,7 +3187,7 @@ }, { "kind": "ui-call", - "line": 1122, + "line": 1124, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3195,7 +3195,7 @@ }, { "kind": "ui-call", - "line": 1127, + "line": 1129, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3203,7 +3203,7 @@ }, { "kind": "ui-call", - "line": 1175, + "line": 1177, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3211,7 +3211,7 @@ }, { "kind": "ui-call", - "line": 1190, + "line": 1192, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3219,7 +3219,7 @@ }, { "kind": "ui-call", - "line": 1206, + "line": 1208, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3227,7 +3227,7 @@ }, { "kind": "ui-call", - "line": 1219, + "line": 1221, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3235,7 +3235,7 @@ }, { "kind": "ui-named-argument", - "line": 1225, + "line": 1227, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3243,7 +3243,7 @@ }, { "kind": "conditional-branch", - "line": 1229, + "line": 1231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3251,7 +3251,7 @@ }, { "kind": "conditional-branch", - "line": 1229, + "line": 1231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3259,7 +3259,7 @@ }, { "kind": "conditional-branch", - "line": 1230, + "line": 1232, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3267,7 +3267,7 @@ }, { "kind": "conditional-branch", - "line": 1230, + "line": 1232, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3275,7 +3275,7 @@ }, { "kind": "ui-named-argument", - "line": 1240, + "line": 1242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3283,7 +3283,7 @@ }, { "kind": "ui-named-argument", - "line": 1241, + "line": 1243, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3291,7 +3291,7 @@ }, { "kind": "ui-named-argument", - "line": 1245, + "line": 1247, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3299,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 1247, + "line": 1249, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3307,7 +3307,7 @@ }, { "kind": "ui-call", - "line": 1267, + "line": 1269, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3315,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 1289, + "line": 1291, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3323,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 1291, + "line": 1293, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3331,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 1298, + "line": 1300, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3339,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 1299, + "line": 1301, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3347,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 1303, + "line": 1305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3355,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 1312, + "line": 1314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3363,7 +3363,7 @@ }, { "kind": "ui-named-argument", - "line": 1313, + "line": 1315, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3371,7 +3371,7 @@ }, { "kind": "ui-named-argument", - "line": 1315, + "line": 1317, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3379,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 1316, + "line": 1318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3387,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 1318, + "line": 1320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3395,7 +3395,7 @@ }, { "kind": "conditional-branch", - "line": 1322, + "line": 1324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3403,7 +3403,7 @@ }, { "kind": "conditional-branch", - "line": 1322, + "line": 1324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3411,7 +3411,7 @@ }, { "kind": "ui-named-argument", - "line": 1339, + "line": 1341, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3419,7 +3419,7 @@ }, { "kind": "ui-named-argument", - "line": 1340, + "line": 1342, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3427,7 +3427,7 @@ }, { "kind": "ui-named-argument", - "line": 1342, + "line": 1344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3435,7 +3435,7 @@ }, { "kind": "ui-named-argument", - "line": 1347, + "line": 1349, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -3443,7 +3443,7 @@ }, { "kind": "ui-named-argument", - "line": 1364, + "line": 1366, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -3451,7 +3451,7 @@ }, { "kind": "ui-named-argument", - "line": 1387, + "line": 1389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "A calm, high-contrast OpenClaw interface.", "surface": "android", @@ -3459,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 1387, + "line": 1389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -3467,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 1398, + "line": 1400, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -3475,7 +3475,7 @@ }, { "kind": "conditional-branch", - "line": 1443, + "line": 1445, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -3483,7 +3483,7 @@ }, { "kind": "ui-named-argument", - "line": 1472, + "line": 1474, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -3491,7 +3491,7 @@ }, { "kind": "ui-named-argument", - "line": 1472, + "line": 1474, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -3499,7 +3499,7 @@ }, { "kind": "ui-named-argument", - "line": 1485, + "line": 1487, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -3507,7 +3507,7 @@ }, { "kind": "ui-named-argument", - "line": 1487, + "line": 1489, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -3515,7 +3515,7 @@ }, { "kind": "ui-named-argument", - "line": 1490, + "line": 1492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -3523,7 +3523,7 @@ }, { "kind": "ui-named-argument", - "line": 1501, + "line": 1503, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -3531,7 +3531,7 @@ }, { "kind": "ui-named-argument", - "line": 1518, + "line": 1520, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -3539,7 +3539,7 @@ }, { "kind": "ui-named-argument", - "line": 1520, + "line": 1522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -3547,7 +3547,7 @@ }, { "kind": "ui-named-argument", - "line": 1521, + "line": 1523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -3555,7 +3555,7 @@ }, { "kind": "ui-named-argument", - "line": 1580, + "line": 1582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -3563,7 +3563,7 @@ }, { "kind": "conditional-branch", - "line": 1581, + "line": 1583, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -3571,7 +3571,7 @@ }, { "kind": "ui-named-argument", - "line": 1590, + "line": 1592, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -3579,7 +3579,7 @@ }, { "kind": "ui-named-argument", - "line": 1616, + "line": 1618, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -3587,7 +3587,7 @@ }, { "kind": "conditional-branch", - "line": 1651, + "line": 1653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -3595,7 +3595,7 @@ }, { "kind": "ui-named-argument", - "line": 1684, + "line": 1686, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -3603,7 +3603,7 @@ }, { "kind": "conditional-branch", - "line": 1758, + "line": 1760, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -3611,7 +3611,7 @@ }, { "kind": "conditional-branch", - "line": 1767, + "line": 1769, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -3619,7 +3619,7 @@ }, { "kind": "conditional-branch", - "line": 1767, + "line": 1769, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowing", "surface": "android", @@ -3627,7 +3627,7 @@ }, { "kind": "conditional-branch", - "line": 1775, + "line": 1777, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -3635,7 +3635,7 @@ }, { "kind": "conditional-branch", - "line": 1775, + "line": 1777, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Saving", "surface": "android", @@ -3643,7 +3643,7 @@ }, { "kind": "conditional-branch", - "line": 1783, + "line": 1785, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -3651,7 +3651,7 @@ }, { "kind": "conditional-branch", - "line": 1783, + "line": 1785, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Denying", "surface": "android", @@ -3659,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 1808, + "line": 1810, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -3667,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 1836, + "line": 1838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -3675,7 +3675,7 @@ }, { "kind": "conditional-branch", - "line": 1866, + "line": 1868, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -3683,7 +3683,7 @@ }, { "kind": "conditional-branch", - "line": 1880, + "line": 1882, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -3691,7 +3691,7 @@ }, { "kind": "conditional-branch", - "line": 1880, + "line": 1882, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -3699,7 +3699,7 @@ }, { "kind": "ui-named-argument", - "line": 1898, + "line": 1900, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -3707,7 +3707,7 @@ }, { "kind": "ui-named-argument", - "line": 1901, + "line": 1903, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -3715,7 +3715,7 @@ }, { "kind": "ui-named-argument", - "line": 1940, + "line": 1942, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -3723,7 +3723,7 @@ }, { "kind": "ui-toast", - "line": 1955, + "line": 1957, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -3731,7 +3731,7 @@ }, { "kind": "conditional-branch", - "line": 1993, + "line": 1995, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -3739,7 +3739,7 @@ }, { "kind": "conditional-branch", - "line": 1995, + "line": 1997, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -3747,7 +3747,7 @@ }, { "kind": "conditional-branch", - "line": 2051, + "line": 2053, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -3755,7 +3755,7 @@ }, { "kind": "conditional-branch", - "line": 2054, + "line": 2056, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -3763,7 +3763,7 @@ }, { "kind": "conditional-branch", - "line": 2054, + "line": 2056, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -3771,7 +3771,7 @@ }, { "kind": "conditional-branch", - "line": 2082, + "line": 2084, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -3779,7 +3779,7 @@ }, { "kind": "conditional-branch", - "line": 2089, + "line": 2091, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -3787,7 +3787,7 @@ }, { "kind": "conditional-branch", - "line": 2120, + "line": 2122, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -3795,7 +3795,7 @@ }, { "kind": "conditional-branch", - "line": 2141, + "line": 2143, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -3803,7 +3803,7 @@ }, { "kind": "conditional-branch", - "line": 2142, + "line": 2144, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -3811,7 +3811,7 @@ }, { "kind": "conditional-branch", - "line": 2143, + "line": 2145, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -3819,7 +3819,7 @@ }, { "kind": "conditional-branch", - "line": 2144, + "line": 2146, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -4347,7 +4347,7 @@ }, { "kind": "ui-named-argument", - "line": 436, + "line": 437, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Overview", "surface": "android", @@ -4355,7 +4355,7 @@ }, { "kind": "ui-named-argument", - "line": 481, + "line": 482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No recent sessions", "surface": "android", @@ -4363,7 +4363,7 @@ }, { "kind": "ui-named-argument", - "line": 482, + "line": 483, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Start a chat and your active OpenClaw conversations will appear here.", "surface": "android", @@ -4371,7 +4371,7 @@ }, { "kind": "ui-named-argument", - "line": 483, + "line": 484, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Start Chat", "surface": "android", @@ -4379,7 +4379,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 530, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw", "surface": "android", @@ -4387,7 +4387,7 @@ }, { "kind": "ui-named-argument", - "line": 537, + "line": 538, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Search", "surface": "android", @@ -4395,7 +4395,7 @@ }, { "kind": "ui-named-argument", - "line": 589, + "line": 590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "ACTIVE AGENT", "surface": "android", @@ -4403,7 +4403,7 @@ }, { "kind": "ui-named-argument", - "line": 598, + "line": 599, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "View", "surface": "android", @@ -4411,7 +4411,7 @@ }, { "kind": "conditional-branch", - "line": 601, + "line": 602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$pendingRunCount active", "surface": "android", @@ -4419,7 +4419,7 @@ }, { "kind": "conditional-branch", - "line": 601, + "line": 602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Idle", "surface": "android", @@ -4427,7 +4427,7 @@ }, { "kind": "ui-named-argument", - "line": 601, + "line": 602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Runs", "surface": "android", @@ -4435,7 +4435,7 @@ }, { "kind": "conditional-branch", - "line": 602, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$sessionCount recent", "surface": "android", @@ -4443,7 +4443,7 @@ }, { "kind": "ui-named-argument", - "line": 602, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Sessions", "surface": "android", @@ -4451,7 +4451,7 @@ }, { "kind": "ui-named-argument", - "line": 603, + "line": 604, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Cron", "surface": "android", @@ -4459,7 +4459,7 @@ }, { "kind": "ui-named-argument", - "line": 606, + "line": 607, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Chat", "surface": "android", @@ -4467,7 +4467,7 @@ }, { "kind": "ui-named-argument", - "line": 610, + "line": 611, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Reconnect gateway", "surface": "android", @@ -4475,7 +4475,7 @@ }, { "kind": "ui-named-argument", - "line": 758, + "line": 759, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open ${card.title}", "surface": "android", @@ -4483,7 +4483,7 @@ }, { "kind": "ui-named-argument", - "line": 826, + "line": 827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Talk", "surface": "android", @@ -4491,7 +4491,7 @@ }, { "kind": "ui-named-argument", - "line": 827, + "line": 828, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open Talk", "surface": "android", @@ -4499,7 +4499,7 @@ }, { "kind": "ui-named-argument", - "line": 829, + "line": 830, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Talk settings", "surface": "android", @@ -4507,7 +4507,7 @@ }, { "kind": "ui-named-argument", - "line": 837, + "line": 838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Recent Sessions", "surface": "android", @@ -4515,7 +4515,7 @@ }, { "kind": "ui-named-argument", - "line": 847, + "line": 848, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "View all", "surface": "android", @@ -4523,7 +4523,7 @@ }, { "kind": "conditional-branch", - "line": 986, + "line": 987, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$onlineNodes/$nodeCount", "surface": "android", @@ -4531,7 +4531,7 @@ }, { "kind": "conditional-branch", - "line": 986, + "line": 987, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "None", "surface": "android", @@ -4539,7 +4539,7 @@ }, { "kind": "conditional-branch", - "line": 1018, + "line": 1019, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Recent conversations", "surface": "android", @@ -4547,7 +4547,7 @@ }, { "kind": "conditional-branch", - "line": 1025, + "line": 1026, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Browse", "surface": "android", @@ -4555,7 +4555,7 @@ }, { "kind": "conditional-branch", - "line": 1025, + "line": 1026, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Offline", "surface": "android", @@ -4563,7 +4563,7 @@ }, { "kind": "conditional-branch", - "line": 1077, + "line": 1078, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Working · $pendingRunCount active ${pluralize(\"run\", pendingRunCount)}", "surface": "android", @@ -4571,7 +4571,7 @@ }, { "kind": "ui-named-argument", - "line": 1198, + "line": 1199, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Needs attention", "surface": "android", @@ -4579,7 +4579,7 @@ }, { "kind": "ui-named-argument", - "line": 1263, + "line": 1264, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open ${row.title}", "surface": "android", @@ -4587,7 +4587,7 @@ }, { "kind": "ui-named-argument", - "line": 1371, + "line": 1372, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open session", "surface": "android", @@ -4595,7 +4595,7 @@ }, { "kind": "ui-named-argument", - "line": 1483, + "line": 1486, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Back", "surface": "android", @@ -4603,7 +4603,7 @@ }, { "kind": "ui-named-argument", - "line": 1486, + "line": 1489, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Settings", "surface": "android", @@ -4611,7 +4611,7 @@ }, { "kind": "ui-named-argument", - "line": 1489, + "line": 1492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Search settings", "surface": "android", @@ -4619,7 +4619,7 @@ }, { "kind": "conditional-branch", - "line": 1513, + "line": 1516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$readyProviderCount ready", "surface": "android", @@ -4627,7 +4627,7 @@ }, { "kind": "conditional-branch", - "line": 1513, + "line": 1516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Review readiness", "surface": "android", @@ -4635,7 +4635,7 @@ }, { "kind": "conditional-branch", - "line": 1524, + "line": 1534, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Speaker muted", "surface": "android", @@ -4643,7 +4643,7 @@ }, { "kind": "conditional-branch", - "line": 1524, + "line": 1534, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Speaker on", "surface": "android", @@ -4651,7 +4651,7 @@ }, { "kind": "conditional-branch", - "line": 1526, + "line": 1536, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Off", "surface": "android", @@ -4659,7 +4659,7 @@ }, { "kind": "conditional-branch", - "line": 1526, + "line": 1536, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Smart delivery", "surface": "android", @@ -4667,7 +4667,7 @@ }, { "kind": "conditional-branch", - "line": 1527, + "line": 1537, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Camera enabled", "surface": "android", @@ -4675,7 +4675,7 @@ }, { "kind": "conditional-branch", - "line": 1527, + "line": 1537, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Locked", "surface": "android", @@ -4683,7 +4683,7 @@ }, { "kind": "ui-named-argument", - "line": 1569, + "line": 1579, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", "surface": "android", @@ -4691,7 +4691,7 @@ }, { "kind": "conditional-branch", - "line": 1572, + "line": 1582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "All systems operational", "surface": "android", @@ -4699,7 +4699,7 @@ }, { "kind": "conditional-branch", - "line": 1572, + "line": 1582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Gateway not connected", "surface": "android", @@ -4707,7 +4707,7 @@ }, { "kind": "conditional-branch", - "line": 1604, + "line": 1614, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No provider usage", "surface": "android", @@ -4715,7 +4715,7 @@ }, { "kind": "conditional-branch", - "line": 1605, + "line": 1615, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "1 provider", "surface": "android", @@ -4723,7 +4723,7 @@ }, { "kind": "conditional-branch", - "line": 1606, + "line": 1616, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$count providers", "surface": "android", @@ -4731,7 +4731,7 @@ }, { "kind": "conditional-branch", - "line": 1612, + "line": 1622, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$ready/${skills.size} ready", "surface": "android", @@ -4739,15 +4739,63 @@ }, { "kind": "conditional-branch", - "line": 1612, + "line": 1622, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No skills", "surface": "android", "id": "native.android.087c72ddfc807c5c" }, + { + "kind": "conditional-branch", + "line": 1636, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "$pending pending", + "surface": "android", + "id": "native.android.560fea9426127f28" + }, + { + "kind": "conditional-branch", + "line": 1636, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "1 pending", + "surface": "android", + "id": "native.android.5d6077f3ff6528f8" + }, + { + "kind": "conditional-branch", + "line": 1641, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "$held held", + "surface": "android", + "id": "native.android.c233607d1c8f2b91" + }, + { + "kind": "conditional-branch", + "line": 1641, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "1 held", + "surface": "android", + "id": "native.android.139b4b73d5b2b093" + }, + { + "kind": "conditional-branch", + "line": 1642, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "$applied applied", + "surface": "android", + "id": "native.android.c13bf9565ea436b4" + }, + { + "kind": "conditional-branch", + "line": 1642, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "1 applied", + "surface": "android", + "id": "native.android.c4036bd26d44345c" + }, { "kind": "ui-named-argument", - "line": 1790, + "line": 1823, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw mobile", "surface": "android", @@ -4755,7 +4803,7 @@ }, { "kind": "ui-named-argument", - "line": 1794, + "line": 1827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open profile", "surface": "android", @@ -4763,12 +4811,244 @@ }, { "kind": "conditional-branch", - "line": 1879, + "line": 1912, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Main session", "surface": "android", "id": "native.android.22d755834386db07" }, + { + "kind": "ui-named-argument", + "line": 127, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Skill Workshop", + "surface": "android", + "id": "native.android.7c6f9feb2c16785f" + }, + { + "kind": "ui-named-argument", + "line": 128, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Review generated skill proposals before they become live skills.", + "surface": "android", + "id": "native.android.21c19709950c3f39" + }, + { + "kind": "ui-named-argument", + "line": 183, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Gateway offline", + "surface": "android", + "id": "native.android.a68e07b919a2a0d1" + }, + { + "kind": "ui-named-argument", + "line": 184, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Connect to a Gateway to load Skill Workshop proposals.", + "surface": "android", + "id": "native.android.f4baaca21d1b59d8" + }, + { + "kind": "ui-named-argument", + "line": 188, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "No proposals", + "surface": "android", + "id": "native.android.cbb84c87811aa6d4" + }, + { + "kind": "ui-named-argument", + "line": 189, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Matching proposals will appear here after agents create reusable skill drafts.", + "surface": "android", + "id": "native.android.90a5b0a2e06cd91e" + }, + { + "kind": "ui-call", + "line": 265, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "${action.action.label} proposal?", + "surface": "android", + "id": "native.android.445ab883554e0249" + }, + { + "kind": "ui-named-argument", + "line": 268, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "This will ${action.action.label.lowercase()} \"${action.title}\" and refresh Skill Workshop state from the gateway.", + "surface": "android", + "id": "native.android.3b3a3f231ac05371" + }, + { + "kind": "ui-call", + "line": 279, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Cancel", + "surface": "android", + "id": "native.android.de53fb795cb7dbea" + }, + { + "kind": "conditional-branch", + "line": 314, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Refresh", + "surface": "android", + "id": "native.android.4c29d1be6804b451" + }, + { + "kind": "conditional-branch", + "line": 314, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Refreshing", + "surface": "android", + "id": "native.android.bb2b28111f0f9190" + }, + { + "kind": "ui-named-argument", + "line": 328, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Search proposals", + "surface": "android", + "id": "native.android.cb094500c3b20cfe" + }, + { + "kind": "conditional-branch", + "line": 444, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Open", + "surface": "android", + "id": "native.android.71aa271f37832333" + }, + { + "kind": "conditional-branch", + "line": 444, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Selected", + "surface": "android", + "id": "native.android.f032646f923b66b3" + }, + { + "kind": "ui-named-argument", + "line": 507, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions.", + "surface": "android", + "id": "native.android.cc4d05c30c0dcaa7" + }, + { + "kind": "ui-named-argument", + "line": 528, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Skill Workshop inspect and apply actions", + "surface": "android", + "id": "native.android.b03bb83bfe200f83" + }, + { + "kind": "conditional-branch", + "line": 532, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Inspect", + "surface": "android", + "id": "native.android.96e9ca04a5f6b505" + }, + { + "kind": "conditional-branch", + "line": 532, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Inspecting", + "surface": "android", + "id": "native.android.3be9c3b373e250ed" + }, + { + "kind": "conditional-branch", + "line": 538, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Apply", + "surface": "android", + "id": "native.android.e16e6dd1cfd597e6" + }, + { + "kind": "conditional-branch", + "line": 538, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Working", + "surface": "android", + "id": "native.android.144ff98399b6eb4a" + }, + { + "kind": "ui-named-argument", + "line": 554, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Skill Workshop reject and quarantine actions", + "surface": "android", + "id": "native.android.6f4c6b0e89efb38f" + }, + { + "kind": "ui-named-argument", + "line": 558, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Reject", + "surface": "android", + "id": "native.android.63c1b72274895358" + }, + { + "kind": "ui-named-argument", + "line": 570, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Quarantine", + "surface": "android", + "id": "native.android.b3b9eca78bd90d1c" + }, + { + "kind": "conditional-branch", + "line": 648, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Pending", + "surface": "android", + "id": "native.android.cabcb1f61fa3cd6f" + }, + { + "kind": "conditional-branch", + "line": 649, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Held", + "surface": "android", + "id": "native.android.2d78d0c70c1913ac" + }, + { + "kind": "conditional-branch", + "line": 650, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Applied", + "surface": "android", + "id": "native.android.733725d1c8280de8" + }, + { + "kind": "conditional-branch", + "line": 651, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Rejected", + "surface": "android", + "id": "native.android.bb3a115829f2c1ec" + }, + { + "kind": "conditional-branch", + "line": 652, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "All", + "surface": "android", + "id": "native.android.10bf039a2781c9b3" + }, + { + "kind": "conditional-branch", + "line": 678, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", + "source": "Default agent", + "surface": "android", + "id": "native.android.6ce9b4b919803618" + }, { "kind": "ui-named-argument", "line": 67, diff --git a/apps/android/README.md b/apps/android/README.md index 993129f8314..c5eaed54bab 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -18,6 +18,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O - [x] Authenticated background presence beacons - [x] Voice tab full functionality - [x] Screen tab full functionality +- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs ## Open in Android Studio diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index ba38a004f3e..1d455944100 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -158,6 +158,7 @@ class MainViewModel( val gatewayConnectionProblem: StateFlow = runtimeState(initial = null) { it.gatewayConnectionProblem } val gatewayConnectionDisplay: StateFlow = runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay } + val operatorAdminScopeAvailable: StateFlow = runtimeState(initial = false) { it.operatorAdminScopeAvailable } val serverName: StateFlow = runtimeState(initial = null) { it.serverName } val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } val gatewayVersion: StateFlow = runtimeState(initial = null) { it.gatewayVersion } @@ -184,6 +185,13 @@ class MainViewModel( val skillsSummary: StateFlow = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary } val skillsRefreshing: StateFlow = runtimeState(initial = false) { it.skillsRefreshing } val skillsErrorText: StateFlow = runtimeState(initial = null) { it.skillsErrorText } + val skillWorkshopSummary: StateFlow = + runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary } + val skillWorkshopRefreshing: StateFlow = runtimeState(initial = false) { it.skillWorkshopRefreshing } + val skillWorkshopErrorText: StateFlow = runtimeState(initial = null) { it.skillWorkshopErrorText } + val skillWorkshopNoticeText: StateFlow = runtimeState(initial = null) { it.skillWorkshopNoticeText } + val skillWorkshopInspectingProposalId: StateFlow = runtimeState(initial = null) { it.skillWorkshopInspectingProposalId } + val skillWorkshopMutatingProposalId: StateFlow = runtimeState(initial = null) { it.skillWorkshopMutatingProposalId } val nodesDevicesSummary: StateFlow = runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary } val nodesDevicesRefreshing: StateFlow = runtimeState(initial = false) { it.nodesDevicesRefreshing } @@ -711,6 +719,46 @@ class MainViewModel( ensureRuntime().refreshSkills() } + fun refreshSkillWorkshopProposals(agentId: String? = null) { + ensureRuntime().refreshSkillWorkshopProposals(agentId = agentId) + } + + fun resetSkillWorkshopAgentScope(agentId: String? = null) { + ensureRuntime().resetSkillWorkshopAgentScope(agentId = agentId) + } + + fun inspectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().inspectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun applySkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().applySkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun rejectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().rejectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun quarantineSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().quarantineSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun clearSkillWorkshopMessage() { + ensureRuntime().clearSkillWorkshopMessage() + } + fun refreshNodesDevices() { ensureRuntime().refreshNodesDevices() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 9249abe7567..9881881615c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -89,10 +89,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -113,6 +116,18 @@ import java.util.concurrent.atomic.AtomicReference private const val MAX_PENDING_NOTIFICATION_EVENTS = 128 private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L +private const val OperatorAdminScope = "operator.admin" + +private enum class SkillWorkshopGatewayAction( + val methodSuffix: String, + val expectedStatus: String, + val notice: String, + val verb: String, +) { + Apply("apply", "applied", "Proposal applied.", "apply"), + Reject("reject", "rejected", "Proposal rejected.", "reject"), + Quarantine("quarantine", "quarantined", "Proposal quarantined.", "quarantine"), +} internal data class PendingNotificationNodeEvent( val event: String, @@ -626,6 +641,12 @@ class NodeRuntime private constructor( val statusText: StateFlow = _statusText.asStateFlow() private val _gatewayConnectionProblem = MutableStateFlow(null) val gatewayConnectionProblem: StateFlow = _gatewayConnectionProblem.asStateFlow() + private val _operatorScopes = MutableStateFlow>(emptyList()) + val operatorScopes: StateFlow> = _operatorScopes.asStateFlow() + val operatorAdminScopeAvailable: StateFlow = + operatorScopes + .map { scopes -> scopes.any { it == OperatorAdminScope } } + .stateIn(scope, SharingStarted.Eagerly, false) private val _pendingGatewayTrust = MutableStateFlow(null) val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() @@ -708,6 +729,21 @@ class NodeRuntime private constructor( val skillsRefreshing: StateFlow = _skillsRefreshing.asStateFlow() private val _skillsErrorText = MutableStateFlow(null) val skillsErrorText: StateFlow = _skillsErrorText.asStateFlow() + private val _skillWorkshopSummary = MutableStateFlow(GatewaySkillWorkshopSummary(proposals = emptyList())) + val skillWorkshopSummary: StateFlow = _skillWorkshopSummary.asStateFlow() + private val _skillWorkshopRefreshing = MutableStateFlow(false) + val skillWorkshopRefreshing: StateFlow = _skillWorkshopRefreshing.asStateFlow() + private val _skillWorkshopErrorText = MutableStateFlow(null) + val skillWorkshopErrorText: StateFlow = _skillWorkshopErrorText.asStateFlow() + private val _skillWorkshopNoticeText = MutableStateFlow(null) + val skillWorkshopNoticeText: StateFlow = _skillWorkshopNoticeText.asStateFlow() + private val _skillWorkshopInspectingProposalId = MutableStateFlow(null) + val skillWorkshopInspectingProposalId: StateFlow = _skillWorkshopInspectingProposalId.asStateFlow() + private val _skillWorkshopMutatingProposalId = MutableStateFlow(null) + val skillWorkshopMutatingProposalId: StateFlow = _skillWorkshopMutatingProposalId.asStateFlow() + private val skillWorkshopListSeq = AtomicLong(0) + private val skillWorkshopInspectSeq = AtomicLong(0) + private val skillWorkshopMutationSeq = AtomicLong(0) private val _nodesDevicesSummary = MutableStateFlow( GatewayNodesDevicesSummary( @@ -791,6 +827,7 @@ class NodeRuntime private constructor( _remoteAddress.value = hello.remoteAddress _gatewayVersion.value = hello.serverVersion _gatewayUpdateAvailable.value = hello.updateAvailable + _operatorScopes.value = normalizeOperatorScopes(hello.authScopes) _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey)) // Every successful connection refreshes history; reconnects preserve local run ownership. @@ -839,6 +876,7 @@ class NodeRuntime private constructor( _remoteAddress.value = null _gatewayVersion.value = null _gatewayUpdateAvailable.value = null + _operatorScopes.value = emptyList() _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _gatewayDefaultAgentId.value = null _gatewayAgents.value = emptyList() @@ -859,6 +897,15 @@ class NodeRuntime private constructor( _skillsSummary.value = GatewaySkillsSummary(skills = emptyList()) _skillsRefreshing.value = false _skillsErrorText.value = null + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + skillWorkshopListSeq.incrementAndGet() + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() _nodesDevicesSummary.value = GatewayNodesDevicesSummary( nodes = emptyList(), @@ -1377,6 +1424,74 @@ class NodeRuntime private constructor( } } + fun refreshSkillWorkshopProposals(agentId: String? = null) { + scope.launch { + refreshSkillWorkshopProposalsFromGateway(agentId = agentId) + } + } + + fun resetSkillWorkshopAgentScope(agentId: String? = null) { + val normalizedAgentId = normalizeSkillWorkshopAgentId(agentId) + skillWorkshopListSeq.incrementAndGet() + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = normalizedAgentId, proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + } + + fun inspectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + val normalized = proposalId.trim() + if (normalized.isEmpty()) return + scope.launch { + inspectSkillWorkshopProposalFromGateway(proposalId = normalized, agentId = agentId) + } + } + + fun applySkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Apply) + } + + fun rejectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Reject) + } + + fun quarantineSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Quarantine) + } + + private fun mutateSkillWorkshopProposal( + proposalId: String, + agentId: String?, + action: SkillWorkshopGatewayAction, + ) { + val normalized = proposalId.trim() + if (normalized.isEmpty()) return + scope.launch { + mutateSkillWorkshopProposalOnGateway(proposalId = normalized, agentId = agentId, action = action) + } + } + + fun clearSkillWorkshopMessage() { + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + } + fun refreshNodesDevices() { if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { @@ -3602,6 +3717,246 @@ class NodeRuntime private constructor( } } + private suspend fun refreshSkillWorkshopProposalsFromGateway(agentId: String?) { + val listSeq = skillWorkshopListSeq.incrementAndGet() + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = "Connect the gateway to load Skill Workshop proposals." + return + } + publishGatewayData(gatewayScope) { + _skillWorkshopRefreshing.value = true + _skillWorkshopErrorText.value = null + if (_skillWorkshopSummary.value.agentId != requestAgentId) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList()) + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() + } + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.list", + skillWorkshopParams(agentId = agentId).toString(), + ) + val root = json.parseToJsonElement(res).asObjectOrNull() + val previousById = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.associateBy { it.id } + .orEmpty() + val proposals = parseSkillWorkshopProposals(root?.get("proposals") as? JsonArray, previousById) + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = proposals) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = "Could not load Skill Workshop proposals." + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopRefreshing.value = false + } + } + } + } + + private suspend fun inspectSkillWorkshopProposalFromGateway( + proposalId: String, + agentId: String?, + ) { + var inspectSeq = 0L + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopErrorText.value = "Connect the gateway to inspect Skill Workshop proposals." + return + } + var inspectStarted = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } && + _skillWorkshopMutatingProposalId.value == null + ) { + inspectStarted = true + inspectSeq = skillWorkshopInspectSeq.incrementAndGet() + _skillWorkshopInspectingProposalId.value = proposalId + _skillWorkshopErrorText.value = null + } + } + if (!scopeCurrent || !inspectStarted) { + return + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.inspect", + skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(), + ) + val root = json.parseToJsonElement(res).asObjectOrNull() + val previous = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.firstOrNull { it.id == proposalId } + val inspected = + parseSkillWorkshopProposalInspect(root, previous) + ?: throw IllegalStateException("skills.proposals.inspect returned no proposal") + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + skillWorkshopInspectSeq.get() == inspectSeq && + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } + ) { + _skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(inspected) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = "Could not inspect Skill Workshop proposal." + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopInspectingProposalId.value = null + } + } + } + } + + private suspend fun mutateSkillWorkshopProposalOnGateway( + proposalId: String, + agentId: String?, + action: SkillWorkshopGatewayAction, + ) { + var mutationSeq = 0L + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + if (!operatorAdminScopeAvailable.value) { + _skillWorkshopErrorText.value = "Skill Workshop proposal actions require operator.admin scope." + return + } + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopErrorText.value = "Connect the gateway to update Skill Workshop proposals." + return + } + var mutationStarted = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } && + _skillWorkshopMutatingProposalId.value == null + ) { + mutationStarted = true + mutationSeq = skillWorkshopMutationSeq.incrementAndGet() + // A lifecycle action supersedes any older detail read. Without this + // guard, a late inspect response can restore the pre-action status. + skillWorkshopInspectSeq.incrementAndGet() + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = proposalId + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + } + } + if (!scopeCurrent || !mutationStarted) { + return + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.${action.methodSuffix}", + skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(), + ) + val updatedProposal = + parseSkillWorkshopProposalActionResult( + root = json.parseToJsonElement(res).asObjectOrNull(), + previous = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.firstOrNull { it.id == proposalId }, + ) + var mutationConfirmed = false + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + if (updatedProposal?.status == action.expectedStatus) { + _skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(updatedProposal) + _skillWorkshopNoticeText.value = action.notice + mutationConfirmed = true + } else { + val statusLabel = updatedProposal?.status?.takeIf { it.isNotBlank() } ?: "unknown" + _skillWorkshopErrorText.value = + "Gateway returned status '$statusLabel' after ${action.verb}." + } + } + } + if (!mutationConfirmed) return + var refreshStillCurrent = false + publishGatewayData(gatewayScope) { + refreshStillCurrent = + skillWorkshopMutationSeq.get() == mutationSeq && + _skillWorkshopSummary.value.agentId == requestAgentId + } + if (refreshStillCurrent) { + refreshSkillWorkshopProposalsFromGateway(agentId = agentId) + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = "Could not ${action.verb} Skill Workshop proposal." + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopMutatingProposalId.value = null + } + } + } + } + + private fun normalizeSkillWorkshopAgentId(agentId: String?): String = agentId?.trim().orEmpty() + + private fun skillWorkshopParams( + agentId: String?, + proposalId: String? = null, + ): JsonObject = + buildJsonObject { + val normalizedAgentId = agentId?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedAgentId != null) put("agentId", JsonPrimitive(normalizedAgentId)) + val normalizedProposalId = proposalId?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedProposalId != null) put("proposalId", JsonPrimitive(normalizedProposalId)) + } + private suspend fun refreshNodesDevicesFromGateway() { val gatewayScope = captureGatewayDataScope() ?: return val refreshGeneration = nodeApprovalRefreshGuard.begin() @@ -4199,6 +4554,113 @@ class NodeRuntime private constructor( ) }.orEmpty() + private fun parseSkillWorkshopProposals( + proposals: JsonArray?, + previousById: Map, + ): List { + val parsed = + proposals?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj.skillWorkshopString("id") ?: return@mapNotNull null + val previous = previousById[id] + val updatedAt = obj.skillWorkshopString("updatedAt").orEmpty() + GatewaySkillWorkshopProposal( + id = id, + kind = obj.skillWorkshopString("kind") ?: "proposal", + status = obj.skillWorkshopString("status") ?: "pending", + title = obj.skillWorkshopString("title") ?: obj.skillWorkshopString("skillName") ?: id, + description = obj.skillWorkshopString("description"), + skillName = obj.skillWorkshopString("skillName") ?: id, + skillKey = obj.skillWorkshopString("skillKey") ?: id, + createdAt = obj.skillWorkshopString("createdAt").orEmpty(), + updatedAt = updatedAt, + scanState = obj.skillWorkshopString("scanState"), + content = previous?.content?.takeIf { previous.updatedAt == updatedAt }, + supportFiles = previous?.supportFiles?.takeIf { previous.updatedAt == updatedAt }.orEmpty(), + ) + } + return parsed.orEmpty().sortedByDescending { it.updatedAt } + } + + private fun parseSkillWorkshopProposalInspect( + root: JsonObject?, + previous: GatewaySkillWorkshopProposal?, + ): GatewaySkillWorkshopProposal? { + val source = root ?: return null + val record = source["record"].asObjectOrNull() ?: return null + val id = record.skillWorkshopString("id") ?: previous?.id ?: return null + val target = record["target"].asObjectOrNull() + val updatedAt = record.skillWorkshopString("updatedAt").orEmpty() + return GatewaySkillWorkshopProposal( + id = id, + kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal", + status = record.skillWorkshopString("status") ?: previous?.status ?: "pending", + title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id, + description = record.skillWorkshopString("description") ?: previous?.description, + skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id, + skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id, + createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(), + updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() }, + scanState = record.skillWorkshopString("scanState") ?: previous?.scanState, + content = stripSkillWorkshopFrontmatter(source["content"].asStringOrNull().orEmpty()), + supportFiles = parseSkillWorkshopSupportFiles(source["supportFiles"] as? JsonArray), + ) + } + + private fun parseSkillWorkshopProposalActionResult( + root: JsonObject?, + previous: GatewaySkillWorkshopProposal?, + ): GatewaySkillWorkshopProposal? { + val record = + root?.get("record").asObjectOrNull() + ?: root?.takeIf { it.skillWorkshopString("status") != null } + ?: return null + val id = record.skillWorkshopString("id") ?: previous?.id ?: return null + val target = record["target"].asObjectOrNull() + val updatedAt = record.skillWorkshopString("updatedAt").orEmpty() + return GatewaySkillWorkshopProposal( + id = id, + kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal", + status = record.skillWorkshopString("status") ?: previous?.status ?: "pending", + title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id, + description = record.skillWorkshopString("description") ?: previous?.description, + skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id, + skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id, + createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(), + updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() }, + scanState = + record["scan"].asObjectOrNull()?.skillWorkshopString("state") + ?: record.skillWorkshopString("scanState") + ?: previous?.scanState, + content = previous?.content, + supportFiles = previous?.supportFiles.orEmpty(), + ) + } + + private fun parseSkillWorkshopSupportFiles(files: JsonArray?): List { + val parsed = + files?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val path = obj.skillWorkshopString("path") ?: return@mapNotNull null + GatewaySkillWorkshopSupportFile( + path = path, + content = obj["content"].asStringOrNull()?.takeIf { it.isNotEmpty() }, + ) + } + return parsed.orEmpty() + } + + private fun stripSkillWorkshopFrontmatter(content: String): String { + val withoutFrontmatter = content.replace(Regex("(?s)^---\\r?\\n.*?\\r?\\n---\\r?\\n?"), "") + return withoutFrontmatter.trim() + } + + private fun JsonObject.skillWorkshopString(key: String): String? = + get(key) + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 } private fun parsePendingDevices(devices: JsonArray?): List = @@ -4674,6 +5136,13 @@ internal fun operatorConnectScopesForAuth( return ConnectionManager.nativeClientOperatorScopes } +internal fun normalizeOperatorScopes(scopes: List): List = + scopes + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .sorted() + private enum class HomeCanvasGatewayState { Connected, Connecting, @@ -4767,6 +5236,38 @@ data class GatewaySkillsSummary( val skills: List, ) +data class GatewaySkillWorkshopSummary( + val agentId: String = "", + val proposals: List, +) { + fun withProposal(proposal: GatewaySkillWorkshopProposal): GatewaySkillWorkshopSummary = + copy( + proposals = + (proposals.filterNot { it.id == proposal.id } + proposal) + .sortedByDescending { it.updatedAt }, + ) +} + +data class GatewaySkillWorkshopProposal( + val id: String, + val kind: String, + val status: String, + val title: String, + val description: String?, + val skillName: String, + val skillKey: String, + val createdAt: String, + val updatedAt: String, + val scanState: String?, + val content: String? = null, + val supportFiles: List = emptyList(), +) + +data class GatewaySkillWorkshopSupportFile( + val path: String, + val content: String?, +) + data class GatewaySkillSummary( val skillKey: String, val name: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index cdb06df3b26..157f95e3f15 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -107,6 +107,8 @@ data class GatewayHelloSummary( val serverVersion: String?, val mainSessionKey: String?, val updateAvailable: GatewayUpdateAvailableSummary?, + val authRole: String? = null, + val authScopes: List = emptyList(), ) data class GatewayUpdateAvailableSummary( @@ -887,6 +889,10 @@ class GatewaySession( ): List? = when (role.trim()) { "node" -> emptyList() + // Setup-code bootstrap handoff is deliberately least-privilege. It never + // persists operator.admin, so Skill Workshop lifecycle actions remain + // disabled until shared token/password auth or an owner-approved scope + // upgrade issues an admin-scoped operator device token. "operator" -> { val allowedOperatorScopes = setOf( @@ -993,6 +999,8 @@ class GatewaySession( serverVersion = serverVersion, mainSessionKey = nextMainSessionKey, updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()), + authRole = authRole, + authScopes = authScopes, ), ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 31eee21d7cb..c035253d29c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -151,6 +151,7 @@ internal enum class SettingsRoute { CronJobs, Usage, Skills, + SkillWorkshop, NodesDevices, Channels, Dreaming, @@ -184,6 +185,7 @@ internal fun SettingsDetailScreen( SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Skills -> SkillsSettingsScreen(viewModel = viewModel, onBack = onBack) + SettingsRoute.SkillWorkshop -> SkillWorkshopSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.NodesDevices -> NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Channels -> ChannelsSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index c2f94c4acc9..cfc1b88ed90 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -9,6 +9,7 @@ import ai.openclaw.app.GatewayDreamingSummary import ai.openclaw.app.GatewayNodeApprovalState import ai.openclaw.app.GatewayNodesDevicesSummary import ai.openclaw.app.GatewaySkillSummary +import ai.openclaw.app.GatewaySkillWorkshopSummary import ai.openclaw.app.HomeDestination import ai.openclaw.app.MainViewModel import ai.openclaw.app.NodeRuntime @@ -1438,6 +1439,7 @@ private fun SettingsShellScreen( val cronStatus by viewModel.cronStatus.collectAsState() val usageSummary by viewModel.usageSummary.collectAsState() val skillsSummary by viewModel.skillsSummary.collectAsState() + val skillWorkshopSummary by viewModel.skillWorkshopSummary.collectAsState() val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState() val channelsSummary by viewModel.channelsSummary.collectAsState() val dreamingSummary by viewModel.dreamingSummary.collectAsState() @@ -1452,6 +1454,7 @@ private fun SettingsShellScreen( viewModel.refreshCronJobs() viewModel.refreshUsage() viewModel.refreshSkills() + viewModel.refreshSkillWorkshopProposals() viewModel.refreshNodesDevices() viewModel.refreshChannels() viewModel.refreshDreaming() @@ -1519,6 +1522,13 @@ private fun SettingsShellScreen( SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs), SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage), SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills), + SettingsRow( + "Skill Workshop", + skillWorkshopSummaryText(skillWorkshopSummary), + Icons.Default.Settings, + status = skillWorkshopStatus(skillWorkshopSummary), + route = SettingsRoute.SkillWorkshop, + ), SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming), SettingsRow("Terminal", "Shell in the agent workspace", Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal), SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice), @@ -1620,6 +1630,28 @@ private fun skillsStatus(skills: List): Boolean? = else -> true } +/** Mirrors the Skill Workshop review queue in one compact Settings row. */ +internal fun skillWorkshopSummaryText(summary: GatewaySkillWorkshopSummary): String { + val pending = summary.proposals.count { it.status == "pending" } + if (pending > 0) return if (pending == 1) "1 pending" else "$pending pending" + val held = summary.proposals.count { it.status == "quarantined" || it.status == "stale" } + val applied = summary.proposals.count { it.status == "applied" } + return when { + summary.proposals.isEmpty() -> "No proposals" + held > 0 -> if (held == 1) "1 held" else "$held held" + applied > 0 -> if (applied == 1) "1 applied" else "$applied applied" + else -> "${summary.proposals.size} proposals" + } +} + +internal fun skillWorkshopStatus(summary: GatewaySkillWorkshopSummary): Boolean? = + when { + summary.proposals.any { it.status == "pending" } -> false + summary.proposals.any { it.status == "quarantined" || it.status == "stale" } -> false + summary.proposals.any { it.status == "applied" } -> true + else -> null + } + /** Prioritizes pending pairings over online counts for compact node/device summaries. */ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String { val online = summary.nodes.count { it.connected } @@ -1726,6 +1758,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): String = SettingsRoute.CronJobs, SettingsRoute.Usage, SettingsRoute.Skills, + SettingsRoute.SkillWorkshop, SettingsRoute.Dreaming, SettingsRoute.Terminal, -> "Agents & automation" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt new file mode 100644 index 00000000000..856b8124580 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt @@ -0,0 +1,689 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayAgentSummary +import ai.openclaw.app.GatewaySkillWorkshopProposal +import ai.openclaw.app.GatewaySkillWorkshopSummary +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.ui.design.ClawPanel +import ai.openclaw.app.ui.design.ClawPrimaryButton +import ai.openclaw.app.ui.design.ClawSecondaryButton +import ai.openclaw.app.ui.design.ClawSegmentedControl +import ai.openclaw.app.ui.design.ClawStatus +import ai.openclaw.app.ui.design.ClawStatusPill +import ai.openclaw.app.ui.design.ClawTextField +import ai.openclaw.app.ui.design.ClawTheme +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.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +private val skillWorkshopFilterLabels = listOf("Pending", "Held", "Applied", "Rejected", "All") + +@Composable +internal fun SkillWorkshopSettingsScreen( + viewModel: MainViewModel, + onBack: () -> Unit, +) { + val summary by viewModel.skillWorkshopSummary.collectAsState() + val refreshing by viewModel.skillWorkshopRefreshing.collectAsState() + val errorText by viewModel.skillWorkshopErrorText.collectAsState() + val noticeText by viewModel.skillWorkshopNoticeText.collectAsState() + val inspectingProposalId by viewModel.skillWorkshopInspectingProposalId.collectAsState() + val mutatingProposalId by viewModel.skillWorkshopMutatingProposalId.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState() + val agents by viewModel.gatewayAgents.collectAsState() + val defaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState() + + var statusFilter by rememberSaveable { mutableStateOf("pending") } + var query by rememberSaveable { mutableStateOf("") } + var selectedAgentId by rememberSaveable { mutableStateOf("") } + var selectedProposalId by rememberSaveable { mutableStateOf(null) } + var pendingAction by remember { mutableStateOf(null) } + val selectedAgentParam = selectedAgentId.trim().takeIf { it.isNotEmpty() } + val visibleProposals = skillWorkshopVisibleProposals(summary, selectedAgentParam) + val filteredProposals = skillWorkshopFilteredProposals(visibleProposals, statusFilter, query) + val selectedProposal = + filteredProposals.firstOrNull { it.id == selectedProposalId } + ?: filteredProposals.firstOrNull() + + LaunchedEffect(isConnected, selectedAgentParam) { + if (isConnected) { + viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam) + } + } + + LaunchedEffect(filteredProposals.map { it.id }) { + if (selectedProposalId == null || filteredProposals.none { it.id == selectedProposalId }) { + selectedProposalId = filteredProposals.firstOrNull()?.id + } + } + + LaunchedEffect(selectedProposal?.id, selectedProposal?.content, isConnected, selectedAgentParam) { + if (isConnected && selectedProposal != null && selectedProposal.content == null) { + viewModel.inspectSkillWorkshopProposal(proposalId = selectedProposal.id, agentId = selectedAgentParam) + } + } + + LaunchedEffect(selectedProposal?.id, pendingAction?.proposalId) { + if (pendingAction != null && selectedProposal?.id != pendingAction?.proposalId) { + pendingAction = null + } + } + + pendingAction?.let { action -> + SkillWorkshopActionConfirmDialog( + action = action, + onDismiss = { pendingAction = null }, + onConfirm = { + pendingAction = null + when (action.action) { + SkillWorkshopProposalAction.Apply -> + viewModel.applySkillWorkshopProposal( + proposalId = action.proposalId, + agentId = selectedAgentParam, + ) + SkillWorkshopProposalAction.Reject -> + viewModel.rejectSkillWorkshopProposal( + proposalId = action.proposalId, + agentId = selectedAgentParam, + ) + SkillWorkshopProposalAction.Quarantine -> + viewModel.quarantineSkillWorkshopProposal( + proposalId = action.proposalId, + agentId = selectedAgentParam, + ) + } + }, + ) + } + + SettingsDetailFrame( + title = "Skill Workshop", + subtitle = "Review generated skill proposals before they become live skills.", + icon = Icons.Default.Settings, + onBack = onBack, + ) { + SettingsMetricPanel( + rows = + listOf( + SettingsMetric("Pending", visibleProposals.count { it.status == "pending" }.toString()), + SettingsMetric( + "Held", + visibleProposals.count { skillWorkshopStatusMatchesFilter(it.status, "held") }.toString(), + ), + SettingsMetric("Applied", visibleProposals.count { it.status == "applied" }.toString()), + SettingsMetric("Rejected", visibleProposals.count { it.status == "rejected" }.toString()), + ), + ) + + SkillWorkshopControls( + agents = agents, + defaultAgentId = defaultAgentId, + selectedAgentId = selectedAgentId, + onAgentChange = { agentId -> + selectedAgentId = agentId + selectedProposalId = null + viewModel.resetSkillWorkshopAgentScope(agentId = agentId.takeIf { it.isNotBlank() }) + }, + statusFilter = statusFilter, + onStatusFilterChange = { filter -> + statusFilter = filter + selectedProposalId = null + }, + query = query, + onQueryChange = { value -> + query = value + selectedProposalId = null + }, + refreshing = refreshing, + isConnected = isConnected, + onRefresh = { viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam) }, + ) + + noticeText?.let { message -> + ClawPanel { + Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.success) + } + } + errorText?.let { message -> + ClawPanel { + Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.warning) + } + } + + when { + !isConnected -> + SkillWorkshopEmptyPanel( + title = "Gateway offline", + detail = "Connect to a Gateway to load Skill Workshop proposals.", + ) + filteredProposals.isEmpty() -> + SkillWorkshopEmptyPanel( + title = "No proposals", + detail = "Matching proposals will appear here after agents create reusable skill drafts.", + ) + else -> { + SkillWorkshopProposalList( + proposals = filteredProposals, + selectedProposalId = selectedProposal?.id, + inspectingProposalId = inspectingProposalId, + mutatingProposalId = mutatingProposalId, + onSelect = { proposal -> + selectedProposalId = proposal.id + viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam) + }, + ) + selectedProposal?.let { proposal -> + SkillWorkshopProposalDetail( + proposal = proposal, + inspecting = inspectingProposalId == proposal.id, + mutating = mutatingProposalId == proposal.id, + isConnected = isConnected, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + onInspect = { + viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam) + }, + onApply = { + pendingAction = + SkillWorkshopPendingAction( + action = SkillWorkshopProposalAction.Apply, + proposalId = proposal.id, + title = proposal.title, + ) + }, + onReject = { + pendingAction = + SkillWorkshopPendingAction( + action = SkillWorkshopProposalAction.Reject, + proposalId = proposal.id, + title = proposal.title, + ) + }, + onQuarantine = { + pendingAction = + SkillWorkshopPendingAction( + action = SkillWorkshopProposalAction.Quarantine, + proposalId = proposal.id, + title = proposal.title, + ) + }, + ) + } + } + } + } +} + +private enum class SkillWorkshopProposalAction( + val label: String, +) { + Apply("Apply"), + Reject("Reject"), + Quarantine("Quarantine"), +} + +private data class SkillWorkshopPendingAction( + val action: SkillWorkshopProposalAction, + val proposalId: String, + val title: String, +) + +@Composable +private fun SkillWorkshopActionConfirmDialog( + action: SkillWorkshopPendingAction, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("${action.action.label} proposal?") }, + text = { + Text( + text = + "This will ${action.action.label.lowercase()} \"${action.title}\" and refresh Skill Workshop state from the gateway.", + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(action.action.label) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun SkillWorkshopControls( + agents: List, + defaultAgentId: String?, + selectedAgentId: String, + onAgentChange: (String) -> Unit, + statusFilter: String, + onStatusFilterChange: (String) -> Unit, + query: String, + onQueryChange: (String) -> Unit, + refreshing: Boolean, + isConnected: Boolean, + onRefresh: () -> Unit, +) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SkillWorkshopAgentMenu( + agents = agents, + defaultAgentId = defaultAgentId, + selectedAgentId = selectedAgentId, + onAgentChange = onAgentChange, + modifier = Modifier.weight(1f), + ) + ClawSecondaryButton( + text = if (refreshing) "Refreshing" else "Refresh", + onClick = onRefresh, + enabled = isConnected && !refreshing, + icon = Icons.Default.Refresh, + ) + } + ClawSegmentedControl( + options = skillWorkshopFilterLabels, + selected = skillWorkshopFilterLabel(statusFilter), + onSelect = { label -> onStatusFilterChange(skillWorkshopFilterFromLabel(label)) }, + ) + ClawTextField( + value = query, + onValueChange = onQueryChange, + placeholder = "Search proposals", + ) + } + } +} + +@Composable +private fun SkillWorkshopAgentMenu( + agents: List, + defaultAgentId: String?, + selectedAgentId: String, + onAgentChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val label = + skillWorkshopAgentLabel( + agents = agents, + defaultAgentId = defaultAgentId, + selectedAgentId = selectedAgentId, + ) + Box(modifier = modifier) { + ClawSecondaryButton( + text = label, + onClick = { expanded = true }, + icon = Icons.Default.ArrowDropDown, + modifier = Modifier.fillMaxWidth(), + enabled = agents.isNotEmpty(), + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Default agent") }, + onClick = { + expanded = false + onAgentChange("") + }, + ) + agents + .filter { agent -> agent.id.trim().isNotEmpty() && agent.id != defaultAgentId } + .sortedBy { it.name ?: it.id } + .forEach { agent -> + DropdownMenuItem( + text = { Text(agent.name?.takeIf { it.isNotBlank() } ?: agent.id) }, + onClick = { + expanded = false + onAgentChange(agent.id) + }, + ) + } + } + } +} + +@Composable +private fun SkillWorkshopProposalList( + proposals: List, + selectedProposalId: String?, + inspectingProposalId: String?, + mutatingProposalId: String?, + onSelect: (GatewaySkillWorkshopProposal) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + proposals.forEach { proposal -> + SkillWorkshopProposalRow( + proposal = proposal, + selected = proposal.id == selectedProposalId, + busy = proposal.id == inspectingProposalId || proposal.id == mutatingProposalId, + onClick = { onSelect(proposal) }, + ) + } + } +} + +@Composable +private fun SkillWorkshopProposalRow( + proposal: GatewaySkillWorkshopProposal, + selected: Boolean, + busy: Boolean, + onClick: () -> Unit, +) { + ClawPanel { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = proposal.title, + style = ClawTheme.type.body, + color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = proposal.description ?: proposal.skillKey, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textSubtle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { + ClawStatusPill( + text = if (busy) "loading" else proposal.status, + status = skillWorkshopStatusPill(proposal.status), + ) + Text( + text = skillWorkshopDateLabel(proposal.updatedAt), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textSubtle, + maxLines = 1, + ) + } + } + ClawSecondaryButton( + text = if (selected) "Selected" else "Open", + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + enabled = !selected, + ) + } +} + +@Composable +private fun SkillWorkshopProposalDetail( + proposal: GatewaySkillWorkshopProposal, + inspecting: Boolean, + mutating: Boolean, + isConnected: Boolean, + operatorAdminScopeAvailable: Boolean, + onInspect: () -> Unit, + onApply: () -> Unit, + onReject: () -> Unit, + onQuarantine: () -> Unit, +) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = proposal.title, + style = ClawTheme.type.title, + color = ClawTheme.colors.text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = proposal.skillKey, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textSubtle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ClawStatusPill(text = proposal.status, status = skillWorkshopStatusPill(proposal.status)) + } + proposal.description?.let { description -> + Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + SettingsMetricPanel( + rows = + listOf( + SettingsMetric("Kind", proposal.kind), + SettingsMetric("Updated", skillWorkshopDateLabel(proposal.updatedAt)), + SettingsMetric("Support Files", proposal.supportFiles.size.toString()), + ), + ) + Text( + text = proposal.content ?: "Inspect this proposal to load its markdown.", + style = ClawTheme.type.body, + color = if (proposal.content == null) ClawTheme.colors.textSubtle else ClawTheme.colors.text, + ) + if (!operatorAdminScopeAvailable) { + Text( + text = "Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.warning, + ) + } + if (proposal.supportFiles.isNotEmpty()) { + HorizontalDivider(color = ClawTheme.colors.border) + proposal.supportFiles.forEach { file -> + Text( + text = file.path, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row( + modifier = + Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Workshop inspect and apply actions" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClawSecondaryButton( + text = if (inspecting) "Inspecting" else "Inspect", + onClick = onInspect, + enabled = isConnected && !inspecting && !mutating, + modifier = Modifier.weight(1f), + ) + ClawPrimaryButton( + text = if (mutating) "Working" else "Apply", + onClick = onApply, + enabled = + skillWorkshopProposalActionEnabled( + isConnected = isConnected, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + busy = inspecting || mutating, + status = proposal.status, + ), + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Workshop reject and quarantine actions" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClawSecondaryButton( + text = "Reject", + onClick = onReject, + enabled = + skillWorkshopProposalActionEnabled( + isConnected = isConnected, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + busy = inspecting || mutating, + status = proposal.status, + ), + modifier = Modifier.weight(1f), + ) + ClawSecondaryButton( + text = "Quarantine", + onClick = onQuarantine, + enabled = + skillWorkshopProposalActionEnabled( + isConnected = isConnected, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + busy = inspecting || mutating, + status = proposal.status, + ), + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun SkillWorkshopEmptyPanel( + title: String, + detail: String, +) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text) + Text(text = detail, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } +} + +internal fun skillWorkshopVisibleProposals( + summary: GatewaySkillWorkshopSummary, + selectedAgentId: String?, +): List = + when { + summary.agentId == skillWorkshopAgentScope(selectedAgentId) -> summary.proposals + else -> emptyList() + } + +internal fun skillWorkshopAgentScope(agentId: String?): String = agentId?.trim().orEmpty() + +internal fun skillWorkshopFilteredProposals( + proposals: List, + statusFilter: String, + query: String, +): List { + val normalizedQuery = query.trim().lowercase() + val matchingStatus = + proposals.filter { proposal -> skillWorkshopStatusMatchesFilter(proposal.status, statusFilter) } + val matchingQuery = + matchingStatus.filter { proposal -> + if (normalizedQuery.isEmpty()) return@filter true + val pieces = + listOf(proposal.title, proposal.description.orEmpty(), proposal.skillName, proposal.skillKey) + val haystack = pieces.joinToString(" ").lowercase() + haystack.contains(normalizedQuery) + } + return matchingQuery.sortedByDescending { it.updatedAt } +} + +internal fun skillWorkshopStatusMatchesFilter( + status: String, + filter: String, +): Boolean = + when (filter) { + "all" -> true + "held" -> status == "quarantined" || status == "stale" + else -> status == filter + } + +internal fun skillWorkshopProposalActionEnabled( + isConnected: Boolean, + operatorAdminScopeAvailable: Boolean, + busy: Boolean, + status: String, +): Boolean = isConnected && operatorAdminScopeAvailable && !busy && status == "pending" + +private fun skillWorkshopFilterLabel(filter: String): String = + when (filter) { + "pending" -> "Pending" + "held" -> "Held" + "applied" -> "Applied" + "rejected" -> "Rejected" + else -> "All" + } + +private fun skillWorkshopFilterFromLabel(label: String): String = + when (label) { + "Pending" -> "pending" + "Held" -> "held" + "Applied" -> "applied" + "Rejected" -> "rejected" + else -> "all" + } + +private fun skillWorkshopAgentLabel( + agents: List, + defaultAgentId: String?, + selectedAgentId: String, +): String { + val selected = selectedAgentId.trim() + if (selected.isNotEmpty()) { + return agents.firstOrNull { it.id == selected }?.name?.takeIf { it.isNotBlank() } ?: selected + } + val defaultId = defaultAgentId?.trim().orEmpty() + if (defaultId.isNotEmpty()) { + return agents.firstOrNull { it.id == defaultId }?.name?.takeIf { it.isNotBlank() } + ?: "Default agent" + } + return "Default agent" +} + +private fun skillWorkshopStatusPill(status: String): ClawStatus = + when (status) { + "pending", "quarantined", "stale" -> ClawStatus.Warning + "applied" -> ClawStatus.Success + "rejected" -> ClawStatus.Neutral + else -> ClawStatus.Neutral + } + +private fun skillWorkshopDateLabel(value: String): String = value.trim().takeIf { it.isNotEmpty() }?.take(10) ?: "unknown" diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt new file mode 100644 index 00000000000..9545c5c3ec4 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt @@ -0,0 +1,237 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SkillWorkshopAgentScopeRuntimeTest { + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun resetSkillWorkshopAgentScopeClearsRowsAndInFlightActionState() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.resetSkillWorkshopAgentScope("main") + readField>(runtime, "_skillWorkshopSummary").value = + GatewaySkillWorkshopSummary( + agentId = "main", + proposals = listOf(skillWorkshopProposal("main-proposal")), + ) + + runtime.resetSkillWorkshopAgentScope("ops") + + assertEquals("ops", runtime.skillWorkshopSummary.value.agentId) + assertEquals(emptyList(), runtime.skillWorkshopSummary.value.proposals) + assertFalse(runtime.skillWorkshopRefreshing.value) + assertNull(runtime.skillWorkshopErrorText.value) + assertNull(runtime.skillWorkshopNoticeText.value) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + assertNull(runtime.skillWorkshopMutatingProposalId.value) + } + + @Test + fun inspectAndMutateDoNotStartForStaleSelectedAgentScope() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.resetSkillWorkshopAgentScope("main") + + runtime.inspectSkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops") + readField>>(runtime, "_operatorScopes").value = listOf("operator.admin") + waitUntil { runtime.operatorAdminScopeAvailable.value } + runtime.applySkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops") + Thread.sleep(100) + + assertEquals("main", runtime.skillWorkshopSummary.value.agentId) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + assertNull(runtime.skillWorkshopMutatingProposalId.value) + assertNull(runtime.skillWorkshopErrorText.value) + assertNull(runtime.skillWorkshopNoticeText.value) + } + + @Test + fun busyProposalActionDoesNotInvalidateActiveRequestGenerations() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>(runtime, "_skillWorkshopSummary").value = + GatewaySkillWorkshopSummary( + agentId = "main", + proposals = listOf(skillWorkshopProposal("proposal-1")), + ) + readField>>(runtime, "_operatorScopes").value = listOf("operator.admin") + waitUntil { runtime.operatorAdminScopeAvailable.value } + readField>(runtime, "_skillWorkshopMutatingProposalId").value = "proposal-1" + val mutationSeq = readField(runtime, "skillWorkshopMutationSeq").apply { set(41) } + val inspectSeq = readField(runtime, "skillWorkshopInspectSeq").apply { set(17) } + + runtime.applySkillWorkshopProposal(proposalId = "proposal-1", agentId = "main") + runtime.inspectSkillWorkshopProposal(proposalId = "proposal-1", agentId = "main") + Thread.sleep(100) + + assertEquals(41, mutationSeq.get()) + assertEquals(17, inspectSeq.get()) + assertEquals("proposal-1", runtime.skillWorkshopMutatingProposalId.value) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + } + + @Test + fun proposalActionResultUsesGatewayReturnedStatusAndPreservesInspectedDetails() { + val runtime = createTestRuntime() + val supportFiles = + listOf(GatewaySkillWorkshopSupportFile(path = "references/proof.md", content = "proof")) + val previous = + skillWorkshopProposal("proposal-1") + .copy( + status = "pending", + content = "inspected markdown", + supportFiles = supportFiles, + ) + + val rejected = + parseSkillWorkshopActionResult( + runtime, + """ + { + "record": { + "id": "proposal-1", + "kind": "create", + "status": "rejected", + "title": "Rejected proposal", + "description": "Gateway action response", + "createdAt": "2026-07-08T00:00:00Z", + "updatedAt": "2026-07-09T00:00:00Z", + "scan": { "state": "clean" }, + "target": { "skillName": "Rejected Skill", "skillKey": "rejected-skill" } + } + } + """.trimIndent(), + previous, + ) + + assertEquals("rejected", rejected?.status) + assertEquals("2026-07-09T00:00:00Z", rejected?.updatedAt) + assertEquals("Rejected Skill", rejected?.skillName) + assertEquals("clean", rejected?.scanState) + assertEquals("inspected markdown", rejected?.content) + assertEquals(supportFiles, rejected?.supportFiles) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + } + + private fun skillWorkshopProposal(id: String): GatewaySkillWorkshopProposal = + GatewaySkillWorkshopProposal( + id = id, + status = "pending", + kind = "create", + title = "Proposal $id", + skillKey = id, + skillName = "Proposal $id", + description = "desc", + createdAt = "2026-07-08T00:00:00Z", + updatedAt = "2026-07-08T00:00:00Z", + scanState = null, + ) + + private fun parseSkillWorkshopActionResult( + runtime: NodeRuntime, + payloadJson: String, + previous: GatewaySkillWorkshopProposal, + ): GatewaySkillWorkshopProposal? { + val method = + runtime.javaClass.getDeclaredMethod( + "parseSkillWorkshopProposalActionResult", + JsonObject::class.java, + GatewaySkillWorkshopProposal::class.java, + ) + method.isAccessible = true + @Suppress("UNCHECKED_CAST") + return method.invoke( + runtime, + json.parseToJsonElement(payloadJson).jsonObject, + previous, + ) as GatewaySkillWorkshopProposal? + } + + private fun waitUntil(condition: () -> Boolean) { + repeat(50) { + if (condition()) return + Thread.sleep(10) + } + error("Expected condition to become true") + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) + return + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun readField( + target: Any, + name: String, + ): T { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(target) as T + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt index d5c3bf49ad4..90923072f8e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt @@ -10,7 +10,10 @@ import ai.openclaw.app.GatewayNodeApprovalState import ai.openclaw.app.GatewayNodeSummary import ai.openclaw.app.GatewayNodesDevicesSummary import ai.openclaw.app.GatewayPendingDeviceSummary +import ai.openclaw.app.GatewaySkillWorkshopProposal +import ai.openclaw.app.GatewaySkillWorkshopSummary import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.normalizeOperatorScopes import ai.openclaw.app.ui.design.ClawStatus import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings @@ -220,6 +223,121 @@ class ShellScreenLogicTest { assertEquals(emptyList(), rows.map { it.title }) } + @Test + fun skillWorkshopSummaryPrioritizesPendingAndHeldProposals() { + assertEquals( + "2 pending", + skillWorkshopSummaryText( + GatewaySkillWorkshopSummary( + proposals = + listOf( + skillWorkshopProposal("one", "pending"), + skillWorkshopProposal("two", "pending"), + skillWorkshopProposal("three", "applied"), + ), + ), + ), + ) + assertEquals( + "1 held", + skillWorkshopSummaryText( + GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("held", "quarantined"))), + ), + ) + assertEquals(null, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = emptyList()))) + assertEquals(false, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("pending", "pending"))))) + assertEquals(true, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("applied", "applied"))))) + } + + @Test + fun skillWorkshopFilteringMatchesHeldAndSearchText() { + val proposals = + listOf( + skillWorkshopProposal("pending", "pending", title = "Browser Playbook", skillKey = "browser-playbook"), + skillWorkshopProposal("stale", "stale", title = "Old Draft", skillKey = "old-draft"), + skillWorkshopProposal("quarantine", "quarantined", title = "Risky Skill", skillKey = "risky-skill"), + ) + + assertEquals(listOf("stale", "quarantine"), skillWorkshopFilteredProposals(proposals, "held", "").map { it.id }) + assertEquals(listOf("pending"), skillWorkshopFilteredProposals(proposals, "all", "browser").map { it.id }) + assertTrue(skillWorkshopStatusMatchesFilter("stale", "held")) + assertFalse(skillWorkshopStatusMatchesFilter("applied", "held")) + } + + @Test + fun skillWorkshopVisibleProposalsAreKeyedBySelectedAgentScope() { + val mainProposal = skillWorkshopProposal("main-proposal", "pending") + val opsProposal = skillWorkshopProposal("ops-proposal", "pending") + + assertEquals( + listOf("main-proposal"), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "", proposals = listOf(mainProposal)), + selectedAgentId = null, + ).map { it.id }, + ) + assertEquals( + emptyList(), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "main", proposals = listOf(mainProposal)), + selectedAgentId = "ops", + ).map { it.id }, + ) + assertEquals( + listOf("ops-proposal"), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "ops", proposals = listOf(opsProposal)), + selectedAgentId = " ops ", + ).map { it.id }, + ) + } + + @Test + fun skillWorkshopProposalActionsRequireAdminScope() { + assertTrue( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = false, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = false, + busy = false, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = true, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = false, + status = "applied", + ), + ) + } + + @Test + fun operatorScopesNormalizeForStableAdminChecks() { + assertEquals( + listOf("operator.admin", "operator.read", "operator.write"), + normalizeOperatorScopes( + listOf(" operator.write ", "operator.admin", "", "operator.write", "operator.read"), + ), + ) + } + @Test fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() { val rows = @@ -664,4 +782,23 @@ class ShellScreenLogicTest { pauseReconnect = false, retryable = false, ) + + private fun skillWorkshopProposal( + id: String, + status: String, + title: String = id, + skillKey: String = id, + ): GatewaySkillWorkshopProposal = + GatewaySkillWorkshopProposal( + id = id, + kind = "create", + status = status, + title = title, + description = null, + skillName = title, + skillKey = skillKey, + createdAt = "2026-07-08T00:00:00.000Z", + updatedAt = "2026-07-08T00:00:00.000Z", + scanState = null, + ) }