mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 13:41:35 +00:00
fix(apps): restore live session updates after native reconnects (#113634)
* fix(apps): replay session visibility across native reconnects * fix(apps): refresh shifted native i18n source lines --------- Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
committed by
GitHub
parent
f43fac21c7
commit
7e80f36723
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Pair<String, String?>>()
|
||||
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<Pair<String, String?>>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Route: Equatable>: 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<GatewayNodeSessionRoute>?
|
||||
private var sessionObserverSync: (id: UUID, task: Task<Void, Never>)?
|
||||
|
||||
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: Equatable>(
|
||||
route: Route,
|
||||
visible: Bool,
|
||||
generation: UInt64,
|
||||
currentGeneration: UInt64,
|
||||
currentVisibility: Bool) -> SessionObserverDeclaration<Route>?
|
||||
{
|
||||
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<EventFrame>,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>?
|
||||
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)
|
||||
|
||||
@@ -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<ObjectIdentifier>] = [:]
|
||||
|
||||
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<String> = []
|
||||
private var sessionObserverOwners = WebChatSessionObserverVisibilityOwners()
|
||||
private var sessionObserverMonitors: [ObjectIdentifier: Task<Void, Never>] = [:]
|
||||
private var sessionObserverRequests: [ObjectIdentifier: (id: UUID, task: Task<Void, Never>)] = [:]
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
Reference in New Issue
Block a user