diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 63adcdc27188..fbf22741a3c0 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2278, + "line": 2317, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2308, + "line": 2347, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2308, + "line": 2347, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2321, + "line": 2360, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2352, + "line": 2391, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-call", - "line": 2352, + "line": 2391, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2374, + "line": 2413, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2380, + "line": 2419, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 2395, + "line": 2434, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 2645, + "line": 2684, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2656, + "line": 2695, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 2677, + "line": 2716, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 2688, + "line": 2727, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 3774, + "line": 3819, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-call", - "line": 3802, + "line": 3847, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-call", - "line": 3811, + "line": 3856, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 4522, + "line": 4567, "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", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 4526, + "line": 4571, "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", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 4530, + "line": 4575, "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", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-named-argument", - "line": 4580, + "line": 4625, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-named-argument", - "line": 4790, + "line": 4835, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5565, + "line": 5610, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5596, + "line": 5641, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5598, + "line": 5643, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5614, + "line": 5659, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5701, + "line": 5746, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5787, + "line": 5832, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5797, + "line": 5842, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5801, + "line": 5846, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5813, + "line": 5858, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5844, + "line": 5889, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 5861, + "line": 5906, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 5870, + "line": 5915, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5882, + "line": 5927, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "ui-call", - "line": 5929, + "line": 5974, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "ui-call", - "line": 6056, + "line": 6101, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 6091, + "line": 6136, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 6104, + "line": 6149, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 6108, + "line": 6153, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 6123, + "line": 6168, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 6123, + "line": 6168, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 6141, + "line": 6186, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 6187, + "line": 6232, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "ui-call", - "line": 6200, + "line": 6245, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "conditional-branch", - "line": 6233, + "line": 6278, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6249, + "line": 6294, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6265, + "line": 6310, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6281, + "line": 6326, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "conditional-branch", - "line": 6371, + "line": 6416, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6378, + "line": 6423, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6418, + "line": 6463, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6458, + "line": 6503, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-call", - "line": 6478, + "line": 6523, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6530, + "line": 6575, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 6550, + "line": 6595, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 6555, + "line": 6600, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 6745, + "line": 6790, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not verify the device pairing change. Refresh and try again.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-named-argument", - "line": 6827, + "line": 6872, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 6857, + "line": 6902, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7516, + "line": 7561, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7546, + "line": 7591, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7583, + "line": 7628, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 8052, + "line": 8097, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 8061, + "line": 8106, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 8062, + "line": 8107, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 8070, + "line": 8115, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 8071, + "line": 8116, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 8072, + "line": 8117, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 8073, + "line": 8118, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 8089, + "line": 8134, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 8106, + "line": 8151, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 8114, + "line": 8159, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 8115, + "line": 8160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 8117, + "line": 8162, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 8121, + "line": 8166, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 8124, + "line": 8169, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 8129, + "line": 8174, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 8130, + "line": 8175, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 8132, + "line": 8177, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 8136, + "line": 8181, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 8139, + "line": 8184, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 8144, + "line": 8189, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 8145, + "line": 8190, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 8147, + "line": 8192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 8151, + "line": 8196, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8154, + "line": 8199, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1587,7 +1587,7 @@ }, { "kind": "ui-call", - "line": 8186, + "line": 8231, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1595,7 +1595,7 @@ }, { "kind": "ui-call", - "line": 8201, + "line": 8246, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1603,7 +1603,7 @@ }, { "kind": "ui-call", - "line": 8202, + "line": 8247, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1611,7 +1611,7 @@ }, { "kind": "ui-call", - "line": 8203, + "line": 8248, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1619,7 +1619,7 @@ }, { "kind": "ui-call", - "line": 8858, + "line": 8903, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -27043,7 +27043,7 @@ }, { "kind": "ui-named-argument", - "line": 295, + "line": 317, "path": "apps/ios/Sources/RootTabs.swift", "source": "Overview", "surface": "apple", @@ -27051,7 +27051,7 @@ }, { "kind": "ui-named-argument", - "line": 323, + "line": 345, "path": "apps/ios/Sources/RootTabs.swift", "source": "Agents", "surface": "apple", @@ -27059,7 +27059,7 @@ }, { "kind": "ui-named-argument", - "line": 330, + "line": 352, "path": "apps/ios/Sources/RootTabs.swift", "source": "Instances", "surface": "apple", @@ -27067,7 +27067,7 @@ }, { "kind": "ui-named-argument", - "line": 341, + "line": 363, "path": "apps/ios/Sources/RootTabs.swift", "source": "Files", "surface": "apple", @@ -27075,7 +27075,7 @@ }, { "kind": "ui-named-argument", - "line": 348, + "line": 370, "path": "apps/ios/Sources/RootTabs.swift", "source": "Dreaming", "surface": "apple", @@ -27083,7 +27083,7 @@ }, { "kind": "ui-named-argument", - "line": 355, + "line": 377, "path": "apps/ios/Sources/RootTabs.swift", "source": "Usage", "surface": "apple", @@ -27091,7 +27091,7 @@ }, { "kind": "ui-named-argument", - "line": 362, + "line": 384, "path": "apps/ios/Sources/RootTabs.swift", "source": "Automations", "surface": "apple", @@ -27099,7 +27099,7 @@ }, { "kind": "ui-localized-call", - "line": 452, + "line": 474, "path": "apps/ios/Sources/RootTabs.swift", "source": "Hide Sidebar", "surface": "apple", @@ -27107,7 +27107,7 @@ }, { "kind": "ui-localized-call", - "line": 458, + "line": 480, "path": "apps/ios/Sources/RootTabs.swift", "source": "Show Sidebar", "surface": "apple", @@ -27115,7 +27115,7 @@ }, { "kind": "ui-modifier", - "line": 585, + "line": 607, "path": "apps/ios/Sources/RootTabs.swift", "source": "Close canvas", "surface": "apple", @@ -27123,7 +27123,7 @@ }, { "kind": "conditional-branch", - "line": 848, + "line": 870, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -27131,7 +27131,7 @@ }, { "kind": "conditional-branch", - "line": 848, + "line": 870, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -27139,7 +27139,7 @@ }, { "kind": "conditional-branch", - "line": 884, + "line": 906, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -27147,7 +27147,7 @@ }, { "kind": "conditional-branch", - "line": 884, + "line": 906, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -27155,7 +27155,7 @@ }, { "kind": "conditional-branch", - "line": 884, + "line": 906, "path": "apps/ios/Sources/RootTabs.swift", "source": "Routed on this phone", "surface": "apple", @@ -39163,7 +39163,7 @@ }, { "kind": "ui-named-argument", - "line": 143, + "line": 179, "path": "apps/macos/Sources/OpenClaw/WebChatManager.swift", "source": "Could Not Open Gateway Window", "surface": "apple", 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 e550a78a70bf..1e8e86a2daf4 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 @@ -612,6 +612,35 @@ internal enum class NodeRuntimeMode { ScreenshotFixture, } +internal class SessionObserverVisibility( + private val isVisible: () -> Boolean, + private val captureLease: () -> GatewaySession.RequestLease?, +) { + private val mutex = Mutex() + private var appliedLease: GatewaySession.RequestLease? = null + private var appliedVisibility: Boolean? = null + + suspend fun sync() { + mutex.withLock { + val lease = captureLease() ?: return@withLock + val visible = isVisible() + // Socket-bound declarations must survive reconnect without duplicate + // foreground RPCs or leaking a queued update onto the next gateway. + if (appliedVisibility == visible && appliedLease?.isCurrent() == true) return@withLock + // A timeout can mean the Gateway applied this change but lost its reply. + // Invalidate the old confirmation first so the next sync cannot skip recovery. + appliedLease = null + appliedVisibility = null + lease.request( + GatewayMethod.SessionsObserverVisibility.rawValue, + """{"visible":$visible}""", + ) + appliedLease = lease + appliedVisibility = visible + } + } +} + private fun openAndroidChatStores( context: Context, prefs: SecurePrefs, @@ -1385,6 +1414,12 @@ class NodeRuntime private constructor( customHeadersProvider = prefs::loadGatewayCustomHeaders, ) + private val sessionObserverVisibility = + SessionObserverVisibility( + isVisible = { _isForeground.value }, + captureLease = { operatorSession.captureRequestLease() }, + ) + private val systemAgentChatController by lazy { SystemAgentChatController( scope = scope, @@ -1599,12 +1634,16 @@ class NodeRuntime private constructor( private suspend fun subscribeOperatorSessionEvents() { try { - operatorSession.request("sessions.subscribe", null) + operatorSession.request(GatewayMethod.SessionsSubscribe.rawValue, null) } catch (err: Throwable) { Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}") } + syncSessionObserverVisibility() + } + + private suspend fun syncSessionObserverVisibility() { try { - operatorSession.request("sessions.observer.visibility", """{"visible":true}""") + sessionObserverVisibility.sync() } catch (err: Throwable) { Log.d( "OpenClawRuntime", @@ -2993,9 +3032,15 @@ class NodeRuntime private constructor( /** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */ fun setForeground(value: Boolean) { + val visibilityChanged = _isForeground.value != value _isForeground.value = value voiceWakeManager.setForeground(value) if (mode == NodeRuntimeMode.ScreenshotFixture) return + if (visibilityChanged) { + scope.launch { + syncSessionObserverVisibility() + } + } if (!value) { voiceLifecycleEpoch.incrementAndGet() } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt new file mode 100644 index 000000000000..ea452c100eef --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt @@ -0,0 +1,129 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayMethod +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionObserverVisibilityTest { + @Test + fun foregroundTransitionsDeclareActualVisibilityOncePerSocket() = + runBlocking { + var visible = true + var firstSocketIsCurrent = true + var secondSocketIsCurrent = false + val requests = mutableListOf>() + val firstLease = + GatewaySession.RequestLease( + endpointStableId = "gateway", + isCurrentImpl = { firstSocketIsCurrent }, + ) { method, params, _ -> + requests.add(method to params) + "" + } + val secondLease = + GatewaySession.RequestLease( + endpointStableId = "gateway", + isCurrentImpl = { secondSocketIsCurrent }, + ) { method, params, _ -> + requests.add(method to params) + "" + } + var activeLease: GatewaySession.RequestLease? = firstLease + val observer = + SessionObserverVisibility( + isVisible = { visible }, + captureLease = { activeLease }, + ) + + observer.sync() + observer.sync() + visible = false + observer.sync() + observer.sync() + visible = true + observer.sync() + firstSocketIsCurrent = false + secondSocketIsCurrent = true + activeLease = secondLease + observer.sync() + observer.sync() + + assertEquals( + listOf( + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":false}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + ), + requests, + ) + } + + @Test + fun disconnectedObserverNeverCreatesARequestOrConnection() = + runBlocking { + var captureAttempts = 0 + var visibilityReads = 0 + val observer = + SessionObserverVisibility( + isVisible = { + visibilityReads += 1 + true + }, + captureLease = { + captureAttempts += 1 + null + }, + ) + + observer.sync() + + assertEquals(1, captureAttempts) + assertEquals(0, visibilityReads) + } + + @Test + fun lostBackgroundReplyCannotSuppressForegroundRecovery() = + runBlocking { + var visible = true + var loseBackgroundReply = true + val requests = mutableListOf>() + val lease = + GatewaySession.RequestLease(endpointStableId = "gateway") { method, params, _ -> + requests.add(method to params) + if (params == """{"visible":false}""" && loseBackgroundReply) { + loseBackgroundReply = false + throw IllegalStateException("visibility reply lost") + } + "" + } + val observer = + SessionObserverVisibility( + isVisible = { visible }, + captureLease = { lease }, + ) + + observer.sync() + visible = false + try { + observer.sync() + throw AssertionError("The lost background reply must be reported") + } catch (error: IllegalStateException) { + assertEquals("visibility reply lost", error.message) + } + visible = true + observer.sync() + observer.sync() + + assertEquals( + listOf( + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":false}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + ), + requests, + ) + } +} diff --git a/apps/ios/Sources/RootSidebarModel.swift b/apps/ios/Sources/RootSidebarModel.swift index af3c4332165b..13cf3b4897cc 100644 --- a/apps/ios/Sources/RootSidebarModel.swift +++ b/apps/ios/Sources/RootSidebarModel.swift @@ -1,6 +1,7 @@ import Foundation import Observation import OpenClawChatUI +import OpenClawKit import OpenClawProtocol struct ChatSessionRosterSnapshot: Sendable { @@ -47,6 +48,12 @@ final class RootSidebarModel { let isPartial: Bool } + struct SessionObserverDeclaration: Equatable { + let route: Route + let visible: Bool + let generation: UInt64 + } + private(set) var sessions: [OpenClawChatSessionEntry] = [] private(set) var usage: CostUsageSummaryLite? private(set) var cronJobs: [CronJob] = [] @@ -54,6 +61,10 @@ final class RootSidebarModel { private(set) var sessionErrorText: String? private var rosterGeneration = 0 private var dashboardGeneration = 0 + private var sessionObserverVisibility = false + private var sessionObserverGeneration: UInt64 = 0 + private var sessionObserverDeclaration: SessionObserverDeclaration? + private var sessionObserverSync: (id: UUID, task: Task)? var failedCronJobCount: Int { self.cronJobs.count { Self.isFailedCronJob($0) } @@ -145,23 +156,107 @@ final class RootSidebarModel { } } + func setSessionObserverVisibility(appModel: NodeAppModel, visible: Bool) async { + self.sessionObserverVisibility = visible + let observerGeneration = self.sessionObserverGeneration + let previous = self.sessionObserverSync?.task + let syncID = UUID() + let task = Task { @MainActor [weak self, weak appModel] in + await previous?.value + guard let self, + let appModel, + self.sessionObserverGeneration == observerGeneration, + self.sessionObserverVisibility == visible, + let route = await appModel.operatorSession.currentRoute(), + self.sessionObserverGeneration == observerGeneration, + self.sessionObserverVisibility == visible + else { + self?.finishSessionObserverSync(id: syncID) + return + } + + if let declaration = self.sessionObserverDeclaration, + declaration.route == route, + declaration.visible == visible, + declaration.generation == observerGeneration + { + self.finishSessionObserverSync(id: syncID) + return + } + + // The Gateway may apply a change before its reply times out. An old + // confirmation must never suppress the next recovery declaration. + self.sessionObserverDeclaration = nil + let request = OpenClawChatGatewayRequests.setSessionObserverVisibility( + visible, + timeoutMs: 12000) + do { + _ = try await appModel.operatorSession.request( + method: request.method, + params: request.params, + timeoutMs: request.timeoutMs, + ifCurrentRoute: route) + if let declaration = Self.confirmedSessionObserverDeclaration( + route: route, + visible: visible, + generation: observerGeneration, + currentGeneration: self.sessionObserverGeneration, + currentVisibility: self.sessionObserverVisibility) + { + self.sessionObserverDeclaration = declaration + } + } catch { + // Reconnect replays visibility on its own physical operator route. + } + self.finishSessionObserverSync(id: syncID) + } + self.sessionObserverSync = (id: syncID, task: task) + await task.value + } + + static func confirmedSessionObserverDeclaration( + route: Route, + visible: Bool, + generation: UInt64, + currentGeneration: UInt64, + currentVisibility: Bool) -> SessionObserverDeclaration? + { + guard generation == currentGeneration, visible == currentVisibility else { return nil } + return SessionObserverDeclaration(route: route, visible: visible, generation: generation) + } + + private func finishSessionObserverSync(id: UUID) { + guard self.sessionObserverSync?.id == id else { return } + self.sessionObserverSync = nil + } + func observeSessionEvents(appModel: NodeAppModel) async { await Self.consumeSubscribedSessionEvents( makeStream: { await appModel.operatorSession.subscribeServerEvents(bufferingNewest: 200) }, subscribe: { + let request = OpenClawChatGatewayRequests.subscribeSessions(timeoutMs: 12000) _ = try await appModel.operatorSession.request( - method: "sessions.subscribe", - paramsJSON: nil, - timeoutSeconds: 12) - _ = try? await appModel.operatorSession.request( - method: "sessions.observer.visibility", - paramsJSON: #"{"visible":true}"#, - timeoutSeconds: 12) + method: request.method, + params: request.params, + timeoutMs: request.timeoutMs) }, onEvent: { [weak self] frame in await self?.handleSessionEvent(frame, appModel: appModel) ?? false + }, + invalidateObserverDeclaration: { [weak self] in + guard let self else { return } + // Subscription generations prevent a delayed old-socket ACK from + // satisfying the visibility replay queued for the new socket. + self.sessionObserverGeneration &+= 1 + self.sessionObserverDeclaration = nil + }, + observerVisibility: { [weak self] in + self?.sessionObserverVisibility ?? false + }, + declareObserverVisibility: { [weak self] visible in + await self?.setSessionObserverVisibility(appModel: appModel, visible: visible) }) } @@ -169,6 +264,9 @@ final class RootSidebarModel { makeStream: @MainActor () async -> AsyncStream, subscribe: @MainActor () async throws -> Void, onEvent: @MainActor (EventFrame) async -> Bool, + invalidateObserverDeclaration: @MainActor () -> Void = {}, + observerVisibility: @MainActor () -> Bool = { false }, + declareObserverVisibility: @MainActor (Bool) async -> Void = { _ in }, retryDelays: [Duration] = [ .seconds(1), .seconds(2), @@ -188,6 +286,10 @@ final class RootSidebarModel { let stream = await makeStream() do { try await subscribe() + // Subscriptions are socket-owned; a successful replay invalidates + // an old visibility ACK even when the logical route is unchanged. + invalidateObserverDeclaration() + await declareObserverVisibility(observerVisibility()) failureCount = 0 } catch is CancellationError { return diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index e6752fbfa41a..57b1686fd7d5 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -4,6 +4,16 @@ import SwiftUI import UIKit struct RootTabs: View { + struct SessionObserverTaskIdentity: Equatable { + let sidebarRefreshID: String + let isSceneActive: Bool + let isSidebarVisible: Bool + + var isObserverVisible: Bool { + self.isSceneActive && self.isSidebarVisible + } + } + @Environment(NodeAppModel.self) private var appModel @Environment(VoiceWakeManager.self) private var voiceWake @Environment(GatewayConnectionController.self) private var gatewayController @@ -190,9 +200,21 @@ struct RootTabs: View { guard self.scenePhase == .active else { return } await self.sidebarModel.observeSessionEvents(appModel: self.appModel) } + .task(id: self.sessionObserverTaskIdentity) { + await self.sidebarModel.setSessionObserverVisibility( + appModel: self.appModel, + visible: self.sessionObserverTaskIdentity.isObserverVisible) + } } } + private var sessionObserverTaskIdentity: SessionObserverTaskIdentity { + SessionObserverTaskIdentity( + sidebarRefreshID: self.sidebarRefreshID, + isSceneActive: self.scenePhase == .active, + isSidebarVisible: self.isSidebarVisible) + } + private var sidebarRefreshID: String { [ self.appModel.chatViewModelIdentityID, diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index a5d66d427f62..8475741f779a 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -713,6 +713,174 @@ struct RootTabsPresentationTests { #expect(events == ["patch", "message"]) } + @Test func `sidebar replays actual observer visibility after each reconnect`() async { + var isVisible = true + var subscribeAttempts = 0 + var declarations: [Bool] = [] + + await RootSidebarModel.consumeSubscribedSessionEvents( + makeStream: { + AsyncStream { continuation in + continuation.finish() + } + }, + subscribe: { + subscribeAttempts += 1 + }, + onEvent: { _ in false }, + observerVisibility: { isVisible }, + declareObserverVisibility: { visible in + declarations.append(visible) + }, + retryDelays: [.zero], + sleep: { _ in + guard subscribeAttempts == 1 else { + throw CancellationError() + } + isVisible = false + }) + + #expect(subscribeAttempts == 2) + #expect(declarations == [true, false]) + } + + @Test func `sidebar invalidates a confirmed same-route observer after resubscription`() async { + let route = "same-operator-route" + var subscribeAttempts = 0 + var confirmation: (route: String, visible: Bool)? = (route, true) + var declarations: [Bool] = [] + + await RootSidebarModel.consumeSubscribedSessionEvents( + makeStream: { + AsyncStream { continuation in + continuation.finish() + } + }, + subscribe: { + subscribeAttempts += 1 + }, + onEvent: { _ in false }, + invalidateObserverDeclaration: { + confirmation = nil + }, + observerVisibility: { true }, + declareObserverVisibility: { visible in + guard confirmation?.route != route || confirmation?.visible != visible else { return } + declarations.append(visible) + confirmation = (route, visible) + }, + retryDelays: [.zero], + sleep: { _ in + guard subscribeAttempts == 1 else { + throw CancellationError() + } + }) + + #expect(subscribeAttempts == 2) + #expect(declarations == [true, true]) + #expect(confirmation?.route == route) + #expect(confirmation?.visible == true) + } + + @Test func `sidebar rejects an old visibility acknowledgement after same-route resubscription`() async { + let route = "same-operator-route" + var subscribeAttempts = 0 + var generation: UInt64 = 0 + var confirmation: RootSidebarModel.SessionObserverDeclaration? + var firstAcknowledgementGeneration: UInt64? + var rejectedOldAcknowledgement = false + var declarations: [Bool] = [] + + await RootSidebarModel.consumeSubscribedSessionEvents( + makeStream: { + AsyncStream { continuation in + continuation.finish() + } + }, + subscribe: { + subscribeAttempts += 1 + }, + onEvent: { _ in false }, + invalidateObserverDeclaration: { + generation &+= 1 + confirmation = nil + + if let firstAcknowledgementGeneration { + let oldAcknowledgement = RootSidebarModel.confirmedSessionObserverDeclaration( + route: route, + visible: true, + generation: firstAcknowledgementGeneration, + currentGeneration: generation, + currentVisibility: true) + rejectedOldAcknowledgement = oldAcknowledgement == nil + if let oldAcknowledgement { + confirmation = oldAcknowledgement + } + } + }, + observerVisibility: { true }, + declareObserverVisibility: { visible in + let declaration = RootSidebarModel.SessionObserverDeclaration( + route: route, + visible: visible, + generation: generation) + guard confirmation != declaration else { return } + declarations.append(visible) + + if subscribeAttempts == 1 { + firstAcknowledgementGeneration = generation + } else { + confirmation = RootSidebarModel.confirmedSessionObserverDeclaration( + route: route, + visible: visible, + generation: generation, + currentGeneration: generation, + currentVisibility: true) + } + }, + retryDelays: [.zero], + sleep: { _ in + guard subscribeAttempts == 1 else { + throw CancellationError() + } + }) + + #expect(subscribeAttempts == 2) + #expect(rejectedOldAcknowledgement) + #expect(declarations == [true, true]) + #expect(confirmation?.route == route) + #expect(confirmation?.visible == true) + #expect(confirmation?.generation == 2) + } + + @Test func `sidebar observer identity restarts for foreground and background transitions`() { + let foreground = RootTabs.SessionObserverTaskIdentity( + sidebarRefreshID: "gateway:main", + isSceneActive: true, + isSidebarVisible: true) + let background = RootTabs.SessionObserverTaskIdentity( + sidebarRefreshID: "gateway:main", + isSceneActive: false, + isSidebarVisible: true) + let foregroundAgain = RootTabs.SessionObserverTaskIdentity( + sidebarRefreshID: "gateway:main", + isSceneActive: true, + isSidebarVisible: true) + let hidden = RootTabs.SessionObserverTaskIdentity( + sidebarRefreshID: "gateway:main", + isSceneActive: true, + isSidebarVisible: false) + + #expect(foreground != background) + #expect(background != foregroundAgain) + #expect(foreground == foregroundAgain) + #expect(foreground != hidden) + #expect(foreground.isObserverVisible) + #expect(!background.isObserverVisible) + #expect(foregroundAgain.isObserverVisible) + #expect(!hidden.isObserverVisible) + } + @Test func `pinned pages storage round trips and preserves pin order`() { #expect(RootTabs.pinnedSidebarPages(from: "") == RootTabs.defaultPinnedSidebarPages) #expect(RootTabs.pinnedSidebarPages(from: "none").isEmpty) diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index ee2638a38565..7bf6eaa10f36 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import OpenClawChatUI /// A borderless panel that can still accept key focus (needed for typing). final class WebChatPanel: NSPanel { @@ -36,6 +37,32 @@ struct WebChatRoute: Equatable, Sendable { } } +struct WebChatSessionObserverVisibilityOwners { + private var ownersByConnection: [ObjectIdentifier: Set] = [:] + + mutating func setVisible( + _ visible: Bool, + owner: ObjectIdentifier, + connection: ObjectIdentifier) -> Bool? + { + let wasVisible = self.isVisible(connection: connection) + if visible { + self.ownersByConnection[connection, default: []].insert(owner) + } else { + self.ownersByConnection[connection]?.remove(owner) + if self.ownersByConnection[connection]?.isEmpty == true { + self.ownersByConnection.removeValue(forKey: connection) + } + } + let isVisible = self.isVisible(connection: connection) + return wasVisible == isVisible ? nil : isVisible + } + + func isVisible(connection: ObjectIdentifier) -> Bool { + self.ownersByConnection[connection]?.isEmpty == false + } +} + @MainActor final class WebChatManager { static let shared = WebChatManager() @@ -54,6 +81,11 @@ final class WebChatManager { private var profileWindows: [UUID: ProfileWindowInstance] = [:] private var profileWindowOrder: [UUID] = [] private var unavailableProfileIDs: Set = [] + private var sessionObserverOwners = WebChatSessionObserverVisibilityOwners() + private var sessionObserverMonitors: [ObjectIdentifier: Task] = [:] + private var sessionObserverRequests: [ObjectIdentifier: (id: UUID, task: Task)] = [:] + private var sessionObserverDeclarations: + [ObjectIdentifier: (lease: GatewayConnection.ServerLease, visible: Bool)] = [:] private static let lastGatewayProfileIDKey = "openclaw.webchat.lastGatewayProfileID" @@ -84,11 +116,15 @@ final class WebChatManager { agentID: route.agentID, initialDraft: draft, presentation: .window) - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) + controller.onVisibilityChanged = { [weak self, weak controller] visible in + guard let self, let controller else { return } + self.setSessionObserverVisible(visible, owner: controller, connection: .shared) + self.onPanelVisibilityChanged?(visible) } controller.onClosed = { [weak self, weak controller] in - guard let self, let controller, self.windowController === controller else { return } + guard let self, let controller else { return } + self.setSessionObserverVisible(false, owner: controller, connection: .shared) + guard self.windowController === controller else { return } if self.currentChatRoute == self.windowRoute { self.currentChatRoute = self.panelRoute } @@ -171,11 +207,14 @@ final class WebChatManager { gatewayID: profile.id, windowTitle: "\(profile.name) — OpenClaw", windowAutosaveName: "OpenClawChatWindow-\(profile.id)") + controller.onVisibilityChanged = { [weak self, weak controller] visible in + guard let self, let controller else { return } + self.setSessionObserverVisible(visible, owner: controller, connection: connection) + } controller.onClosed = { [weak self, weak controller] in - guard let self, - let controller, - self.profileWindows[windowID]?.controller === controller - else { return } + guard let self, let controller else { return } + self.setSessionObserverVisible(false, owner: controller, connection: connection) + guard self.profileWindows[windowID]?.controller === controller else { return } self.profileWindows.removeValue(forKey: windowID) self.profileWindowOrder.removeAll { $0 == windowID } } @@ -295,6 +334,117 @@ final class WebChatManager { self.resetTunnels() } + private func setSessionObserverVisible( + _ visible: Bool, + owner: WebChatSwiftUIWindowController, + connection: GatewayConnection) + { + let connectionID = ObjectIdentifier(connection) + guard let aggregateVisibility = self.sessionObserverOwners.setVisible( + visible, + owner: ObjectIdentifier(owner), + connection: connectionID) + else { return } + + if aggregateVisibility, self.sessionObserverMonitors[connectionID] == nil { + // Visibility and subscriptions belong to a physical socket; a reconnect + // must redeclare both while any window on that connection remains open. + self.sessionObserverMonitors[connectionID] = Task { @MainActor [weak self] in + let pushes = await connection.subscribe(bufferingNewest: 1) + for await push in pushes { + guard !Task.isCancelled else { return } + guard case .snapshot = push else { continue } + guard let self else { return } + self.scheduleSessionObserverVisibility( + self.sessionObserverOwners.isVisible(connection: connectionID), + connection: connection) + } + } + } + self.scheduleSessionObserverVisibility(aggregateVisibility, connection: connection) + } + + private func scheduleSessionObserverVisibility( + _ visible: Bool, + connection: GatewayConnection, + remainingHiddenRetries: Int = 1) + { + let connectionID = ObjectIdentifier(connection) + let previous = self.sessionObserverRequests[connectionID]?.task + let requestID = UUID() + let task = Task { @MainActor [weak self] in + await previous?.value + guard let self, + self.sessionObserverOwners.isVisible(connection: connectionID) == visible, + let lease = await connection.captureServerLease(), + self.sessionObserverOwners.isVisible(connection: connectionID) == visible + else { + self?.finishSessionObserverRequest(connection: connectionID, id: requestID) + return + } + + if let declaration = self.sessionObserverDeclarations[connectionID], + declaration.visible == visible, + await connection.isCurrentServerLease(declaration.lease) + { + self.finishSessionObserverRequest(connection: connectionID, id: requestID) + return + } + + // A timed-out mutation may already have changed the Gateway. Clear + // the old confirmation before dispatch so reopening retries truthfully. + self.sessionObserverDeclarations.removeValue(forKey: connectionID) + do { + if visible { + let subscribe = OpenClawChatGatewayRequests.subscribeSessions() + _ = try await connection.request( + method: subscribe.method, + params: subscribe.params, + timeoutMs: subscribe.timeoutMs, + ifCurrentServerLease: lease) + } + guard self.sessionObserverOwners.isVisible(connection: connectionID) == visible else { + self.finishSessionObserverRequest(connection: connectionID, id: requestID) + return + } + let request = OpenClawChatGatewayRequests.setSessionObserverVisibility(visible) + _ = try await connection.request( + method: request.method, + params: request.params, + timeoutMs: request.timeoutMs, + ifCurrentServerLease: lease) + if visible { + self.sessionObserverDeclarations[connectionID] = (lease: lease, visible: true) + } else { + self.sessionObserverDeclarations.removeValue(forKey: connectionID) + if !self.sessionObserverOwners.isVisible(connection: connectionID) { + self.sessionObserverMonitors.removeValue(forKey: connectionID)?.cancel() + } + } + } catch { + // A hidden mutation can time out after dispatch. Retry once on its + // original socket; keep the snapshot monitor for a replaced socket. + if !visible, + remainingHiddenRetries > 0, + await connection.isCurrentServerLease(lease), + !self.sessionObserverOwners.isVisible(connection: connectionID) + { + self.scheduleSessionObserverVisibility( + false, + connection: connection, + remainingHiddenRetries: remainingHiddenRetries - 1) + } + } + self.finishSessionObserverRequest(connection: connectionID, id: requestID) + } + self.sessionObserverRequests[connectionID] = (id: requestID, task: task) + } + + private func finishSessionObserverRequest(connection: ObjectIdentifier, id: UUID) { + guard self.sessionObserverRequests[connection]?.id == id else { return } + self.sessionObserverRequests.removeValue(forKey: connection) + } + private func panelHidden() { self.onPanelVisibilityChanged?(false) // Keep panel controller cached so reopening doesn't re-bootstrap. @@ -353,6 +503,10 @@ final class WebChatManager { } #if DEBUG + func _testSessionObserverVisible(connection: GatewayConnection) -> Bool { + self.sessionObserverOwners.isVisible(connection: ObjectIdentifier(connection)) + } + func _testProfileWindowCount(profileID: String) -> Int { self.profileWindows.values.count { $0.profileID == profileID } } diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift index d56031344fcc..cc3983f46d53 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift @@ -41,6 +41,77 @@ struct WebChatSwiftUISmokeTests { func setActiveSessionKey(_: String) async throws {} } + @Test func `session observer remains visible until the last shared gateway window closes`() { + var owners = WebChatSessionObserverVisibilityOwners() + let firstConnection = NSObject() + let secondConnection = NSObject() + let firstWindow = NSObject() + let secondWindow = NSObject() + let independentWindow = NSObject() + let sharedConnectionID = ObjectIdentifier(firstConnection) + let independentConnectionID = ObjectIdentifier(secondConnection) + + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(firstWindow), + connection: sharedConnectionID) == true) + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(firstWindow), + connection: sharedConnectionID) == nil) + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(secondWindow), + connection: sharedConnectionID) == nil) + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(independentWindow), + connection: independentConnectionID) == true) + #expect(owners.setVisible( + false, + owner: ObjectIdentifier(firstWindow), + connection: sharedConnectionID) == nil) + #expect(owners.isVisible(connection: sharedConnectionID)) + #expect(owners.isVisible(connection: independentConnectionID)) + #expect(owners.setVisible( + false, + owner: ObjectIdentifier(secondWindow), + connection: sharedConnectionID) == false) + #expect(!owners.isVisible(connection: sharedConnectionID)) + #expect(owners.isVisible(connection: independentConnectionID)) + #expect(owners.setVisible( + false, + owner: ObjectIdentifier(secondWindow), + connection: sharedConnectionID) == nil) + } + + @Test func `reopening a gateway window restores visibility after the last owner closes`() { + var owners = WebChatSessionObserverVisibilityOwners() + let connection = NSObject() + let closingWindow = NSObject() + let reopeningWindow = NSObject() + let connectionID = ObjectIdentifier(connection) + + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(closingWindow), + connection: connectionID) == true) + #expect(owners.setVisible( + false, + owner: ObjectIdentifier(closingWindow), + connection: connectionID) == false) + #expect(owners.setVisible( + true, + owner: ObjectIdentifier(reopeningWindow), + connection: connectionID) == true) + #expect(owners.isVisible(connection: connectionID)) + #expect(owners.setVisible( + false, + owner: ObjectIdentifier(closingWindow), + connection: connectionID) == nil) + #expect(owners.isVisible(connection: connectionID)) + } + @Test func `window controller merges titlebar and keeps toolbar controls`() throws { let traceKeys = [ OpenClawChatWindowShell.assistantTraceDefaultsKey, @@ -104,6 +175,8 @@ struct WebChatSwiftUISmokeTests { presentation: .window, transport: TestTransport()) var closeCount = 0 + var visibilityChanges: [Bool] = [] + controller.onVisibilityChanged = { visibilityChanges.append($0) } controller.onClosed = { closeCount += 1 } controller.close() @@ -111,6 +184,7 @@ struct WebChatSwiftUISmokeTests { #expect(controller._testWindow == nil) #expect(closeCount == 1) + #expect(visibilityChanges == [false]) } @Test func `one Gateway profile can own multiple independent windows`() async throws { @@ -122,10 +196,13 @@ struct WebChatSwiftUISmokeTests { try await manager.show(profile: profile) try await manager.show(profile: profile) + let connection = await MacGatewayConnectionFleet.shared.connection(profileID: profile.id) #expect(manager._testProfileWindowCount(profileID: profile.id) == 2) + #expect(manager._testSessionObserverVisible(connection: connection)) await manager.closeGatewayWindows(profileID: profile.id) #expect(manager._testProfileWindowCount(profileID: profile.id) == 0) + #expect(!manager._testSessionObserverVisible(connection: connection)) } @Test func `initial draft populates an empty composer without replacing user text`() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index 428f65f456e1..26d3c0089449 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -389,6 +389,22 @@ public enum OpenClawChatGatewayRequests { timeoutMs: self.mutationTimeoutMs) } + public static func subscribeSessions(timeoutMs: Double = 10000) -> OpenClawChatGatewayRequest { + OpenClawChatGatewayRequest( + method: "sessions.subscribe", + timeoutMs: timeoutMs) + } + + public static func setSessionObserverVisibility( + _ visible: Bool, + timeoutMs: Double = 10000) -> OpenClawChatGatewayRequest + { + OpenClawChatGatewayRequest( + method: "sessions.observer.visibility", + params: ["visible": AnyCodable(visible)], + timeoutMs: timeoutMs) + } + public static func subscribeSessionMessages( sessionKey: String, agentID: String?) -> OpenClawChatGatewayRequest diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index 1ba6dd3348c5..b838d5293d4c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -4,6 +4,28 @@ import Testing @testable import OpenClawChatUI struct ChatGatewayRequestTests { + @Test func `session observation requests encode global subscription and actual visibility`() { + let subscribe = OpenClawChatGatewayRequests.subscribeSessions() + let visible = OpenClawChatGatewayRequests.setSessionObserverVisibility(true) + let hidden = OpenClawChatGatewayRequests.setSessionObserverVisibility(false) + let longerSubscription = OpenClawChatGatewayRequests.subscribeSessions(timeoutMs: 12000) + let longerVisibility = OpenClawChatGatewayRequests.setSessionObserverVisibility( + true, + timeoutMs: 12000) + + #expect(subscribe.method == "sessions.subscribe") + #expect(subscribe.params.isEmpty) + #expect(subscribe.timeoutMs == 10000) + #expect(visible.method == "sessions.observer.visibility") + #expect(visible.params["visible"]?.value as? Bool == true) + #expect(visible.timeoutMs == 10000) + #expect(hidden.method == "sessions.observer.visibility") + #expect(hidden.params["visible"]?.value as? Bool == false) + #expect(hidden.timeoutMs == 10000) + #expect(longerSubscription.timeoutMs == 12000) + #expect(longerVisibility.timeoutMs == 12000) + } + @Test func `session targets share normalization while preserving platform routing policy`() { #expect(OpenClawChatSessionTarget.resolve( " Matrix:Channel:Room ",