diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 4c9b038bdc1e..97a293c6b4f1 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -539,7 +539,7 @@ }, { "kind": "ui-state-text", - "line": 465, + "line": 466, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -547,7 +547,7 @@ }, { "kind": "ui-state-text", - "line": 598, + "line": 599, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -555,7 +555,7 @@ }, { "kind": "ui-state-text", - "line": 612, + "line": 613, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -691,7 +691,7 @@ }, { "kind": "ui-call", - "line": 173, + "line": 174, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", "surface": "android", @@ -699,7 +699,7 @@ }, { "kind": "ui-call", - "line": 175, + "line": 176, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The Gateway still shows this approval as pending. Review it before trying again.", "surface": "android", @@ -707,7 +707,7 @@ }, { "kind": "ui-call", - "line": 177, + "line": 178, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -715,7 +715,7 @@ }, { "kind": "ui-call", - "line": 179, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -723,7 +723,7 @@ }, { "kind": "ui-call", - "line": 181, + "line": 182, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 278, + "line": 279, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Device approved.", "surface": "android", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 279, + "line": 280, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pairing request rejected.", "surface": "android", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 281, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Paired device removed.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 341, + "line": 342, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 341, + "line": 342, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 342, + "line": 343, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 342, + "line": 343, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 344, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 344, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 350, + "line": 351, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 351, + "line": 352, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 359, + "line": 360, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 541, + "line": 542, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 542, + "line": 543, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 544, + "line": 545, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 552, + "line": 553, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 2217, + "line": 2218, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 2247, + "line": 2248, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 2247, + "line": 2248, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 2260, + "line": 2261, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 2291, + "line": 2292, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2291, + "line": 2292, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2313, + "line": 2314, "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", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2319, + "line": 2320, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2334, + "line": 2335, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2584, + "line": 2585, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2595, + "line": 2596, "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", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2616, + "line": 2617, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2627, + "line": 2628, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 3699, + "line": 3701, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 3727, + "line": 3729, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-call", - "line": 3736, + "line": 3738, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 4447, + "line": 4449, "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", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 4451, + "line": 4453, "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", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 4455, + "line": 4457, "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", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-named-argument", - "line": 4505, + "line": 4507, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-named-argument", - "line": 4715, + "line": 4717, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 5449, + "line": 5451, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 5480, + "line": 5482, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 5482, + "line": 5484, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-call", - "line": 5498, + "line": 5500, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-call", - "line": 5585, + "line": 5587, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5671, + "line": 5673, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5681, + "line": 5683, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5685, + "line": 5687, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5697, + "line": 5699, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5728, + "line": 5730, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5745, + "line": 5747, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5754, + "line": 5756, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5766, + "line": 5768, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5813, + "line": 5815, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5940, + "line": 5942, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 5975, + "line": 5977, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 5988, + "line": 5990, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5992, + "line": 5994, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "conditional-branch", - "line": 6007, + "line": 6009, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "conditional-branch", - "line": 6007, + "line": 6009, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 6025, + "line": 6027, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 6071, + "line": 6073, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 6084, + "line": 6086, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 6117, + "line": 6119, "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", @@ -1219,7 +1219,7 @@ }, { "kind": "ui-call", - "line": 6133, + "line": 6135, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 6149, + "line": 6151, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 6165, + "line": 6167, "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", @@ -1243,7 +1243,7 @@ }, { "kind": "conditional-branch", - "line": 6255, + "line": 6257, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "ui-call", - "line": 6262, + "line": 6264, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6302, + "line": 6304, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6342, + "line": 6344, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6362, + "line": 6364, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "ui-call", - "line": 6414, + "line": 6416, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6434, + "line": 6436, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6439, + "line": 6441, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6629, + "line": 6631, "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", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-named-argument", - "line": 6711, + "line": 6713, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6741, + "line": 6743, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 7399, + "line": 7401, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 7429, + "line": 7431, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 7466, + "line": 7468, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-call", - "line": 7935, + "line": 7937, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 7944, + "line": 7946, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7945, + "line": 7947, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7953, + "line": 7955, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7954, + "line": 7956, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 7955, + "line": 7957, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 7956, + "line": 7958, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 7972, + "line": 7974, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 7989, + "line": 7991, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 7997, + "line": 7999, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 7998, + "line": 8000, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 8000, + "line": 8002, "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", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 8004, + "line": 8006, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 8007, + "line": 8009, "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", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 8012, + "line": 8014, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 8013, + "line": 8015, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 8015, + "line": 8017, "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", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 8019, + "line": 8021, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 8022, + "line": 8024, "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", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 8027, + "line": 8029, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 8028, + "line": 8030, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 8030, + "line": 8032, "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", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 8034, + "line": 8036, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 8037, + "line": 8039, "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", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 8069, + "line": 8071, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 8084, + "line": 8086, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 8085, + "line": 8087, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 8086, + "line": 8088, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8741, + "line": 8743, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 1050, + "line": 1053, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Wait for the current response to finish before starting a new chat.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 1183, + "line": 1186, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update model.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 1248, + "line": 1251, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update thinking level.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 1741, + "line": 1744, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed before the run started; try again.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 3179, + "line": 3198, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 3212, + "line": 3231, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3218, + "line": 3237, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 3224, + "line": 3243, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 3229, + "line": 3248, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 3236, + "line": 3255, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1835,7 +1835,7 @@ }, { "kind": "ui-call", - "line": 3953, + "line": 3972, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1843,7 +1843,7 @@ }, { "kind": "ui-call", - "line": 4091, + "line": 4110, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1851,7 +1851,7 @@ }, { "kind": "ui-call", - "line": 4228, + "line": 4247, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1859,7 +1859,7 @@ }, { "kind": "ui-call", - "line": 4438, + "line": 4457, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", @@ -12043,7 +12043,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 172, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preparing audio…", "surface": "android", @@ -12051,7 +12051,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 172, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Speaking…", "surface": "android", @@ -12059,7 +12059,7 @@ }, { "kind": "ui-call", - "line": 277, + "line": 279, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preview · $domain", "surface": "android", @@ -12067,7 +12067,7 @@ }, { "kind": "ui-call", - "line": 286, + "line": 288, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Expand link preview", "surface": "android", @@ -12075,7 +12075,7 @@ }, { "kind": "ui-call", - "line": 329, + "line": 331, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Loading preview…", "surface": "android", @@ -12083,7 +12083,7 @@ }, { "kind": "ui-call", - "line": 330, + "line": 332, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "No preview available", "surface": "android", @@ -12091,15 +12091,23 @@ }, { "kind": "ui-call", - "line": 376, + "line": 379, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", - "source": "Thinking...", + "source": "Working", "surface": "android", - "id": "native.android.a50e44d8f380f9ad" + "id": "native.android.66aef4de5e07438f" + }, + { + "kind": "ui-call", + "line": 389, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", + "source": "· $phrase", + "surface": "android", + "id": "native.android.6e7be1773c946de3" }, { "kind": "ui-named-argument", - "line": 392, + "line": 406, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Tools", "surface": "android", @@ -12107,7 +12115,7 @@ }, { "kind": "ui-call", - "line": 395, + "line": 409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Running tools...", "surface": "android", @@ -12115,7 +12123,7 @@ }, { "kind": "ui-call", - "line": 399, + "line": 413, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "${display.emoji} ${display.label}", "surface": "android", @@ -12123,7 +12131,7 @@ }, { "kind": "ui-call", - "line": 416, + "line": 430, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "... +${toolCalls.size - 6} more", "surface": "android", @@ -12131,7 +12139,7 @@ }, { "kind": "ui-call", - "line": 437, + "line": 451, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Queued — sends when reconnected", "surface": "android", @@ -12139,7 +12147,7 @@ }, { "kind": "ui-call", - "line": 438, + "line": 452, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Sending…", "surface": "android", @@ -12147,7 +12155,7 @@ }, { "kind": "ui-call", - "line": 439, + "line": 453, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Sent — confirming delivery…", "surface": "android", @@ -12155,7 +12163,7 @@ }, { "kind": "ui-call", - "line": 444, + "line": 458, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Failed", "surface": "android", @@ -12163,7 +12171,7 @@ }, { "kind": "ui-call", - "line": 444, + "line": 458, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Failed — $it", "surface": "android", @@ -12171,7 +12179,7 @@ }, { "kind": "ui-call", - "line": 448, + "line": 462, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "user", "surface": "android", @@ -12179,7 +12187,7 @@ }, { "kind": "ui-call", - "line": 456, + "line": 470, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "📎 ${attachment.fileName}", "surface": "android", @@ -12187,7 +12195,7 @@ }, { "kind": "ui-call", - "line": 472, + "line": 486, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Retry", "surface": "android", @@ -12195,7 +12203,7 @@ }, { "kind": "ui-call", - "line": 477, + "line": 491, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Delete", "surface": "android", @@ -12203,7 +12211,7 @@ }, { "kind": "ui-call", - "line": 508, + "line": 522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "assistant", "surface": "android", @@ -12211,7 +12219,7 @@ }, { "kind": "ui-named-argument", - "line": 509, + "line": 523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw · Live", "surface": "android", @@ -12219,7 +12227,7 @@ }, { "kind": "ui-call", - "line": 545, + "line": 559, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "You", "surface": "android", @@ -12227,7 +12235,7 @@ }, { "kind": "ui-call", - "line": 546, + "line": 560, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "System", "surface": "android", @@ -12235,7 +12243,7 @@ }, { "kind": "ui-call", - "line": 547, + "line": 561, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw", "surface": "android", @@ -12243,7 +12251,7 @@ }, { "kind": "ui-call", - "line": 570, + "line": 584, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Attachment", "surface": "android", @@ -12251,7 +12259,7 @@ }, { "kind": "ui-call", - "line": 583, + "line": 597, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Open image preview", "surface": "android", @@ -12259,7 +12267,7 @@ }, { "kind": "ui-call", - "line": 601, + "line": 615, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Image preview", "surface": "android", @@ -12267,7 +12275,7 @@ }, { "kind": "ui-call", - "line": 615, + "line": 629, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Close image preview", "surface": "android", @@ -12275,7 +12283,7 @@ }, { "kind": "ui-call", - "line": 624, + "line": 638, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Unsupported attachment", "surface": "android", @@ -12355,7 +12363,7 @@ }, { "kind": "ui-call", - "line": 363, + "line": 374, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Model", "surface": "android", @@ -12363,7 +12371,7 @@ }, { "kind": "ui-call", - "line": 486, + "line": 497, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Some shared images were omitted or could not be added.", "surface": "android", @@ -12371,7 +12379,7 @@ }, { "kind": "ui-call", - "line": 628, + "line": 639, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -12379,7 +12387,7 @@ }, { "kind": "ui-call", - "line": 914, + "line": 927, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -12387,7 +12395,7 @@ }, { "kind": "ui-call", - "line": 997, + "line": 1010, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Working", "surface": "android", @@ -12395,7 +12403,7 @@ }, { "kind": "ui-call", - "line": 998, + "line": 1011, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready", "surface": "android", @@ -12403,7 +12411,7 @@ }, { "kind": "ui-call", - "line": 999, + "line": 1012, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Offline", "surface": "android", @@ -12411,7 +12419,7 @@ }, { "kind": "ui-call", - "line": 1012, + "line": 1025, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat actions", "surface": "android", @@ -12419,7 +12427,7 @@ }, { "kind": "ui-call", - "line": 1017, + "line": 1030, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -12427,7 +12435,7 @@ }, { "kind": "ui-call", - "line": 1025, + "line": 1038, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dashboard", "surface": "android", @@ -12435,7 +12443,7 @@ }, { "kind": "ui-call", - "line": 1033, + "line": 1046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Background tasks", "surface": "android", @@ -12443,7 +12451,7 @@ }, { "kind": "ui-call", - "line": 1054, + "line": 1067, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -12451,7 +12459,7 @@ }, { "kind": "ui-call", - "line": 1201, + "line": 1237, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Messages to recover", "surface": "android", @@ -12459,7 +12467,7 @@ }, { "kind": "ui-call", - "line": 1203, + "line": 1239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.", "surface": "android", @@ -12467,7 +12475,7 @@ }, { "kind": "ui-call", - "line": 1231, + "line": 1273, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading thread", "surface": "android", @@ -12475,7 +12483,7 @@ }, { "kind": "ui-call", - "line": 1264, + "line": 1306, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -12483,7 +12491,7 @@ }, { "kind": "ui-call", - "line": 1294, + "line": 1385, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -12491,7 +12499,7 @@ }, { "kind": "ui-call", - "line": 1298, + "line": 1389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start with a prompt, or use voice.", "surface": "android", @@ -12499,7 +12507,7 @@ }, { "kind": "ui-call", - "line": 1300, + "line": 1391, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use the recovery options below to reconnect.", "surface": "android", @@ -12507,7 +12515,7 @@ }, { "kind": "ui-call", - "line": 1302, + "line": 1393, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is checking Gateway health.", "surface": "android", @@ -12515,7 +12523,7 @@ }, { "kind": "ui-call", - "line": 1325, + "line": 1416, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -12523,7 +12531,7 @@ }, { "kind": "ui-call", - "line": 1326, + "line": 1417, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -12531,7 +12539,7 @@ }, { "kind": "ui-call", - "line": 1385, + "line": 1476, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up", "surface": "android", @@ -12539,7 +12547,7 @@ }, { "kind": "ui-call", - "line": 1386, + "line": 1477, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Summarize recent threads and next steps.", "surface": "android", @@ -12547,7 +12555,7 @@ }, { "kind": "ui-call", - "line": 1387, + "line": 1478, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up on my recent OpenClaw threads and suggest next steps.", "surface": "android", @@ -12555,7 +12563,7 @@ }, { "kind": "ui-call", - "line": 1391, + "line": 1482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Plan the work", "surface": "android", @@ -12563,7 +12571,7 @@ }, { "kind": "ui-call", - "line": 1392, + "line": 1483, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Turn a goal into an actionable checklist.", "surface": "android", @@ -12571,7 +12579,7 @@ }, { "kind": "ui-call", - "line": 1393, + "line": 1484, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Help me turn this goal into a practical checklist: ", "surface": "android", @@ -12579,7 +12587,7 @@ }, { "kind": "ui-call", - "line": 1397, + "line": 1488, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use this phone", "surface": "android", @@ -12587,7 +12595,7 @@ }, { "kind": "ui-call", - "line": 1398, + "line": 1489, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ask OpenClaw to use Android capabilities.", "surface": "android", @@ -12595,7 +12603,7 @@ }, { "kind": "ui-call", - "line": 1399, + "line": 1490, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "What can you help me do from this phone right now?", "surface": "android", @@ -12603,7 +12611,7 @@ }, { "kind": "ui-call", - "line": 1466, + "line": 1557, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw · Live", "surface": "android", @@ -12611,7 +12619,7 @@ }, { "kind": "ui-call", - "line": 1467, + "line": 1558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "You", "surface": "android", @@ -12619,7 +12627,7 @@ }, { "kind": "ui-call", - "line": 1468, + "line": 1559, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "System", "surface": "android", @@ -12627,7 +12635,7 @@ }, { "kind": "ui-call", - "line": 1469, + "line": 1560, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -12635,7 +12643,7 @@ }, { "kind": "ui-call", - "line": 1551, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -12643,7 +12651,7 @@ }, { "kind": "ui-call", - "line": 1551, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -12651,7 +12659,7 @@ }, { "kind": "ui-call", - "line": 1582, + "line": 1673, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close", "surface": "android", @@ -12659,7 +12667,7 @@ }, { "kind": "ui-call", - "line": 1582, + "line": 1673, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "View all", "surface": "android", @@ -12667,7 +12675,7 @@ }, { "kind": "ui-call", - "line": 1612, + "line": 1703, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -12675,7 +12683,7 @@ }, { "kind": "ui-call", - "line": 1614, + "line": 1705, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -12683,31 +12691,15 @@ }, { "kind": "ui-call", - "line": 1617, + "line": 1708, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", "id": "native.android.0fd6b75cadc0ce75" }, - { - "kind": "ui-call", - "line": 1627, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Thinking", - "surface": "android", - "id": "native.android.7e0aea1dca378135" - }, - { - "kind": "ui-call", - "line": 1628, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "OpenClaw is preparing a response.", - "surface": "android", - "id": "native.android.98a0d4d5bba788c4" - }, { "kind": "ui-named-argument", - "line": 1693, + "line": 1774, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$completedCount/${steps.size}", "surface": "android", @@ -12715,7 +12707,7 @@ }, { "kind": "ui-call", - "line": 1700, + "line": 1781, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Collapse plan checklist", "surface": "android", @@ -12723,7 +12715,7 @@ }, { "kind": "ui-call", - "line": 1700, + "line": 1781, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Expand plan checklist", "surface": "android", @@ -12731,7 +12723,7 @@ }, { "kind": "ui-call", - "line": 1832, + "line": 1913, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dismiss shared-image warning", "surface": "android", @@ -12739,7 +12731,7 @@ }, { "kind": "ui-call", - "line": 1935, + "line": 2016, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -12747,7 +12739,7 @@ }, { "kind": "ui-call", - "line": 2033, + "line": 2114, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -12755,7 +12747,7 @@ }, { "kind": "ui-call", - "line": 2099, + "line": 2180, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -12763,7 +12755,7 @@ }, { "kind": "ui-call", - "line": 2099, + "line": 2180, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -12771,7 +12763,7 @@ }, { "kind": "ui-call", - "line": 2116, + "line": 2197, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -12779,7 +12771,7 @@ }, { "kind": "ui-call", - "line": 2158, + "line": 2239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Command", "surface": "android", @@ -12787,7 +12779,7 @@ }, { "kind": "ui-call", - "line": 2178, + "line": 2259, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -12795,7 +12787,7 @@ }, { "kind": "ui-call", - "line": 2224, + "line": 2305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close thinking level selector", "surface": "android", @@ -12803,7 +12795,7 @@ }, { "kind": "ui-call", - "line": 2224, + "line": 2305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open thinking level selector", "surface": "android", @@ -12811,7 +12803,7 @@ }, { "kind": "ui-call", - "line": 2289, + "line": 2370, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -12819,7 +12811,7 @@ }, { "kind": "ui-call", - "line": 2294, + "line": 2375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attachment", "surface": "android", @@ -12827,7 +12819,7 @@ }, { "kind": "ui-call", - "line": 2325, + "line": 2406, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -12835,7 +12827,7 @@ }, { "kind": "ui-call", - "line": 2354, + "line": 2435, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "End Talk", "surface": "android", @@ -12843,7 +12835,7 @@ }, { "kind": "ui-call", - "line": 2354, + "line": 2435, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start Talk", "surface": "android", @@ -12851,7 +12843,7 @@ }, { "kind": "ui-call", - "line": 2439, + "line": 2520, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Voice note · ${formatVoiceNoteDuration(duration)}", "surface": "android", @@ -12859,7 +12851,7 @@ }, { "kind": "ui-call", - "line": 2448, + "line": 2529, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -12867,7 +12859,7 @@ }, { "kind": "ui-call", - "line": 2460, + "line": 2541, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -12875,7 +12867,7 @@ }, { "kind": "ui-call", - "line": 2469, + "line": 2550, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -12883,7 +12875,7 @@ }, { "kind": "ui-call", - "line": 2470, + "line": 2551, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Current", "surface": "android", @@ -12891,7 +12883,7 @@ }, { "kind": "ui-call", - "line": 2477, + "line": 2558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -12899,7 +12891,7 @@ }, { "kind": "ui-call", - "line": 2536, + "line": 2617, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -12907,7 +12899,7 @@ }, { "kind": "ui-call", - "line": 2547, + "line": 2628, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is still checking Gateway health.", "surface": "android", @@ -12915,7 +12907,7 @@ }, { "kind": "ui-call", - "line": 2548, + "line": 2629, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway is offline. Fix the connection below or copy diagnostics.", "surface": "android", @@ -12923,7 +12915,7 @@ }, { "kind": "ui-call", - "line": 2549, + "line": 2630, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway authentication needs attention.", "surface": "android", @@ -12931,7 +12923,7 @@ }, { "kind": "ui-call", - "line": 2568, + "line": 2649, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context ${(it * 100).roundToInt()}%", "surface": "android", @@ -12939,7 +12931,7 @@ }, { "kind": "ui-call", - "line": 2569, + "line": 2650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context --", "surface": "android", @@ -12947,7 +12939,7 @@ }, { "kind": "ui-call", - "line": 2570, + "line": 2651, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -12955,7 +12947,7 @@ }, { "kind": "ui-call", - "line": 2609, + "line": 2690, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Off", "surface": "android", @@ -12963,7 +12955,7 @@ }, { "kind": "ui-call", - "line": 2610, + "line": 2691, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Minimal", "surface": "android", @@ -12971,7 +12963,7 @@ }, { "kind": "ui-call", - "line": 2611, + "line": 2692, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Low", "surface": "android", @@ -12979,7 +12971,7 @@ }, { "kind": "ui-call", - "line": 2612, + "line": 2693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Medium", "surface": "android", @@ -12987,7 +12979,7 @@ }, { "kind": "ui-call", - "line": 2613, + "line": 2694, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "High", "surface": "android", @@ -12995,7 +12987,7 @@ }, { "kind": "ui-call", - "line": 2614, + "line": 2695, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Xhigh", "surface": "android", @@ -13003,7 +12995,7 @@ }, { "kind": "ui-call", - "line": 2615, + "line": 2696, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Adaptive", "surface": "android", @@ -13011,12 +13003,236 @@ }, { "kind": "ui-call", - "line": 2616, + "line": 2697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Max", "surface": "android", "id": "native.android.3200f8564c25a1fa" }, + { + "kind": "ui-call", + "line": 222, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt", + "source": "1 token", + "surface": "android", + "id": "native.android.c2dc3a65302976c0" + }, + { + "kind": "ui-call", + "line": 224, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt", + "source": "$count tokens", + "surface": "android", + "id": "native.android.e7059e3a756908c4" + }, + { + "kind": "ui-call", + "line": 234, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt", + "source": "Done in $duration", + "surface": "android", + "id": "native.android.c32ff602fa622e4b" + }, + { + "kind": "conditional-branch", + "line": 253, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt", + "source": "${decimal(count / 1_000_000.0)}M", + "surface": "android", + "id": "native.android.d2797e7488fe5021" + }, + { + "kind": "conditional-branch", + "line": 253, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt", + "source": "${thousands}k", + "surface": "android", + "id": "native.android.4206e3e68c56767a" + }, + { + "kind": "ui-call", + "line": 345, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "${days}d", + "surface": "android", + "id": "native.android.5e758e9851ded127" + }, + { + "kind": "ui-call", + "line": 349, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "${hours}h", + "surface": "android", + "id": "native.android.4fe67b5100cb91b1" + }, + { + "kind": "ui-call", + "line": 353, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "${minutes}m", + "surface": "android", + "id": "native.android.92a53292eaf7c63e" + }, + { + "kind": "ui-call", + "line": 357, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "${seconds}s", + "surface": "android", + "id": "native.android.6d5683b5537bdbaf" + }, + { + "kind": "ui-call", + "line": 392, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Shelling", + "surface": "android", + "id": "native.android.e2f387a31240c8a3" + }, + { + "kind": "ui-call", + "line": 393, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Scuttling", + "surface": "android", + "id": "native.android.70a6de7c24baffb7" + }, + { + "kind": "ui-call", + "line": 394, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Clawing", + "surface": "android", + "id": "native.android.0ed1354a65faa07e" + }, + { + "kind": "ui-call", + "line": 395, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Pinching", + "surface": "android", + "id": "native.android.42d24b16fc658ed3" + }, + { + "kind": "ui-call", + "line": 396, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Molting", + "surface": "android", + "id": "native.android.69d5b3ca23a7bec3" + }, + { + "kind": "ui-call", + "line": 397, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Bubbling", + "surface": "android", + "id": "native.android.80bfb2d6720f8dc5" + }, + { + "kind": "ui-call", + "line": 398, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Tiding", + "surface": "android", + "id": "native.android.e984f80d309c2d01" + }, + { + "kind": "ui-call", + "line": 399, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Reefing", + "surface": "android", + "id": "native.android.60bb819ee08cbbc1" + }, + { + "kind": "ui-call", + "line": 400, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Cracking", + "surface": "android", + "id": "native.android.7f465ae2654f0c25" + }, + { + "kind": "ui-call", + "line": 401, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Sifting", + "surface": "android", + "id": "native.android.cfc143346cf262c8" + }, + { + "kind": "ui-call", + "line": 402, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Brining", + "surface": "android", + "id": "native.android.72b67956ab234898" + }, + { + "kind": "ui-call", + "line": 403, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Nautiling", + "surface": "android", + "id": "native.android.3ecce1d21d8ab752" + }, + { + "kind": "ui-call", + "line": 404, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Krilling", + "surface": "android", + "id": "native.android.305ed08fbb829753" + }, + { + "kind": "ui-call", + "line": 405, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Barnacling", + "surface": "android", + "id": "native.android.c39933b1ec86d7a9" + }, + { + "kind": "ui-call", + "line": 406, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Lobstering", + "surface": "android", + "id": "native.android.32a8072679db94bb" + }, + { + "kind": "ui-call", + "line": 407, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Tidepooling", + "surface": "android", + "id": "native.android.de205a44a6d159fb" + }, + { + "kind": "ui-call", + "line": 408, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Pearling", + "surface": "android", + "id": "native.android.8d97a105339c7a20" + }, + { + "kind": "ui-call", + "line": 409, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Snapping", + "surface": "android", + "id": "native.android.7bdb054d45ca1b23" + }, + { + "kind": "ui-call", + "line": 410, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt", + "source": "Surfacing", + "surface": "android", + "id": "native.android.c2210ea40e357fcd" + }, { "kind": "ui-call", "line": 138, diff --git a/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json b/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json new file mode 100644 index 000000000000..074e0e30dbff --- /dev/null +++ b/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json @@ -0,0 +1,177 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6faa4088ccfb9dd4dd8bc3e6619de163", + "entities": [ + { + "tableName": "cached_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `status` TEXT, `startedAt` INTEGER, `endedAt` INTEGER, `runtimeMs` INTEGER, `outputTokens` INTEGER, `hasRunMetadata` INTEGER NOT NULL, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "runtimeMs", + "columnName": "runtimeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasRunMetadata", + "columnName": "hasRunMetadata", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey" + ] + } + }, + { + "tableName": "cached_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textPartsJson", + "columnName": "textPartsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampMs", + "columnName": "timestampMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey", + "rowOrder" + ] + } + }, + { + "tableName": "cached_gateway_owners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6faa4088ccfb9dd4dd8bc3e6619de163')" + ] + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index b5643f19a33c..5e0dd06326e7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -10,6 +10,7 @@ import ai.openclaw.app.chat.ChatPlanStep import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.chat.ChatTranscriptAnchorState import ai.openclaw.app.chat.ChatWidgetResource import ai.openclaw.app.chat.GatewayDefaultAgentOwner import ai.openclaw.app.chat.MessageSpeechState @@ -617,6 +618,8 @@ class MainViewModel private constructor( val chatSessionOwnerAgentId: StateFlow = runtimeState(initial = null) { it.chatSessionOwnerAgentId } val chatSessionId: StateFlow = runtimeState(initial = null) { it.chatSessionId } val chatMessages: StateFlow> = runtimeState(initial = emptyList()) { it.chatMessages } + val chatTranscriptAnchor: StateFlow = + runtimeState(initial = null) { it.chatTranscriptAnchor } val chatHistoryLoading: StateFlow = runtimeState(initial = false) { it.chatHistoryLoading } val chatError: StateFlow = runtimeState(initial = null) { it.chatError } val chatHealthOk: StateFlow = runtimeState(initial = false) { it.chatHealthOk } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 68ceb372a00d..9eef7b981816 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -15,6 +15,7 @@ import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionDeletion import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.chat.ChatTranscriptAnchorState import ai.openclaw.app.chat.ChatTranscriptCache import ai.openclaw.app.chat.ChatWidgetResource import ai.openclaw.app.chat.ChatWidgetSurface @@ -2728,6 +2729,7 @@ class NodeRuntime private constructor( internal val gatewayComposerDefaultAgentOwner: StateFlow = chat.composerDefaultAgentOwner val chatSessionId: StateFlow = chat.sessionId val chatMessages: StateFlow> = chat.messages + val chatTranscriptAnchor: StateFlow = chat.transcriptAnchor val chatHistoryLoading: StateFlow = chat.historyLoading val chatError: StateFlow = chat.errorText val chatHealthOk: StateFlow = chat.healthOk diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 3756249e07ed..6ca610970f9b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -192,6 +192,9 @@ class ChatController internal constructor( private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + private val _transcriptAnchor = MutableStateFlow(null) + val transcriptAnchor: StateFlow = _transcriptAnchor.asStateFlow() + // True while the transcript shown came from the offline cache and no live history replaced it yet. private val _messagesFromCache = MutableStateFlow(false) val messagesFromCache: StateFlow = _messagesFromCache.asStateFlow() @@ -2608,6 +2611,7 @@ class ChatController internal constructor( generation: Long, updateSessionInfo: Boolean, runIdsToReconcile: Set = emptySet(), + markCompletedTranscript: Boolean = false, ): HistoryRefreshResult { val requestSequence = historyRequestSequence.incrementAndGet() val runIdsOwnedAtRequest = synchronized(pendingRuns) { pendingRuns.toSet() } @@ -2702,8 +2706,23 @@ class ChatController internal constructor( !unresolvedRepliesByRunId.containsKey(it) }.forEach(::clearPendingRun) } + val nextMessages = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) _messagesFromCache.value = false - _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) + _messages.value = nextMessages + val previousAnchor = _transcriptAnchor.value?.takeIf { it.sessionKey == sessionKey } + val completionSettled = + markCompletedTranscript && + runIdsToReconcile.none(unresolvedRepliesByRunId::containsKey) && + history.sessionInfo?.endedAt != null + _transcriptAnchor.value = + ChatTranscriptAnchorState( + sessionKey = sessionKey, + newestItemId = nextMessages.lastOrNull()?.id, + completedEndedAt = + if (completionSettled) history.sessionInfo.endedAt else previousAnchor?.completedEndedAt, + completedNewestItemId = + if (completionSettled) history.messages.lastOrNull()?.id else previousAnchor?.completedNewestItemId, + ) _sessionId.value = history.sessionId markLiveHistoryApplied(sessionKey = sessionKey, sessionId = history.sessionId, generation = generation) _historyLoading.value = false @@ -4454,6 +4473,7 @@ class ChatController internal constructor( generation, updateSessionInfo = true, runIdsToReconcile = runIdsToReconcile, + markCompletedTranscript = runIdsToReconcile.isNotEmpty(), ) } catch (err: CancellationException) { throw err @@ -4475,6 +4495,7 @@ class ChatController internal constructor( generation = generation, updateSessionInfo = updateSessionInfo, runIdsToReconcile = runIdsToReconcile, + markCompletedTranscript = runIdsToReconcile.isNotEmpty(), ) } catch (_: Throwable) { // best-effort @@ -4613,6 +4634,17 @@ class ChatController internal constructor( obj["activeRunIds"] .asArrayOrNull() ?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) }, + status = obj["status"].asStringOrNull()?.trim(), + startedAt = obj["startedAt"].asLongOrNull(), + endedAt = obj["endedAt"].asLongOrNull(), + runtimeMs = obj["runtimeMs"].asLongOrNull(), + outputTokens = obj["outputTokens"].asLongOrNull(), + hasRunMetadata = + "status" in obj || + "startedAt" in obj || + "endedAt" in obj || + "runtimeMs" in obj || + "outputTokens" in obj, ) } @@ -5379,6 +5411,12 @@ internal fun mergeChatSessionEntry( preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null else -> next.hasContextUsageMetadata }, + status = if (next.hasRunMetadata) next.status else existing.status, + startedAt = if (next.hasRunMetadata) next.startedAt else existing.startedAt, + endedAt = if (next.hasRunMetadata) next.endedAt else existing.endedAt, + runtimeMs = if (next.hasRunMetadata) next.runtimeMs else existing.runtimeMs, + outputTokens = if (next.hasRunMetadata) next.outputTokens else existing.outputTokens, + hasRunMetadata = existing.hasRunMetadata || next.hasRunMetadata, ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index 849575f01bfc..3e466851994b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -26,6 +26,13 @@ data class ChatMessage( val idempotencyKey: String? = null, ) +data class ChatTranscriptAnchorState( + val sessionKey: String, + val newestItemId: String?, + val completedEndedAt: Long?, + val completedNewestItemId: String?, +) + /** * One content part in a chat message; binary parts carry base64 plus their MIME metadata. */ @@ -165,6 +172,13 @@ data class ChatSessionEntry( val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null, val hasActiveRun: Boolean? = null, val activeRunIds: List? = null, + val status: String? = null, + val startedAt: Long? = null, + val endedAt: Long? = null, + val runtimeMs: Long? = null, + val outputTokens: Long? = null, + val hasRunMetadata: Boolean = + status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null, ) /** Local fallback for server-side `sessions.list` search over cached entries. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt index bf095bf37082..ecaf97997075 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt @@ -75,6 +75,12 @@ internal data class CachedSessionEntity( val sessionKey: String, val displayName: String?, val updatedAtMs: Long?, + val status: String?, + val startedAt: Long?, + val endedAt: Long?, + val runtimeMs: Long?, + val outputTokens: Long?, + val hasRunMetadata: Boolean, // Preserves gateway list order so offline session rows render in the familiar order. val rowOrder: Int, ) @@ -258,6 +264,12 @@ class RoomChatTranscriptCache internal constructor( updatedAtMs = row.updatedAtMs, ownerAgentId = agent, displayName = row.displayName, + status = row.status, + startedAt = row.startedAt, + endedAt = row.endedAt, + runtimeMs = row.runtimeMs, + outputTokens = row.outputTokens, + hasRunMetadata = row.hasRunMetadata, ) } } @@ -305,6 +317,12 @@ class RoomChatTranscriptCache internal constructor( sessionKey = entry.key, displayName = entry.displayName, updatedAtMs = entry.updatedAtMs, + status = entry.status, + startedAt = entry.startedAt, + endedAt = entry.endedAt, + runtimeMs = entry.runtimeMs, + outputTokens = entry.outputTokens, + hasRunMetadata = entry.hasRunMetadata, rowOrder = 0, ) } ?: dao.session(gateway, agent, retainedKey) @@ -320,6 +338,12 @@ class RoomChatTranscriptCache internal constructor( sessionKey = session.key, displayName = session.displayName, updatedAtMs = session.updatedAtMs, + status = session.status, + startedAt = session.startedAt, + endedAt = session.endedAt, + runtimeMs = session.runtimeMs, + outputTokens = session.outputTokens, + hasRunMetadata = session.hasRunMetadata, rowOrder = index, ) } @@ -380,6 +404,12 @@ class RoomChatTranscriptCache internal constructor( sessionKey = key, displayName = null, updatedAtMs = null, + status = null, + startedAt = null, + endedAt = null, + runtimeMs = null, + outputTokens = null, + hasRunMetadata = false, rowOrder = dao.nextSessionRowOrder(gateway, agent), ), ), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt index 775f86528da6..934bd4a39205 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt @@ -76,7 +76,7 @@ internal interface ClientStateControlDao { /** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */ @Database( entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class], - version = 1, + version = 2, exportSchema = true, ) internal abstract class GatewayCacheDatabase : RoomDatabase() { @@ -151,9 +151,19 @@ internal abstract class ClientStateDatabase : RoomDatabase() { * * Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes. */ +@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"]) +internal data class LegacyCachedSessionEntity( + val gatewayId: String, + val agentId: String, + val sessionKey: String, + val displayName: String?, + val updatedAtMs: Long?, + val rowOrder: Int, +) + @Database( entities = [ - CachedSessionEntity::class, + LegacyCachedSessionEntity::class, CachedMessageEntity::class, OutboxCommandEntity::class, OutboxAttachmentEntity::class, @@ -165,8 +175,6 @@ internal abstract class ClientStateDatabase : RoomDatabase() { exportSchema = false, ) internal abstract class LegacyChatDatabase : RoomDatabase() { - abstract fun dao(): ChatCacheDao - abstract fun outboxDao(): ChatOutboxDao companion object { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 48b09fabeee2..aaae45483ed7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -9,6 +9,7 @@ import ai.openclaw.app.chat.MessageSpeechPhase import ai.openclaw.app.chat.MessageSpeechState import ai.openclaw.app.chat.normalizeVisibleChatMessageRole import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.nativeStringResource import ai.openclaw.app.tools.ToolDisplayRegistry import ai.openclaw.app.ui.MobileColorsAccessor import ai.openclaw.app.ui.design.ClawTheme @@ -63,7 +64,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap @@ -71,6 +71,8 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -363,17 +365,29 @@ private fun linkPreviewDomain(url: String): String = /** Assistant placeholder shown while a run is active but no text has streamed yet. */ @Composable -fun ChatTypingIndicatorBubble() { +fun ChatTypingIndicatorBubble( + runKey: String, + observedAtElapsedMs: Long, +) { + val elapsedMs = rememberWorkingElapsedMs(observedAtElapsedMs) + val phrase = workingPhraseText(seed = runKey, elapsedMs = elapsedMs) ChatBubbleContainer( style = bubbleStyle("assistant"), roleLabel = roleLabel("assistant"), ) { Row( + modifier = Modifier.clearAndSetSemantics { contentDescription = nativeString("Working") }, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - DotPulse(color = mobileTextSecondary) - Text(nativeString("Thinking..."), style = mobileCallout, color = mobileTextSecondary) + WorkingClawIcon(runKey = runKey, color = mobileAccent) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(formatLocalizedChatDurationCompact(elapsedMs), style = mobileCallout, color = mobileTextSecondary) + phrase?.let { Text(nativeStringResource("· \$phrase", it), style = mobileCallout, color = mobileTextSecondary) } + } } } } @@ -625,27 +639,6 @@ internal fun ChatBase64Image( } } -@Composable -private fun DotPulse(color: Color) { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f, color = color) - PulseDot(alpha = 0.62f, color = color) - PulseDot(alpha = 0.90f, color = color) - } -} - -@Composable -private fun PulseDot( - alpha: Float, - color: Color, -) { - Surface( - modifier = Modifier.size(6.dp).alpha(alpha), - shape = CircleShape, - color = color, - ) {} -} - /** Shared code block renderer used by chat Markdown. */ @Composable fun ChatCodeBlock( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 30b939639f20..4e6eacd046db 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -18,6 +18,7 @@ import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelOption import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.chat.ChatTranscriptAnchorState import ai.openclaw.app.chat.ChatWidgetResource import ai.openclaw.app.chat.MessageSpeechPhase import ai.openclaw.app.chat.MessageSpeechState @@ -53,6 +54,7 @@ import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint import ai.openclaw.app.ui.gatewayStatusForDisplay import ai.openclaw.app.ui.localizedUppercase import ai.openclaw.app.ui.mobileCallout +import android.os.SystemClock import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.LinearEasing @@ -240,6 +242,7 @@ fun ChatScreen( onOpenGatewaySettings: () -> Unit, ) { val messages by viewModel.chatMessages.collectAsState() + val transcriptAnchor by viewModel.chatTranscriptAnchor.collectAsState() val historyLoading by viewModel.chatHistoryLoading.collectAsState() val errorText by viewModel.chatError.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState() @@ -285,6 +288,14 @@ fun ChatScreen( fallbackSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog), ) val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions) + val activeSession = + sessions.firstOrNull { + isActiveSessionChoice( + choiceKey = it.key, + sessionKey = sessionKey, + mainSessionKey = mainSessionKey, + ) + } val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls) val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() } val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: gatewayConnectionDisplay.statusText) @@ -632,7 +643,9 @@ fun ChatScreen( ChatMessageList( sessionKey = sessionKey, + session = activeSession, messages = messages, + transcriptAnchor = transcriptAnchor, historyLoading = historyLoading, pendingRunCount = pendingRunCount, pendingToolCalls = pendingToolCalls, @@ -1120,7 +1133,9 @@ private fun HeaderIcon( @Composable private fun ChatMessageList( sessionKey: String, + session: ChatSessionEntry?, messages: List, + transcriptAnchor: ChatTranscriptAnchorState?, historyLoading: Boolean, pendingRunCount: Int, pendingToolCalls: List, @@ -1141,7 +1156,7 @@ private fun ChatMessageList( resolveInlineWidgetResource: suspend (String, ChatWidgetResource?) -> ChatWidgetResource?, modifier: Modifier = Modifier, ) { - val timeline = + val baseTimeline = remember(messages, pendingRunCount, pendingToolCalls, questions, streamingAssistantText, outboxItems, recoveryOutboxItems) { buildChatTimeline( messages = messages, @@ -1153,12 +1168,33 @@ private fun ChatMessageList( questions = questions, ) } + val indicatorVisible = pendingRunCount > 0 + val workingRunTracker = remember(sessionKey) { ChatWorkingRunTracker(sessionKey) } + val workingRun = workingRunTracker.resolve(indicatorVisible, session, SystemClock.elapsedRealtime()) + val turnRecapResolver = remember { TurnRecapResolver() } + val turnRecap = + turnRecapResolver.resolve( + sessionKey = sessionKey, + indicatorVisible = indicatorVisible, + row = session, + transcript = + TurnRecapTranscriptState( + sessionKey = transcriptAnchor?.sessionKey, + newestItemId = transcriptAnchor?.newestItemId, + completedEndedAt = transcriptAnchor?.completedEndedAt, + completedNewestItemId = transcriptAnchor?.completedNewestItemId, + ), + ) + val timeline = remember(baseTimeline, turnRecap) { baseTimeline.withTurnRecap(turnRecap) } val readerScroll = rememberChatReaderScrollController( sessionKey = sessionKey, timeline = timeline, historyLoading = historyLoading, ) + DisposableEffect(sessionKey, turnRecapResolver) { + onDispose { turnRecapResolver.abandonActiveWatch(sessionKey) } + } Box(modifier = modifier.fillMaxWidth()) { LazyColumn( @@ -1208,6 +1244,7 @@ private fun ChatMessageList( is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls) is ChatTimelineItem.QuestionPrompt -> ChatQuestionCard(prompt = item.prompt, onSubmit = onResolveQuestion, onSkip = onSkipQuestion) + is ChatTimelineItem.TurnRecapSummary -> ChatTurnRecapRow(item.recap) is ChatTimelineItem.StreamingAssistant -> ChatBubble( messageId = null, @@ -1221,7 +1258,12 @@ private fun ChatMessageList( inlineWidgetResolverReady = healthOk, resolveInlineWidgetResource = resolveInlineWidgetResource, ) - ChatTimelineItem.Thinking -> ChatThinkingBubble() + ChatTimelineItem.Thinking -> { + val run = workingRun + if (run != null) { + ChatTypingIndicatorBubble(runKey = run.key, observedAtElapsedMs = run.observedAtElapsedMs) + } + } } } } @@ -1272,6 +1314,55 @@ private fun ChatMessageList( } } +internal data class ChatWorkingRun( + val key: String, + val observedAtElapsedMs: Long, + val authoritativeRunId: String?, + val authoritativeStartedAtMs: Long?, +) + +internal class ChatWorkingRunTracker( + private val sessionKey: String, +) { + private var current: ChatWorkingRun? = null + + fun resolve( + indicatorVisible: Boolean, + session: ChatSessionEntry?, + nowElapsedMs: Long, + ): ChatWorkingRun? { + if (!indicatorVisible) { + current = null + return null + } + val runId = session?.activeRunIds?.lastOrNull() + val startedAt = session?.startedAt?.takeIf { session.endedAt == null } + val previous = current + val replacementByRunId = previous?.authoritativeRunId != null && runId != null && previous.authoritativeRunId != runId + val replacementByStart = + previous?.authoritativeStartedAtMs != null && startedAt != null && previous.authoritativeStartedAtMs != startedAt + val replacement = replacementByRunId || replacementByStart + if (previous == null || replacement) { + return ChatWorkingRun( + key = runId ?: "$sessionKey:${startedAt ?: nowElapsedMs}", + observedAtElapsedMs = nowElapsedMs, + authoritativeRunId = runId, + authoritativeStartedAtMs = startedAt, + ).also { current = it } + } + val adoptsRunId = previous.authoritativeRunId == null && runId != null + val adoptsStartedAt = previous.authoritativeStartedAtMs == null && startedAt != null + if (adoptsRunId || adoptsStartedAt) { + current = + previous.copy( + authoritativeRunId = runId ?: previous.authoritativeRunId, + authoritativeStartedAtMs = startedAt ?: previous.authoritativeStartedAtMs, + ) + } + return current + } +} + internal fun showChatLoadingPlaceholder( historyLoading: Boolean, healthOk: Boolean, @@ -1620,16 +1711,6 @@ private fun ToolBubble(toolCalls: List) { } } -@Composable -private fun ChatThinkingBubble() { - ClawPanel { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ClawStatusPill(text = nativeString("Thinking"), status = ClawStatus.Warning) - Text(text = nativeString("OpenClaw is preparing a response."), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) - } - } -} - @Composable private fun ChatNotice( title: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt index 4d9dd628f722..f38864fe0bf4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt @@ -39,6 +39,10 @@ internal sealed class ChatTimelineItem { val prompt: ChatQuestionPrompt, ) : ChatTimelineItem() + data class TurnRecapSummary( + val recap: TurnRecap, + ) : ChatTimelineItem() + object Thinking : ChatTimelineItem() } @@ -194,6 +198,18 @@ internal fun ChatTimeline.containsUserMessageVersion(version: String): Boolean = message.role.trim().equals("user", ignoreCase = true) && stableMessageVersion(message) == version } +internal fun ChatTimeline.withTurnRecap(recap: TurnRecap?): ChatTimeline { + if (recap == null) return this + // reverseLayout makes index 0 the newest visual edge. The recap replaces the terminal + // thinking slot there, while shifting the saved user-message anchor to the same row. + return copy( + items = listOf(ChatTimelineItem.TurnRecapSummary(recap)) + items, + readAnchorIndex = readAnchorIndex?.plus(1), + latestContentIndex = 0, + latestContentVersion = "$latestContentVersion:recap=${recap.runtimeMs}:${recap.outputTokens ?: ""}", + ) +} + // Reader restoration only needs to detect changes at the live edge. Avoid hashing // the full transcript whenever a streamed response updates. private fun latestContentVersion( @@ -273,6 +289,7 @@ internal fun chatTimelineItemKey(item: ChatTimelineItem): String = is ChatTimelineItem.OutboxRecoveryHeader -> "outbox-recovery-header" is ChatTimelineItem.PendingTools -> "tools" is ChatTimelineItem.QuestionPrompt -> "question:${item.prompt.record.id}" + is ChatTimelineItem.TurnRecapSummary -> "turn-recap" is ChatTimelineItem.StreamingAssistant -> "stream" ChatTimelineItem.Thinking -> "thinking" } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt new file mode 100644 index 000000000000..b20be5488611 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt @@ -0,0 +1,257 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.i18n.nativeStringResource +import ai.openclaw.app.ui.design.ClawTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.util.Locale + +internal data class TurnRecap( + val runtimeMs: Long, + val outputTokens: Long?, +) + +internal data class TurnRecapTranscriptState( + val sessionKey: String?, + val newestItemId: String?, + val completedEndedAt: Long?, + val completedNewestItemId: String?, +) + +internal data class TurnRecapTokenFormat( + val singular: Boolean, + val count: String, +) + +/** + * [baselineEndedAt] is the session row's endedAt when the working indicator appeared: the + * previous run's terminal stamp, or null once the run-start patch cleared it. Only a row whose + * endedAt moved past that baseline belongs to the run this pane watched. [settled] freezes the + * first recap while [settledTranscriptItemId] still identifies the transcript's newest item. + */ +private data class TurnRecapWatch( + var watching: Boolean, + /** False until a session row was observed; without a baseline, a later terminal is ambiguous. */ + var baselineKnown: Boolean, + var baselineEndedAt: Long?, + /** A stamp changed while the claw was still up, so later stamps cannot be attributed safely. */ + var absorbedTerminal: Boolean, + /** First idle render after the indicator cleared; canceled queued sends must expire promptly. */ + var settleStartedAt: Long?, + var pendingTerminal: TurnRecap?, + var pendingTerminalEndedAt: Long?, + val settled: TurnRecap?, + val settledTranscriptItemId: String?, + val tracksTranscript: Boolean, +) + +/** + * Session rows have no run identity. The watched terminal normally arrives moments after the + * indicator clears, so an unresolved watch expires after this window instead of matching an + * unrelated later completion. An unrelated completion inside the window remains an accepted, + * cosmetic ambiguity until the gateway supplies a terminal-row run id. + */ +internal const val TURN_RECAP_SETTLE_WINDOW_MS = 30_000L + +internal class TurnRecapResolver( + private val nowMs: () -> Long = System::currentTimeMillis, +) { + private val watches = mutableMapOf() + + /** Leaving before settlement destroys attribution; settled recaps remain until superseded. */ + fun abandonActiveWatch(sessionKey: String) { + val watch = watches[sessionKey] ?: return + if (watch.settled == null) watches.remove(sessionKey) + } + + /** + * Watches while the indicator is visible, then resolves the first fresh terminal row. Only a + * clean `done` with runtime data produces a recap; every other fresh terminal consumes quietly. + */ + fun resolve( + sessionKey: String, + indicatorVisible: Boolean, + row: ChatSessionEntry?, + ): TurnRecap? = + resolveInternal( + sessionKey = sessionKey, + indicatorVisible = indicatorVisible, + row = row, + transcript = null, + ) + + fun resolve( + sessionKey: String, + indicatorVisible: Boolean, + row: ChatSessionEntry?, + transcript: TurnRecapTranscriptState, + ): TurnRecap? = + resolveInternal( + sessionKey = sessionKey, + indicatorVisible = indicatorVisible, + row = row, + transcript = transcript, + ) + + private fun resolveInternal( + sessionKey: String, + indicatorVisible: Boolean, + row: ChatSessionEntry?, + transcript: TurnRecapTranscriptState?, + ): TurnRecap? { + val watch = watches[sessionKey] + val rowEndedAt = row?.endedAt + if (indicatorVisible) { + if (watch == null || !watch.watching) { + watches[sessionKey] = + TurnRecapWatch( + watching = true, + baselineKnown = row != null, + baselineEndedAt = rowEndedAt, + absorbedTerminal = false, + settleStartedAt = null, + pendingTerminal = null, + pendingTerminalEndedAt = null, + settled = null, + settledTranscriptItemId = null, + tracksTranscript = transcript != null, + ) + } else if (!watch.baselineKnown) { + if (row != null) { + watch.baselineKnown = true + watch.baselineEndedAt = rowEndedAt + } + } else if (rowEndedAt != null && rowEndedAt != watch.baselineEndedAt) { + watch.baselineEndedAt = rowEndedAt + watch.absorbedTerminal = true + } + return null + } + if (watch == null) return null + watch.watching = false + watch.settled?.let { settled -> + if ( + !watch.tracksTranscript || + transcript?.sessionKey != sessionKey || + watch.settledTranscriptItemId == transcript.newestItemId + ) { + return settled + } + // A newer transcript turn has replaced the content this recap summarized. Session rows do + // not expose enough run identity to reposition it safely, so discard it. + watches.remove(sessionKey) + return null + } + if (watch.absorbedTerminal || !watch.baselineKnown) { + // Attribution is ambiguous, so fail quiet instead of freezing another run's numbers. + watches.remove(sessionKey) + return null + } + if (watch.settleStartedAt == null) { + watch.settleStartedAt = nowMs() + } else if (nowMs() - checkNotNull(watch.settleStartedAt) > TURN_RECAP_SETTLE_WINDOW_MS) { + watches.remove(sessionKey) + return null + } + val isStale = + rowEndedAt == null || + (watch.baselineEndedAt != null && rowEndedAt <= checkNotNull(watch.baselineEndedAt)) + if (isStale) { + // No watched terminal yet. Stamps never regress, so <= stays stale until the bounded expiry. + return null + } + // Any fresh non-success concludes the watch. Waiting past it could attach a later unrelated + // success to this turn. + val runtimeMs = row.runtimeMs + if (row.status != "done" || runtimeMs == null) { + watches.remove(sessionKey) + return null + } + val terminal = TurnRecap(runtimeMs = runtimeMs, outputTokens = row.outputTokens) + if (watch.pendingTerminalEndedAt != null && watch.pendingTerminalEndedAt != rowEndedAt) { + // Session rows have no run id. Once another terminal replaces the candidate, attribution is + // gone even if a history refresh completes inside the settlement window. + watches.remove(sessionKey) + return null + } + if ( + watch.tracksTranscript && + ( + transcript?.sessionKey != sessionKey || + transcript.completedEndedAt != rowEndedAt + ) + ) { + // The terminal session row can arrive before the terminal-triggered chat.history snapshot. + // Keep waiting so its final item becomes the recap anchor, not an intermediate tool row. + watch.pendingTerminal = terminal + watch.pendingTerminalEndedAt = rowEndedAt + return null + } + if (watch.tracksTranscript && transcript?.newestItemId != transcript?.completedNewestItemId) { + // Newer transcript content already superseded the completed snapshot before this pane could + // settle it, so there is no safe recap attribution left to display. + watches.remove(sessionKey) + return null + } + watches.remove(sessionKey) + val settled = watch.pendingTerminal ?: terminal + watches[sessionKey] = + watch.copy( + settled = settled, + settledTranscriptItemId = transcript?.completedNewestItemId, + ) + return settled + } +} + +@Composable +internal fun ChatTurnRecapRow(recap: TurnRecap) { + val duration = formatLocalizedChatDurationCompact(recap.runtimeMs.coerceAtLeast(1_000L)) + val tokens = + recap.outputTokens?.let { count -> + val format = turnRecapTokenFormat(count) + if (format.singular) { + nativeStringResource("1 token") + } else { + nativeStringResource("\$count tokens", format.count) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + WorkingClawIcon(runKey = "turn-recap", color = ClawTheme.colors.primary, parked = true) + Text( + text = nativeStringResource("Done in \$duration", duration), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + tokens?.let { + Text(text = nativeStringResource("·"), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle) + Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } + } +} + +internal fun turnRecapTokenFormat(count: Long): TurnRecapTokenFormat = TurnRecapTokenFormat(singular = count == 1L, count = formatCompactTokenCount(count)) + +internal fun formatCompactTokenCount(count: Long): String { + fun decimal(value: Double): String = String.format(Locale.US, "%.1f", value).removeSuffix(".0") + return when { + count >= 1_000_000L -> "${decimal(count / 1_000_000.0)}M" + count >= 1_000L -> { + val thousands = decimal(count / 1_000.0) + if (thousands == "1000") "${decimal(count / 1_000_000.0)}M" else "${thousands}k" + } + else -> count.toString() + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt new file mode 100644 index 000000000000..622dfc04e2da --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt @@ -0,0 +1,412 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.nativeStringResource +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.provider.Settings +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlin.math.roundToLong +import kotlin.random.Random + +private const val DEFAULT_CLAW_CYCLE_MS = 2_400L +private const val FLURRY_CLAW_CYCLE_MS = 1_300L +private const val SPIN_CLAW_CYCLE_MS = 3_600L +internal const val WORKING_PHRASE_SHOW_AFTER_MS = 30_000L +internal const val WORKING_PHRASE_ROTATE_EVERY_MS = 45_000L + +private val clawBodyPath by lazy { + PathParser() + .parsePathString( + "M9.6 9.2 A5.6 5.6 0 1 0 9.6 20.4 A5.6 5.6 0 0 0 9.6 9.2 Z " + + "M10 20 C14 20.9 17.9 19.5 20.1 16.1 C20.6 15.4 20.05 14.5 19.25 14.65 " + + "C17.1 15 14.9 14.4 13.2 13 L10.6 16 Z", + ).toPath() +} +private val clawJawPath by lazy { + PathParser() + .parsePathString( + "M6 10.6 C6.6 4.4 12.4 0.8 17.6 2.8 C20.8 4 22.8 6.8 23 9.8 " + + "C23.07 10.9 21.9 11.4 21.1 10.7 C19.4 9.2 16.9 8.7 14.7 9.5 " + + "C13.4 10 12.3 10.9 11.6 12.1 L7.2 12.4 Z", + ).toPath() +} + +internal enum class WorkingClawStance { + Default, + Southpaw, + Flurry, + Spin, + Shadowbox, + Backflip, +} + +private val stanceWeights = + listOf( + WorkingClawStance.Default to 66, + WorkingClawStance.Southpaw to 20, + WorkingClawStance.Flurry to 5, + WorkingClawStance.Spin to 4, + WorkingClawStance.Shadowbox to 3, + WorkingClawStance.Backflip to 2, + ) +private val processStanceSalt = Random.nextInt() + +internal fun workingClawHash(value: String): Int { + var hash = 0x811c9dc5.toInt() + value.forEach { character -> + hash = (hash xor character.code) * 0x01000193 + } + return hash +} + +internal fun pickWorkingClawStance( + runKey: String, + salt: Int = processStanceSalt, +): WorkingClawStance { + var roll = ((workingClawHash(runKey) xor salt).toUInt().toLong() % 1_000L).toInt() + stanceWeights.forEach { (stance, weight) -> + val buckets = weight * 10 + if (roll < buckets) return stance + roll -= buckets + } + return WorkingClawStance.Default +} + +private data class ClawKeyframe( + val phase: Float, + val value: Float, +) + +private val easeOut = CubicBezierEasing(0f, 0f, 0.58f, 1f) +private val flexFrames = + frames(0f to 0f, 0.06f to 0f, 0.10f to -4f, 0.16f to 3f, 0.22f to -4f, 0.26f to -4f, 0.32f to 3f, 0.42f to 0f, 1f to 0f) +private val snipFrames = + frames(0f to -10f, 0.06f to -10f, 0.10f to -26f, 0.16f to 4f, 0.22f to -24f, 0.26f to -24f, 0.32f to 4f, 0.42f to -10f, 1f to -10f) +private val comboXFrames = + frames(0f to 0f, 0.08f to 0f, 0.12f to -2f, 0.16f to 5f, 0.22f to -2f, 0.26f to -2f, 0.30f to 5f, 0.38f to 0f, 0.46f to -3f, 0.52f to 8f, 0.62f to 0f, 1f to 0f) +private val comboRotationFrames = + frames(0f to 0f, 0.38f to 0f, 0.46f to -6f, 0.52f to 4f, 0.62f to 0f, 1f to 0f) +private val comboJawFrames = + frames(0f to -10f, 0.08f to -10f, 0.12f to -16f, 0.16f to 4f, 0.22f to -16f, 0.26f to -16f, 0.30f to 4f, 0.38f to -10f, 0.46f to -18f, 0.52f to 6f, 0.62f to -10f, 1f to -10f) +private val backflipRotationFrames = + frames(0f to 0f, 0.55f to 0f, 0.62f to -120f, 0.70f to -240f, 0.78f to -360f, 1f to -360f) +private val backflipYFrames = frames(0f to 0f, 0.55f to 0f, 0.62f to -3f, 0.70f to -3f, 0.78f to 0f, 1f to 0f) +private val powAlphaFrames = frames(0f to 0f, 0.46f to 0f, 0.52f to 1f, 0.58f to 0f, 1f to 0f) +private val powScaleFrames = frames(0f to 0.4f, 0.46f to 0.4f, 0.52f to 1.2f, 0.58f to 1.5f, 1f to 1.5f) + +private fun frames(vararg values: Pair): List = values.map { (phase, value) -> ClawKeyframe(phase, value) } + +private fun sampleFrames( + keyframes: List, + phase: Float, +): Float { + val bounded = phase.coerceIn(0f, 1f) + val nextIndex = keyframes.indexOfFirst { it.phase >= bounded }.takeIf { it >= 0 } ?: keyframes.lastIndex + if (nextIndex == 0) return keyframes.first().value + val previous = keyframes[nextIndex - 1] + val next = keyframes[nextIndex] + if (next.phase == previous.phase) return next.value + val progress = easeOut.transform((bounded - previous.phase) / (next.phase - previous.phase)) + return previous.value + (next.value - previous.value) * progress +} + +private data class WorkingClawPose( + val rotationZ: Float = 0f, + val rotationY: Float = 0f, + val translationXDp: Float = 0f, + val translationYDp: Float = 0f, + val jawRotation: Float = -10f, + val powAlpha: Float = 0f, + val powScale: Float = 0.4f, +) + +private fun workingClawPose( + stance: WorkingClawStance, + phase: Float, +): WorkingClawPose = + when (stance) { + WorkingClawStance.Spin -> + WorkingClawPose( + rotationY = phase * 360f, + jawRotation = sampleFrames(snipFrames, phase), + ) + WorkingClawStance.Shadowbox -> + WorkingClawPose( + rotationZ = sampleFrames(comboRotationFrames, phase), + translationXDp = sampleFrames(comboXFrames, phase), + jawRotation = sampleFrames(comboJawFrames, phase), + powAlpha = sampleFrames(powAlphaFrames, phase), + powScale = sampleFrames(powScaleFrames, phase), + ) + WorkingClawStance.Backflip -> + WorkingClawPose( + rotationZ = sampleFrames(backflipRotationFrames, phase), + translationYDp = sampleFrames(backflipYFrames, phase), + jawRotation = sampleFrames(snipFrames, phase), + ) + WorkingClawStance.Default, + WorkingClawStance.Southpaw, + WorkingClawStance.Flurry, + -> + WorkingClawPose( + rotationZ = sampleFrames(flexFrames, phase), + jawRotation = sampleFrames(snipFrames, phase), + ) + } + +@Composable +internal fun WorkingClawIcon( + runKey: String, + color: Color, + modifier: Modifier = Modifier, + parked: Boolean = false, +) { + val stance = remember(runKey, parked) { if (parked) WorkingClawStance.Default else pickWorkingClawStance(runKey) } + val density = LocalDensity.current + val animationsEnabled = rememberSystemAnimationsEnabled() && !parked + val cycleMs = + when (stance) { + WorkingClawStance.Flurry -> FLURRY_CLAW_CYCLE_MS + WorkingClawStance.Spin -> SPIN_CLAW_CYCLE_MS + else -> DEFAULT_CLAW_CYCLE_MS + } + var phase by remember(runKey) { mutableFloatStateOf(0f) } + LaunchedEffect(animationsEnabled, runKey, cycleMs) { + if (!animationsEnabled) { + phase = 0f + return@LaunchedEffect + } + var bornNanos = Long.MIN_VALUE + while (true) { + withFrameNanos { frameNanos -> + if (bornNanos == Long.MIN_VALUE) bornNanos = frameNanos + val cycleNanos = cycleMs * 1_000_000L + phase = ((frameNanos - bornNanos) % cycleNanos).toFloat() / cycleNanos.toFloat() + } + } + } + val pose = + when { + parked -> WorkingClawPose(rotationZ = 8f, jawRotation = -4f) + animationsEnabled -> workingClawPose(stance, phase) + else -> WorkingClawPose() + } + val iconWidth = if (stance == WorkingClawStance.Shadowbox) 30.dp else 18.dp + Box(modifier = modifier.size(width = iconWidth, height = 20.dp), contentAlignment = Alignment.CenterStart) { + Canvas( + modifier = + Modifier + .size(18.dp) + .graphicsLayer { + rotationZ = pose.rotationZ + rotationY = pose.rotationY + translationX = with(density) { pose.translationXDp.dp.toPx() } + translationY = with(density) { pose.translationYDp.dp.toPx() } + scaleX = if (stance == WorkingClawStance.Southpaw) -1f else 1f + cameraDistance = with(density) { 60.dp.toPx() } + }, + ) { + val scale = size.minDimension / 24f + withTransform({ scale(scale, scale, pivot = Offset.Zero) }) { + drawPath(path = clawBodyPath, color = color) + withTransform({ rotate(pose.jawRotation, pivot = Offset(8.6f, 11f)) }) { + drawPath(path = clawJawPath, color = color) + } + } + } + if (stance == WorkingClawStance.Shadowbox && animationsEnabled) { + Text( + text = nativeStringResource("✦"), + color = color, + fontSize = 11.sp, + lineHeight = 11.sp, + modifier = + Modifier + .offset(x = 19.dp, y = (-4).dp) + .graphicsLayer { + alpha = pose.powAlpha + scaleX = pose.powScale + scaleY = pose.powScale + }, + ) + } + } +} + +@Composable +private fun rememberSystemAnimationsEnabled(): Boolean { + val context = LocalContext.current + val resolver = context.contentResolver + var scale by remember(resolver) { mutableFloatStateOf(readAnimatorDurationScale(resolver)) } + DisposableEffect(resolver) { + val observer = + object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + scale = readAnimatorDurationScale(resolver) + } + } + resolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, observer) + scale = readAnimatorDurationScale(resolver) + onDispose { resolver.unregisterContentObserver(observer) } + } + return scale > 0f +} + +private fun readAnimatorDurationScale(resolver: android.content.ContentResolver): Float = Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + +@Composable +internal fun rememberWorkingElapsedMs(observedAtElapsedMs: Long): Long { + var nowElapsedMs by remember(observedAtElapsedMs) { mutableLongStateOf(SystemClock.elapsedRealtime()) } + LaunchedEffect(observedAtElapsedMs) { + while (true) { + nowElapsedMs = SystemClock.elapsedRealtime() + delay(1_000L) + } + } + return (nowElapsedMs - observedAtElapsedMs).coerceAtLeast(0L) +} + +internal enum class ChatDurationUnit { + Day, + Hour, + Minute, + Second, +} + +internal fun formatChatDurationCompact( + durationMs: Long, + formatPart: (Long, ChatDurationUnit) -> String = ::formatEnglishChatDurationPart, +): String { + var remaining = (durationMs.coerceAtLeast(1_000L) / 1_000.0).roundToLong().coerceAtLeast(1L) + val units = + listOf( + 86_400L to ChatDurationUnit.Day, + 3_600L to ChatDurationUnit.Hour, + 60L to ChatDurationUnit.Minute, + 1L to ChatDurationUnit.Second, + ) + val parts = mutableListOf() + units.forEach { (seconds, unit) -> + if (parts.size == 2) return@forEach + val count = remaining / seconds + if (count > 0L) { + parts += formatPart(count, unit) + remaining %= seconds + } + } + return parts.joinToString(" ") +} + +private fun formatEnglishChatDurationPart( + count: Long, + unit: ChatDurationUnit, +): String = + when (unit) { + ChatDurationUnit.Day -> "${count}d" + ChatDurationUnit.Hour -> "${count}h" + ChatDurationUnit.Minute -> "${count}m" + ChatDurationUnit.Second -> "${count}s" + } + +internal fun formatLocalizedChatDurationCompact(durationMs: Long): String = + formatChatDurationCompact(durationMs) { count, unit -> + when (unit) { + ChatDurationUnit.Day -> { + val days = count + nativeString("\${days}d", days) + } + ChatDurationUnit.Hour -> { + val hours = count + nativeString("\${hours}h", hours) + } + ChatDurationUnit.Minute -> { + val minutes = count + nativeString("\${minutes}m", minutes) + } + ChatDurationUnit.Second -> { + val seconds = count + nativeString("\${seconds}s", seconds) + } + } + } + +internal fun workingPhraseIndex( + seed: String, + bucket: Long, +): Int { + val length = WORKING_PHRASE_COUNT + val offset = workingClawHash("$seed:offset").toUInt().toLong() % length + val stride = 1L + (workingClawHash("$seed:stride").toUInt().toLong() % (length - 1)) + return ((offset + (bucket % length) * stride) % length).toInt() +} + +internal fun workingPhraseIndexForElapsed( + seed: String, + elapsedMs: Long, +): Int? { + if (elapsedMs < WORKING_PHRASE_SHOW_AFTER_MS) return null + val bucket = (elapsedMs - WORKING_PHRASE_SHOW_AFTER_MS) / WORKING_PHRASE_ROTATE_EVERY_MS + return workingPhraseIndex(seed, bucket) +} + +@Composable +internal fun workingPhraseText( + seed: String, + elapsedMs: Long, +): String? = workingPhraseIndexForElapsed(seed, elapsedMs)?.let { localizedWorkingPhrase(it) + "…" } + +private const val WORKING_PHRASE_COUNT = 19 + +@Composable +private fun localizedWorkingPhrase(index: Int): String = + when (index) { + 0 -> nativeStringResource("Shelling") + 1 -> nativeStringResource("Scuttling") + 2 -> nativeStringResource("Clawing") + 3 -> nativeStringResource("Pinching") + 4 -> nativeStringResource("Molting") + 5 -> nativeStringResource("Bubbling") + 6 -> nativeStringResource("Tiding") + 7 -> nativeStringResource("Reefing") + 8 -> nativeStringResource("Cracking") + 9 -> nativeStringResource("Sifting") + 10 -> nativeStringResource("Brining") + 11 -> nativeStringResource("Nautiling") + 12 -> nativeStringResource("Krilling") + 13 -> nativeStringResource("Barnacling") + 14 -> nativeStringResource("Lobstering") + 15 -> nativeStringResource("Tidepooling") + 16 -> nativeStringResource("Pearling") + 17 -> nativeStringResource("Snapping") + 18 -> nativeStringResource("Surfacing") + else -> error("working phrase index out of range: $index") + } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt index 86b03c118995..da20ddf69356 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt @@ -179,4 +179,34 @@ class ChatControllerSessionPolicyTest { assertEquals(10L, merged.lastReadAt) assertEquals(20L, merged.lastActivityAt) } + + @Test + fun sessionMergeReplacesRunMetadataAsOneSnapshot() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + status = "done", + startedAt = 100L, + endedAt = 200L, + runtimeMs = 100L, + outputTokens = 12L, + ) + val running = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 2L, + status = "running", + startedAt = 300L, + hasRunMetadata = true, + ) + + val merged = mergeChatSessionEntry(existing, running) + + assertEquals("running", merged.status) + assertEquals(300L, merged.startedAt) + assertEquals(null, merged.endedAt) + assertEquals(null, merged.runtimeMs) + assertEquals(null, merged.outputTokens) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt index f45e3977d73c..2855f6278471 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt @@ -24,7 +24,7 @@ class ClientDatabasesTest { val databases = open(names, registeredGatewayIds = setOf("gateway-test")) try { assertEquals( - 1, + 2, databases .gatewayCacheDatabase() .openHelper.writableDatabase.version, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt index 4b8788f8d409..f1362a3476d0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt @@ -148,6 +148,37 @@ class RoomChatTranscriptCacheTest { assertEquals(emptyList(), store.loadTranscript("gateway-a", "main", "session-55")) } + @Test + fun sessionRoundTripKeepsRunMetadata() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = + listOf( + ChatSessionEntry( + key = "main", + updatedAtMs = 20L, + status = "done", + startedAt = 1_000L, + endedAt = 5_000L, + runtimeMs = 4_000L, + outputTokens = 485L, + ), + ), + ) + + val loaded = store.loadSessions("gateway-a", "main").single() + + assertEquals("done", loaded.status) + assertEquals(1_000L, loaded.startedAt) + assertEquals(5_000L, loaded.endedAt) + assertEquals(4_000L, loaded.runtimeMs) + assertEquals(485L, loaded.outputTokens) + assertTrue(loaded.hasRunMetadata) + } + @Test fun transcriptForSessionOutsideFullCachedListSurvivesEviction() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt index 3bd4763c9c6c..5ad37692f063 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt @@ -76,6 +76,29 @@ class ChatTimelineTest { assertEquals("user-1", timeline.latestUserMessageId) } + @Test + fun finishedTurnRecapUsesNewestSlotWithoutChangingReaderAnchorRow() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done") + val timeline = + buildChatTimeline( + messages = listOf(user, assistant), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + val withRecap = timeline.withTurnRecap(TurnRecap(runtimeMs = 2_000L, outputTokens = 10L)) + + assertEquals( + listOf("turn-recap", "message:assistant-1", "message:user-1"), + withRecap.items.map(::chatTimelineItemKey), + ) + assertEquals(0, withRecap.latestContentIndex) + assertEquals(2, withRecap.readAnchorIndex) + assertEquals("user-1", withRecap.latestUserMessageId) + } + @Test fun emptyTimelineHasNoScrollTarget() { val timeline = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt new file mode 100644 index 000000000000..8558b2d4750f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt @@ -0,0 +1,311 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatTurnRecapResolverTest { + private val session = "agent:main:main" + private val previousEndedAt = 900_000L + private val runEndedAt = 1_000_000L + + private fun row( + status: String, + endedAt: Long? = null, + runtimeMs: Long? = null, + outputTokens: Long? = null, + ): ChatSessionEntry = + ChatSessionEntry( + key = session, + updatedAtMs = endedAt, + status = status, + endedAt = endedAt, + runtimeMs = runtimeMs, + outputTokens = outputTokens, + hasRunMetadata = true, + ) + + private fun done( + endedAt: Long, + runtimeMs: Long? = 51_000L, + outputTokens: Long? = null, + ): ChatSessionEntry = row("done", endedAt, runtimeMs, outputTokens) + + @Test + fun resolvesOnceAFreshTerminalStampLandsThenSticks() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + + val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L) + val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L) + assertEquals(expected, resolver.resolve(session, false, terminal)) + assertEquals(expected, resolver.resolve(session, false, terminal)) + } + + @Test + fun rejectsPreviousTurnAndRegressedStamps() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt - 5_000L))) + } + + @Test + fun expiresAnUnresolvedWatchInsteadOfMatchingALaterRun() { + var nowMs = 1_000_000L + val resolver = TurnRecapResolver { nowMs } + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + + nowMs += 31_000L + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun freshDoneWithoutRuntimeConsumesTheWatch() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = null))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun clearedRunStartBaselineIsStaleFree() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, row(status = "running"))) + assertEquals( + TurnRecap(runtimeMs = 2_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 2_000L)), + ) + } + + @Test + fun neverResolvesWithoutWatchingAnIndicator() { + assertNull(TurnRecapResolver().resolve(session, false, done(runEndedAt))) + } + + @Test + fun consumesAWatchWhoseBaselineRowWasNeverObserved() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, null)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun adoptsTheFirstRowObservedMidWatchAsBaseline() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, null)) + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertEquals( + TurnRecap(runtimeMs = 6_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 6_000L)), + ) + } + + @Test + fun forfeitsWhenATerminalStampChangesMidWatch() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, row(status = "running"))) + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = 4_000L))) + } + + @Test + fun forfeitsAFailedTurnWhoseTerminalRacedTheIndicator() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, row(status = "running")) + resolver.resolve(session, true, row(status = "failed", endedAt = runEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 60_000L))) + } + + @Test + fun freezesTheFirstRecapAgainstLaterUnwatchedTerminals() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + val settled = resolver.resolve(session, false, done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L)) + + assertNotNull(settled) + assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 90_000L, runtimeMs = 7_000L, outputTokens = 42L))) + } + + @Test + fun settledRecapSticksOnlyWhileItsTranscriptAnchorIsNewest() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L) + val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L) + + assertNull( + resolver.resolve(session, false, terminal, transcript("assistant-tool")), + ) + assertEquals( + expected, + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + assertEquals( + expected, + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + assertNull( + resolver.resolve( + session, + false, + terminal, + transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L), + ), + ) + } + + @Test + fun emptyTranscriptWaitsForTheCompletedItemBeforeSettling() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript(null)) + val terminal = done(runEndedAt, runtimeMs = 2_000L) + + assertNull(resolver.resolve(session, false, terminal, transcript(null))) + assertEquals( + TurnRecap(runtimeMs = 2_000L, outputTokens = null), + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + } + + @Test + fun newerContentAlreadyPresentWhenHistoryCompletesDropsTheRecap() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + val terminal = done(runEndedAt, runtimeMs = 2_000L) + + assertNull( + resolver.resolve( + session, + false, + terminal, + transcript( + newestItemId = "user-2", + completedEndedAt = runEndedAt, + completedNewestItemId = "assistant-1", + ), + ), + ) + } + + @Test + fun changedTerminalWhileWaitingForHistoryDestroysAttribution() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("user-1")), + ) + + assertNull( + resolver.resolve( + session, + false, + done(runEndedAt + 1_000L, runtimeMs = 9_000L), + transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L), + ), + ) + } + + @Test + fun terminalWaitingForHistoryStillExpires() { + var nowMs = 1_000_000L + val resolver = TurnRecapResolver { nowMs } + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("user-1")), + ) + + nowMs += TURN_RECAP_SETTLE_WINDOW_MS + 1L + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + } + + @Test + fun hidesTheRecapAsSoonAsTheNextIndicatorAppears() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNotNull(resolver.resolve(session, false, done(runEndedAt))) + + assertNull(resolver.resolve(session, true, done(runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun ignoresAStaleFailedRowThenResolvesAFreshDone() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, row(status = "failed", endedAt = previousEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = previousEndedAt))) + assertEquals( + TurnRecap(runtimeMs = 3_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 3_000L)), + ) + } + + @Test + fun freshFailedRowConsumesTheWatch() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun leavingTheSessionAbandonsUnsettledButKeepsSettled() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + resolver.abandonActiveWatch(session) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + + resolver.resolve(session, true, done(previousEndedAt)) + val settled = resolver.resolve(session, false, done(runEndedAt)) + resolver.abandonActiveWatch(session) + assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun everyNonDoneTerminalConsumesQuietly() { + listOf("failed", "killed", "timeout").forEach { status -> + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, row(status = status, endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + } + + @Test + fun formatsZeroOneAndCompactTokenCounts() { + assertEquals(TurnRecapTokenFormat(singular = false, count = "0"), turnRecapTokenFormat(0L)) + assertEquals(TurnRecapTokenFormat(singular = true, count = "1"), turnRecapTokenFormat(1L)) + assertEquals("1.2k", formatCompactTokenCount(1_234L)) + assertEquals("1M", formatCompactTokenCount(999_999L)) + } + + private fun transcript( + newestItemId: String?, + completedEndedAt: Long? = null, + transcriptSessionKey: String? = session, + completedNewestItemId: String? = newestItemId.takeIf { completedEndedAt != null }, + ): TurnRecapTranscriptState = + TurnRecapTranscriptState( + sessionKey = transcriptSessionKey, + newestItemId = newestItemId, + completedEndedAt = completedEndedAt, + completedNewestItemId = completedNewestItemId, + ) +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt new file mode 100644 index 000000000000..25c6d00eb304 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt @@ -0,0 +1,128 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatWorkingIndicatorTest { + @Test + fun stanceSelectionIsDeterministicForRunAndSalt() { + val first = pickWorkingClawStance("run-123", salt = 42) + + repeat(20) { + assertEquals(first, pickWorkingClawStance("run-123", salt = 42)) + } + } + + @Test + fun stanceSelectionUsesConfiguredWeights() { + val key = "run-weight-check" + val hash = workingClawHash(key) + val counts = mutableMapOf() + + repeat(1_000) { bucket -> + val stance = pickWorkingClawStance(key, salt = hash xor bucket) + counts[stance] = counts.getOrDefault(stance, 0) + 1 + } + + assertEquals(660, counts[WorkingClawStance.Default]) + assertEquals(200, counts[WorkingClawStance.Southpaw]) + assertEquals(50, counts[WorkingClawStance.Flurry]) + assertEquals(40, counts[WorkingClawStance.Spin]) + assertEquals(30, counts[WorkingClawStance.Shadowbox]) + assertEquals(20, counts[WorkingClawStance.Backflip]) + } + + @Test + fun phraseStrideVisitsEveryPhraseWithoutAdjacentRepeats() { + val indexes = (0L until 19L).map { bucket -> workingPhraseIndex("run-phrase", bucket) } + + assertEquals(19, indexes.toSet().size) + indexes.zipWithNext().forEach { (previous, next) -> assertNotEquals(previous, next) } + assertNotEquals(indexes.last(), workingPhraseIndex("run-phrase", 19L)) + } + + @Test + fun phraseWaitsThirtySecondsAndRotatesEveryFortyFive() { + assertEquals(null, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS - 1L)) + val first = workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS) + assertEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 44_999L)) + assertNotEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 45_000L)) + } + + @Test + fun compactDurationClampsToOneSecond() { + assertEquals("1s", formatChatDurationCompact(0L)) + assertEquals("1m 30s", formatChatDurationCompact(90_000L)) + assertTrue(formatChatDurationCompact(3_600_000L).startsWith("1h")) + } + + @Test + fun provisionalRunAdoptsAuthoritativeIdentityWithoutChangingLocalStart() { + val tracker = ChatWorkingRunTracker("agent:main:main") + val provisional = requireNotNull(tracker.resolve(indicatorVisible = true, session = null, nowElapsedMs = 5_000L)) + + val authoritative = + requireNotNull( + tracker.resolve( + indicatorVisible = true, + session = + ChatSessionEntry( + key = "agent:main:main", + updatedAtMs = 6_000L, + status = "running", + startedAt = 4_000L, + activeRunIds = listOf("run-1"), + ), + nowElapsedMs = 6_000L, + ), + ) + + assertEquals(provisional.key, authoritative.key) + assertEquals(5_000L, authoritative.observedAtElapsedMs) + assertEquals("run-1", authoritative.authoritativeRunId) + assertEquals(4_000L, authoritative.authoritativeStartedAtMs) + } + + @Test + fun authoritativeReplacementGetsANewRunIdentity() { + val tracker = ChatWorkingRunTracker("agent:main:main") + val first = + requireNotNull( + tracker.resolve( + indicatorVisible = true, + session = + ChatSessionEntry( + key = "agent:main:main", + updatedAtMs = 1L, + status = "running", + startedAt = 1_000L, + activeRunIds = listOf("run-1"), + ), + nowElapsedMs = 7_000L, + ), + ) + val replacement = + requireNotNull( + tracker.resolve( + indicatorVisible = true, + session = + ChatSessionEntry( + key = "agent:main:main", + updatedAtMs = 2L, + status = "running", + startedAt = 2_000L, + activeRunIds = listOf("run-2"), + ), + nowElapsedMs = 9_000L, + ), + ) + + assertEquals("run-1", first.key) + assertEquals("run-2", replacement.key) + assertEquals(9_000L, replacement.observedAtElapsedMs) + assertEquals(2_000L, replacement.authoritativeStartedAtMs) + } +}