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:
Peter Steinberger
2026-07-25 04:58:41 -07:00
committed by GitHub
parent f43fac21c7
commit 7e80f36723
10 changed files with 858 additions and 123 deletions

View File

@@ -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",

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 }
}

View File

@@ -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`() {

View File

@@ -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

View File

@@ -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 ",