mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(apple): show live subagent activity in chat (#121815)
* feat(apple): show live subagent activity in chat * chore(apple): refresh native localization inventory
This commit is contained in:
committed by
GitHub
parent
d7ea18e600
commit
4b46a14a54
@@ -19083,7 +19083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 45,
|
||||
"line": 46,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Background task",
|
||||
"surface": "apple",
|
||||
@@ -19091,7 +19091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 54,
|
||||
"line": 55,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Queued",
|
||||
"surface": "apple",
|
||||
@@ -19099,7 +19099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 56,
|
||||
"line": 57,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Completed",
|
||||
"surface": "apple",
|
||||
@@ -19107,7 +19107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 57,
|
||||
"line": 58,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Failed",
|
||||
"surface": "apple",
|
||||
@@ -19115,7 +19115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 63,
|
||||
"line": 64,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Subagent",
|
||||
"surface": "apple",
|
||||
@@ -19123,7 +19123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 64,
|
||||
"line": 65,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Cron",
|
||||
"surface": "apple",
|
||||
@@ -19131,7 +19131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 67,
|
||||
"line": 68,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Task",
|
||||
"surface": "apple",
|
||||
@@ -19139,7 +19139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 169,
|
||||
"line": 170,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Loading background tasks…",
|
||||
"surface": "apple",
|
||||
@@ -19147,7 +19147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 181,
|
||||
"line": 182,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Tasks for this agent will appear here.",
|
||||
"surface": "apple",
|
||||
@@ -19155,7 +19155,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 192,
|
||||
"line": 193,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "No running tasks",
|
||||
"surface": "apple",
|
||||
@@ -19163,7 +19163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 201,
|
||||
"line": 202,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Running",
|
||||
"surface": "apple",
|
||||
@@ -19171,7 +19171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 205,
|
||||
"line": 206,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "No finished tasks",
|
||||
"surface": "apple",
|
||||
@@ -19179,7 +19179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 214,
|
||||
"line": 215,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Finished",
|
||||
"surface": "apple",
|
||||
@@ -19187,7 +19187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 221,
|
||||
"line": 222,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Background Tasks",
|
||||
"surface": "apple",
|
||||
@@ -19195,7 +19195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 229,
|
||||
"line": 230,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -19203,7 +19203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 336,
|
||||
"line": 337,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Prompt",
|
||||
"surface": "apple",
|
||||
@@ -19211,7 +19211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 338,
|
||||
"line": 339,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Loading…",
|
||||
"surface": "apple",
|
||||
@@ -19219,7 +19219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 339,
|
||||
"line": 340,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Prompt unavailable.",
|
||||
"surface": "apple",
|
||||
@@ -19227,7 +19227,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 341,
|
||||
"line": 342,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Output",
|
||||
"surface": "apple",
|
||||
@@ -19235,7 +19235,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 342,
|
||||
"line": 343,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "No output yet.",
|
||||
"surface": "apple",
|
||||
@@ -19243,7 +19243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 352,
|
||||
"line": 353,
|
||||
"path": "apps/ios/Sources/Design/BackgroundTasksScreen.swift",
|
||||
"source": "Task Details",
|
||||
"surface": "apple",
|
||||
@@ -39491,7 +39491,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 936,
|
||||
"line": 943,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode uses the primary Gateway window",
|
||||
"surface": "apple",
|
||||
@@ -39499,7 +39499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 938,
|
||||
"line": 945,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode off",
|
||||
"surface": "apple",
|
||||
@@ -39507,7 +39507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 940,
|
||||
"line": 947,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode paused",
|
||||
"surface": "apple",
|
||||
@@ -39515,7 +39515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 943,
|
||||
"line": 950,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode ready",
|
||||
"surface": "apple",
|
||||
@@ -39523,7 +39523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 944,
|
||||
"line": 951,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Listening",
|
||||
"surface": "apple",
|
||||
@@ -39531,7 +39531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 945,
|
||||
"line": 952,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Thinking",
|
||||
"surface": "apple",
|
||||
@@ -39539,7 +39539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 946,
|
||||
"line": 953,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Speaking",
|
||||
"surface": "apple",
|
||||
@@ -39547,7 +39547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 950,
|
||||
"line": 957,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "What would you like to work on?",
|
||||
"surface": "apple",
|
||||
@@ -39555,7 +39555,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 954,
|
||||
"line": 961,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Check OpenClaw status",
|
||||
"surface": "apple",
|
||||
@@ -39563,7 +39563,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 955,
|
||||
"line": 962,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
|
||||
"surface": "apple",
|
||||
@@ -39571,7 +39571,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 958,
|
||||
"line": 965,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "What can you do?",
|
||||
"surface": "apple",
|
||||
@@ -39579,7 +39579,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 959,
|
||||
"line": 966,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Show me what you can help with on this Mac right now.",
|
||||
"surface": "apple",
|
||||
@@ -39587,7 +39587,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 962,
|
||||
"line": 969,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Catch me up",
|
||||
"surface": "apple",
|
||||
@@ -39595,7 +39595,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 963,
|
||||
"line": 970,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Summarize what happened in my threads since yesterday.",
|
||||
"surface": "apple",
|
||||
@@ -40499,7 +40499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 701,
|
||||
"line": 702,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Voice note",
|
||||
"surface": "apple",
|
||||
@@ -40507,7 +40507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 742,
|
||||
"line": 743,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Attachment",
|
||||
"surface": "apple",
|
||||
@@ -40515,7 +40515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 782,
|
||||
"line": 783,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Writing",
|
||||
"surface": "apple",
|
||||
@@ -40523,7 +40523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 821,
|
||||
"line": 822,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Preparing audio…",
|
||||
"surface": "apple",
|
||||
@@ -40531,7 +40531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 824,
|
||||
"line": 825,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Speaking…",
|
||||
"surface": "apple",
|
||||
@@ -40539,7 +40539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 832,
|
||||
"line": 833,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Preparing audio, tap to cancel",
|
||||
"surface": "apple",
|
||||
@@ -40547,7 +40547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 833,
|
||||
"line": 834,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Speaking, tap to stop",
|
||||
"surface": "apple",
|
||||
@@ -41617,6 +41617,14 @@
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b09b91392835fe58"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 14,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivityViews.swift",
|
||||
"source": "+%1$lld more working",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.d5a544f5ba642c60"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 49,
|
||||
@@ -41643,7 +41651,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 83,
|
||||
"line": 86,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
|
||||
"source": "Running",
|
||||
"surface": "apple",
|
||||
@@ -41651,7 +41659,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 133,
|
||||
"line": 140,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
|
||||
"source": "Collapse tool result",
|
||||
"surface": "apple",
|
||||
@@ -41659,7 +41667,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 133,
|
||||
"line": 140,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
|
||||
"source": "Expand tool result",
|
||||
"surface": "apple",
|
||||
@@ -41667,7 +41675,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 169,
|
||||
"line": 176,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
|
||||
"source": "Show less",
|
||||
"surface": "apple",
|
||||
@@ -41675,7 +41683,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 171,
|
||||
"line": 178,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
|
||||
"source": "Show all %lld lines",
|
||||
"surface": "apple",
|
||||
@@ -41731,7 +41739,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 585,
|
||||
"line": 592,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Retry Send",
|
||||
"surface": "apple",
|
||||
@@ -41739,7 +41747,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 599,
|
||||
"line": 606,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Delete",
|
||||
"surface": "apple",
|
||||
@@ -41747,7 +41755,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 635,
|
||||
"line": 642,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Stop Listening",
|
||||
"surface": "apple",
|
||||
@@ -41755,7 +41763,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 638,
|
||||
"line": 645,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Listen",
|
||||
"surface": "apple",
|
||||
@@ -41763,7 +41771,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 735,
|
||||
"line": 742,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Jump to latest reply",
|
||||
"surface": "apple",
|
||||
@@ -41771,7 +41779,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 755,
|
||||
"line": 762,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -41779,7 +41787,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1165,
|
||||
"line": 1175,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Copy Message",
|
||||
"surface": "apple",
|
||||
@@ -41787,7 +41795,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1188,
|
||||
"line": 1198,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Open Full Message",
|
||||
"surface": "apple",
|
||||
@@ -41795,7 +41803,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1207,
|
||||
"line": 1217,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Rewind to Here",
|
||||
"surface": "apple",
|
||||
@@ -41803,7 +41811,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1227,
|
||||
"line": 1237,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Fork from Here",
|
||||
"surface": "apple",
|
||||
@@ -41811,7 +41819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1253,
|
||||
"line": 1263,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Reply",
|
||||
"surface": "apple",
|
||||
@@ -41819,7 +41827,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1263,
|
||||
"line": 1273,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "You",
|
||||
"surface": "apple",
|
||||
@@ -41827,7 +41835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1265,
|
||||
"line": 1275,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Assistant",
|
||||
"surface": "apple",
|
||||
@@ -41835,7 +41843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1331,
|
||||
"line": 1341,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Loading chat",
|
||||
"surface": "apple",
|
||||
@@ -41843,7 +41851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1411,
|
||||
"line": 1421,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Dismiss",
|
||||
"surface": "apple",
|
||||
@@ -41971,7 +41979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1225,
|
||||
"line": 1205,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
|
||||
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -775,6 +775,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
|
||||
}
|
||||
|
||||
func listTasks(sessionKey: String, agentID: String?) async throws -> [TaskSummary] {
|
||||
let data = try await gateway.request(OpenClawChatGatewayRequests.tasksList(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID))
|
||||
return try JSONDecoder().decode(TasksListResult.self, from: data).tasks
|
||||
}
|
||||
|
||||
func getQuestion(id: String) async throws -> QuestionRecord {
|
||||
let data = try await gateway.request(OpenClawChatGatewayRequests.questionGet(id: id))
|
||||
return try JSONDecoder().decode(QuestionGetResult.self, from: data).question
|
||||
|
||||
@@ -34,6 +34,7 @@ struct MobileBackgroundTask: Decodable, Identifiable, Equatable {
|
||||
let updatedAt: Timestamp?
|
||||
let startedAt: Timestamp?
|
||||
let endedAt: Timestamp?
|
||||
let lastActivity: String?
|
||||
let progressSummary: String?
|
||||
let terminalSummary: String?
|
||||
let error: String?
|
||||
@@ -70,9 +71,9 @@ struct MobileBackgroundTask: Decodable, Identifiable, Equatable {
|
||||
|
||||
var output: String? {
|
||||
let candidates = if self.status == "failed" || self.status == "timed_out" {
|
||||
[self.error, self.terminalSummary, self.progressSummary]
|
||||
[self.error, self.terminalSummary, self.lastActivity, self.progressSummary]
|
||||
} else {
|
||||
[self.terminalSummary, self.error, self.progressSummary]
|
||||
[self.terminalSummary, self.error, self.lastActivity, self.progressSummary]
|
||||
}
|
||||
return candidates.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty }.first
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ struct BackgroundTasksScreenTests {
|
||||
#expect(task.activityMilliseconds > 0)
|
||||
}
|
||||
|
||||
@Test func `running task prefers live activity over progress summary`() throws {
|
||||
let data = Data(#"""
|
||||
{
|
||||
"id":"task-live",
|
||||
"status":"running",
|
||||
"runtime":"subagent",
|
||||
"lastActivity":"Editing the shared transcript",
|
||||
"progressSummary":"Earlier milestone"
|
||||
}
|
||||
"""#.utf8)
|
||||
|
||||
let task = try JSONDecoder().decode(MobileBackgroundTask.self, from: data)
|
||||
|
||||
#expect(task.output == "Editing the shared transcript")
|
||||
}
|
||||
|
||||
@Test func `groups active work and deduplicates newest task snapshot`() throws {
|
||||
let recent = try self.task(id: "finished", status: "completed", updatedAt: 4000)
|
||||
let stale = try self.task(id: "running", status: "running", updatedAt: 2000)
|
||||
|
||||
@@ -672,6 +672,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
|
||||
}
|
||||
|
||||
func listTasks(sessionKey: String, agentID: String?) async throws -> [TaskSummary] {
|
||||
let data = try await connection.request(OpenClawChatGatewayRequests.tasksList(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID))
|
||||
return try JSONDecoder().decode(TasksListResult.self, from: data).tasks
|
||||
}
|
||||
|
||||
func getQuestion(id: String) async throws -> QuestionRecord {
|
||||
let data = try await connection.request(OpenClawChatGatewayRequests.questionGet(id: id))
|
||||
return try JSONDecoder().decode(QuestionGetResult.self, from: data).question
|
||||
|
||||
@@ -162,18 +162,23 @@ public enum OpenClawChatGatewayPayloadCodec {
|
||||
as: OpenClawAgentEventPayload.self)
|
||||
else { return nil }
|
||||
return .agent(agent)
|
||||
default:
|
||||
return self.secondaryEvent(from: frame)
|
||||
}
|
||||
}
|
||||
|
||||
private static func secondaryEvent(from frame: EventFrame) -> OpenClawChatTransportEvent? {
|
||||
guard let payload = frame.payload else { return nil }
|
||||
switch frame.event {
|
||||
case "task":
|
||||
return (try? GatewayPayloadDecoding.decode(payload, as: OpenClawChatTaskEvent.self))
|
||||
.map(OpenClawChatTransportEvent.task)
|
||||
case "question.requested":
|
||||
guard let payload = frame.payload,
|
||||
let question = try? GatewayPayloadDecoding.decode(payload, as: QuestionRecord.self)
|
||||
else { return nil }
|
||||
return .questionRequested(question)
|
||||
return (try? GatewayPayloadDecoding.decode(payload, as: QuestionRecord.self))
|
||||
.map(OpenClawChatTransportEvent.questionRequested)
|
||||
case "question.resolved":
|
||||
guard let payload = frame.payload,
|
||||
let resolved = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawQuestionResolvedEvent.self)
|
||||
else { return nil }
|
||||
return .questionResolved(resolved)
|
||||
return (try? GatewayPayloadDecoding.decode(payload, as: OpenClawQuestionResolvedEvent.self))
|
||||
.map(OpenClawChatTransportEvent.questionResolved)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@ public enum OpenClawChatGatewayRequests {
|
||||
OpenClawChatGatewayRequest(method: "question.list", timeoutMs: self.defaultTimeoutMs)
|
||||
}
|
||||
|
||||
public static func tasksList(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
limit: Int = 200) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"limit": AnyCodable(limit),
|
||||
]
|
||||
self.add(agentID, to: ¶ms, key: "agentId")
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "tasks.list",
|
||||
params: params,
|
||||
timeoutMs: self.defaultTimeoutMs)
|
||||
}
|
||||
|
||||
public static func questionGet(id: String) -> OpenClawChatGatewayRequest {
|
||||
OpenClawChatGatewayRequest(
|
||||
method: "question.get",
|
||||
|
||||
@@ -503,7 +503,8 @@ private struct ChatMessageBody: View {
|
||||
details: self.message.details,
|
||||
resultText: self.primaryText,
|
||||
isError: self.message.isError ?? false,
|
||||
isPending: false)]
|
||||
isPending: false,
|
||||
liveDiffStat: nil)]
|
||||
}
|
||||
guard self.message.role.lowercased() == "assistant" else { return [] }
|
||||
return ChatToolActivity.items(calls: self.toolCalls, results: self.inlineToolResults)
|
||||
@@ -1012,7 +1013,8 @@ struct ChatPendingToolsBubble: View {
|
||||
details: nil,
|
||||
resultText: nil,
|
||||
isError: false,
|
||||
isPending: true)
|
||||
isPending: true,
|
||||
liveDiffStat: call.diffStat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +877,7 @@ public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
|
||||
public let args: AnyCodable?
|
||||
public let startedAt: Double?
|
||||
public let isError: Bool?
|
||||
let diffStat: ChatToolDiffStat?
|
||||
}
|
||||
|
||||
public struct OpenClawGatewayHealthOK: Codable, Sendable {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
enum ChatSubagentActivityStatus: String, Sendable {
|
||||
case queued
|
||||
case running
|
||||
case completed
|
||||
case failed
|
||||
case cancelled
|
||||
case timedOut = "timed_out"
|
||||
|
||||
var isWorking: Bool {
|
||||
self == .queued || self == .running
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatSubagentActivitySource: Sendable {
|
||||
case event
|
||||
case snapshot
|
||||
}
|
||||
|
||||
struct ChatSubagentActivity: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let status: ChatSubagentActivityStatus
|
||||
let snippet: String?
|
||||
let diffStat: ChatToolDiffStat?
|
||||
let startedAt: Double
|
||||
let updatedAt: Double
|
||||
let endedAt: Double?
|
||||
let terminalObservedAt: Double?
|
||||
let childSessionKey: String?
|
||||
let terminalSummary: String?
|
||||
}
|
||||
|
||||
struct ChatSubagentActivityPresentation: Equatable, Sendable {
|
||||
let rows: [ChatSubagentActivity]
|
||||
let hiddenWorkingCount: Int
|
||||
}
|
||||
|
||||
struct ChatSubagentActivityState: Equatable, Sendable {
|
||||
private(set) var activitiesByID: [String: ChatSubagentActivity] = [:]
|
||||
|
||||
mutating func upsert(
|
||||
_ task: TaskSummary,
|
||||
nowMilliseconds: Double,
|
||||
source: ChatSubagentActivitySource = .event)
|
||||
{
|
||||
guard let status = task.status.stringValue.flatMap(ChatSubagentActivityStatus.init(rawValue:))
|
||||
else { return }
|
||||
let previous = self.activitiesByID[task.id]
|
||||
let fallbackSnippet = Self.firstNonBlank(task.lastactivity, task.progresssummary, task.lasttoolname)
|
||||
let snippet = if !status.isWorking, previous != nil, Self.nonBlank(task.lastactivity) == nil {
|
||||
previous?.snippet
|
||||
} else {
|
||||
fallbackSnippet ?? previous?.snippet
|
||||
}
|
||||
let endedAt = Self.timestampMilliseconds(task.endedat) ?? previous?.endedAt
|
||||
let updatedAt = Self.timestampMilliseconds(task.updatedat)
|
||||
?? previous?.updatedAt
|
||||
?? endedAt
|
||||
?? nowMilliseconds
|
||||
let terminalObservedAt: Double? = if status.isWorking {
|
||||
nil
|
||||
} else if let previous, !previous.status.isWorking {
|
||||
previous.terminalObservedAt
|
||||
} else {
|
||||
source == .event ? nowMilliseconds : endedAt ?? updatedAt
|
||||
}
|
||||
self.activitiesByID[task.id] = ChatSubagentActivity(
|
||||
id: task.id,
|
||||
status: status,
|
||||
snippet: snippet,
|
||||
diffStat: Self.diffStat(task.diffstat) ?? previous?.diffStat,
|
||||
startedAt: Self.timestampMilliseconds(task.startedat)
|
||||
?? previous?.startedAt
|
||||
?? Self.timestampMilliseconds(task.createdat)
|
||||
?? nowMilliseconds,
|
||||
updatedAt: updatedAt,
|
||||
endedAt: endedAt,
|
||||
terminalObservedAt: terminalObservedAt,
|
||||
childSessionKey: Self.nonBlank(task.childsessionkey) ?? previous?.childSessionKey,
|
||||
terminalSummary: Self.nonBlank(task.terminalsummary) ?? previous?.terminalSummary)
|
||||
}
|
||||
|
||||
mutating func remove(taskID: String) {
|
||||
self.activitiesByID[taskID] = nil
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
self.activitiesByID.removeAll()
|
||||
}
|
||||
|
||||
mutating func removeExpired(
|
||||
nowMilliseconds: Double,
|
||||
retentionMilliseconds: Double = 60000)
|
||||
{
|
||||
self.activitiesByID = self.activitiesByID.filter { _, activity in
|
||||
activity.status.isWorking ||
|
||||
(activity.terminalObservedAt.map { nowMilliseconds - $0 < retentionMilliseconds } ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
func presentation(limit: Int = 5) -> ChatSubagentActivityPresentation {
|
||||
let sorted = self.activitiesByID.values.sorted { lhs, rhs in
|
||||
if lhs.updatedAt != rhs.updatedAt {
|
||||
return lhs.updatedAt > rhs.updatedAt
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
let ordered = sorted.filter(\.status.isWorking) + sorted.filter { !$0.status.isWorking }
|
||||
let rows = Array(ordered.prefix(limit))
|
||||
let visibleIDs = Set(rows.map(\.id))
|
||||
let hiddenWorkingCount = ordered.count { activity in
|
||||
activity.status == .running && !visibleIDs.contains(activity.id)
|
||||
}
|
||||
return ChatSubagentActivityPresentation(
|
||||
rows: rows,
|
||||
hiddenWorkingCount: hiddenWorkingCount)
|
||||
}
|
||||
|
||||
func nextExpiryMilliseconds(retentionMilliseconds: Double = 60000) -> Double? {
|
||||
self.activitiesByID.values
|
||||
.filter { !$0.status.isWorking }
|
||||
.compactMap(\.terminalObservedAt)
|
||||
.map { $0 + retentionMilliseconds }
|
||||
.min()
|
||||
}
|
||||
|
||||
private static func diffStat(_ value: [String: AnyCodable]?) -> ChatToolDiffStat? {
|
||||
guard let added = value?["added"]?.intValue,
|
||||
let removed = value?["removed"]?.intValue,
|
||||
added >= 0,
|
||||
removed >= 0
|
||||
else { return nil }
|
||||
let files = value?["files"]?.intValue
|
||||
return ChatToolDiffStat(
|
||||
files: files.map { max(0, $0) },
|
||||
added: added,
|
||||
removed: removed)
|
||||
}
|
||||
|
||||
private static func timestampMilliseconds(_ value: AnyCodable?) -> Double? {
|
||||
if let number = value?.doubleValue, number >= 0 { return number }
|
||||
guard let raw = value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else { return nil }
|
||||
if let number = Double(raw), number >= 0 { return number }
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let date = fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw)
|
||||
return date.map { $0.timeIntervalSince1970 * 1000 }
|
||||
}
|
||||
|
||||
private static func firstNonBlank(_ values: String?...) -> String? {
|
||||
values.lazy.compactMap(self.nonBlank).first
|
||||
}
|
||||
|
||||
private static func nonBlank(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed?.isEmpty == false ? trimmed : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawChatViewModel {
|
||||
func handleTaskEvent(_ event: OpenClawChatTaskEvent) {
|
||||
switch event {
|
||||
case let .upserted(task):
|
||||
self.foldSubagentTask(task)
|
||||
case let .deleted(taskID):
|
||||
self.updateSubagentActivityState { $0.remove(taskID: taskID) }
|
||||
case .restored:
|
||||
let session = self.currentSessionSnapshot()
|
||||
Task { await self.refreshSubagentActivities(sessionSnapshot: session) }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSubagentActivities(sessionSnapshot: SessionSnapshot) async {
|
||||
let baseline = self.subagentActivityState.activitiesByID
|
||||
let tasks: [TaskSummary]
|
||||
do {
|
||||
tasks = try await self.transport.listTasks(
|
||||
sessionKey: sessionSnapshot.key,
|
||||
agentID: sessionSnapshot.deliveryAgentID)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
self.updateSubagentActivityState { state in
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
for task in tasks where self.isCurrentSubagentTask(task) {
|
||||
// A task event received during this request is newer than its list snapshot.
|
||||
guard state.activitiesByID[task.id] == baseline[task.id] else { continue }
|
||||
state.upsert(task, nowMilliseconds: now, source: .snapshot)
|
||||
}
|
||||
state.removeExpired(nowMilliseconds: now)
|
||||
}
|
||||
}
|
||||
|
||||
func clearSubagentActivities() {
|
||||
self.subagentActivityCleanupTask?.cancel()
|
||||
self.subagentActivityCleanupTask = nil
|
||||
self.subagentActivityState.removeAll()
|
||||
self.subagentActivities = []
|
||||
self.hiddenWorkingSubagentCount = 0
|
||||
}
|
||||
|
||||
private func foldSubagentTask(_ task: TaskSummary) {
|
||||
guard self.isCurrentSubagentTask(task) else { return }
|
||||
self.updateSubagentActivityState { state in
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
state.upsert(task, nowMilliseconds: now)
|
||||
state.removeExpired(nowMilliseconds: now)
|
||||
}
|
||||
}
|
||||
|
||||
private func isCurrentSubagentTask(_ task: TaskSummary) -> Bool {
|
||||
guard task.runtime == "subagent",
|
||||
let requesterSessionKey = task.sessionkey
|
||||
else { return false }
|
||||
return self.matchesCurrentSessionKey(
|
||||
incoming: requesterSessionKey,
|
||||
agentId: task.agentid,
|
||||
current: self.sessionKey)
|
||||
}
|
||||
|
||||
private func updateSubagentActivityState(
|
||||
_ update: (inout ChatSubagentActivityState) -> Void)
|
||||
{
|
||||
let previous = self.subagentActivityState
|
||||
update(&self.subagentActivityState)
|
||||
guard self.subagentActivityState != previous else { return }
|
||||
let presentation = self.subagentActivityState.presentation()
|
||||
self.subagentActivities = presentation.rows
|
||||
self.hiddenWorkingSubagentCount = presentation.hiddenWorkingCount
|
||||
self.scheduleSubagentActivityCleanup()
|
||||
self.markTimelineChanged()
|
||||
}
|
||||
|
||||
private func scheduleSubagentActivityCleanup() {
|
||||
self.subagentActivityCleanupTask?.cancel()
|
||||
guard let expiry = self.subagentActivityState.nextExpiryMilliseconds() else {
|
||||
self.subagentActivityCleanupTask = nil
|
||||
return
|
||||
}
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let delay = max(0, Int64((expiry - now).rounded(.up)))
|
||||
self.subagentActivityCleanupTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(delay))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.updateSubagentActivityState { state in
|
||||
state.removeExpired(nowMilliseconds: Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSubagentActivityList: View {
|
||||
let activities: [ChatSubagentActivity]
|
||||
let hiddenWorkingCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(self.activities) { activity in
|
||||
ChatSubagentActivityRow(activity: activity)
|
||||
}
|
||||
if self.hiddenWorkingCount > 0 {
|
||||
Text(verbatim: String(
|
||||
format: String(localized: "+%1$lld more working"),
|
||||
Int64(self.hiddenWorkingCount)))
|
||||
.font(OpenClawChatTypography.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 35)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatSubagentActivityRow: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
let activity: ChatSubagentActivity
|
||||
|
||||
private var detail: String? {
|
||||
if self.activity.status.isWorking {
|
||||
return self.activity.snippet
|
||||
}
|
||||
return self.activity.terminalSummary ?? self.activity.snippet
|
||||
}
|
||||
|
||||
private var title: LocalizedStringResource {
|
||||
switch self.activity.status {
|
||||
case .queued, .running:
|
||||
"Subagent working"
|
||||
case .completed:
|
||||
"Subagent finished"
|
||||
case .failed, .timedOut:
|
||||
"Subagent failed"
|
||||
case .cancelled:
|
||||
"Subagent cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
switch self.activity.status {
|
||||
case .failed, .timedOut:
|
||||
OpenClawChatTheme.danger
|
||||
case .queued, .running, .completed, .cancelled:
|
||||
OpenClawChatTheme.assistantText
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 7) {
|
||||
if self.activity.status.isWorking {
|
||||
ChatWorkingClawView(seed: self.activity.id)
|
||||
} else {
|
||||
Image(systemName: self.activity.status == .completed ? "checkmark" : "xmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(
|
||||
self.activity.status == .completed
|
||||
? OpenClawChatTheme.success
|
||||
: OpenClawChatTheme.danger)
|
||||
.frame(width: 28, height: 24)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
Text(self.title)
|
||||
.font(OpenClawChatTypography.footnoteSemiBold)
|
||||
.foregroundStyle(self.titleColor)
|
||||
.lineLimit(1)
|
||||
|
||||
if let detail = self.detail {
|
||||
Text(verbatim: detail)
|
||||
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.contentTransition(.opacity)
|
||||
.animation(
|
||||
self.reduceMotion ? nil : .easeOut(duration: 0.16),
|
||||
value: detail)
|
||||
}
|
||||
|
||||
if let stat = self.activity.diffStat {
|
||||
ChatDiffStatChips(stat: stat)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ struct ChatToolActivityItem: Identifiable, Equatable {
|
||||
let resultText: String?
|
||||
let isError: Bool
|
||||
let isPending: Bool
|
||||
let liveDiffStat: ChatToolDiffStat?
|
||||
}
|
||||
|
||||
enum ChatToolActivity {
|
||||
@@ -34,7 +35,8 @@ enum ChatToolActivity {
|
||||
details: result?.details,
|
||||
resultText: result?.text,
|
||||
isError: result?.isError ?? false,
|
||||
isPending: false)
|
||||
isPending: false,
|
||||
liveDiffStat: nil)
|
||||
}
|
||||
|
||||
items.append(contentsOf: remainingResults.map { index, result in
|
||||
@@ -45,7 +47,8 @@ enum ChatToolActivity {
|
||||
details: result.details,
|
||||
resultText: result.text,
|
||||
isError: result.isError ?? false,
|
||||
isPending: false)
|
||||
isPending: false,
|
||||
liveDiffStat: nil)
|
||||
})
|
||||
return items
|
||||
}
|
||||
@@ -106,6 +109,10 @@ struct ChatToolActivityRow: View {
|
||||
]
|
||||
}
|
||||
|
||||
private var displayedDiffStat: ChatToolDiffStat? {
|
||||
self.item.isPending ? self.item.liveDiffStat ?? self.resolvedDiff?.stat : self.resolvedDiff?.stat
|
||||
}
|
||||
|
||||
init(item: ChatToolActivityItem) {
|
||||
self.item = item
|
||||
self.resolvedDiff = ChatToolDiff.resolveDiff(
|
||||
@@ -222,15 +229,8 @@ struct ChatToolActivityRow: View {
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
if let stat = self.resolvedDiff?.stat {
|
||||
Text(verbatim: "+\(stat.added)")
|
||||
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
|
||||
.foregroundStyle(OpenClawChatTheme.success.opacity(0.9))
|
||||
.lineLimit(1)
|
||||
Text(verbatim: "−\(stat.removed)")
|
||||
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
|
||||
.foregroundStyle(OpenClawChatTheme.danger.opacity(0.9))
|
||||
.lineLimit(1)
|
||||
if let stat = self.displayedDiffStat {
|
||||
ChatDiffStatChips(stat: stat)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -398,6 +398,21 @@ struct ChatToolActivityRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDiffStatChips: View {
|
||||
let stat: ChatToolDiffStat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Text(verbatim: "+\(self.stat.added)")
|
||||
.foregroundStyle(OpenClawChatTheme.success.opacity(0.9))
|
||||
Text(verbatim: "−\(self.stat.removed)")
|
||||
.foregroundStyle(OpenClawChatTheme.danger.opacity(0.9))
|
||||
}
|
||||
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolActivityList: View {
|
||||
let items: [ChatToolActivityItem]
|
||||
|
||||
|
||||
@@ -21,9 +21,16 @@ struct ChatToolDiffLine: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolDiffStat: Equatable, Sendable {
|
||||
struct ChatToolDiffStat: Equatable, Hashable, Sendable {
|
||||
let files: Int?
|
||||
let added: Int
|
||||
let removed: Int
|
||||
|
||||
init(files: Int? = nil, added: Int, removed: Int) {
|
||||
self.files = files
|
||||
self.added = added
|
||||
self.removed = removed
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatToolDiff {
|
||||
|
||||
@@ -9,12 +9,44 @@ public enum OpenClawChatTransportEvent: Sendable {
|
||||
case chat(OpenClawChatEventPayload)
|
||||
case sessionMessage(OpenClawSessionMessageEventPayload)
|
||||
case agent(OpenClawAgentEventPayload)
|
||||
case task(OpenClawChatTaskEvent)
|
||||
case questionRequested(QuestionRecord)
|
||||
case questionResolved(OpenClawQuestionResolvedEvent)
|
||||
case routeChanged
|
||||
case seqGap
|
||||
}
|
||||
|
||||
public enum OpenClawChatTaskEvent: Sendable, Decodable {
|
||||
case upserted(TaskSummary)
|
||||
case deleted(taskID: String)
|
||||
case restored
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let action = try container.decode(Action.self, forKey: .action)
|
||||
switch action {
|
||||
case .upserted:
|
||||
self = try .upserted(container.decode(TaskSummary.self, forKey: .task))
|
||||
case .deleted:
|
||||
self = try .deleted(taskID: container.decode(String.self, forKey: .taskID))
|
||||
case .restored:
|
||||
self = .restored
|
||||
}
|
||||
}
|
||||
|
||||
private enum Action: String, Decodable {
|
||||
case upserted
|
||||
case deleted
|
||||
case restored
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case action
|
||||
case task
|
||||
case taskID = "taskId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawQuestionResolvedEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let status: QuestionStatus
|
||||
@@ -727,6 +759,7 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool
|
||||
func listQuestions() async throws -> [QuestionRecord]
|
||||
func listTasks(sessionKey: String, agentID: String?) async throws -> [TaskSummary]
|
||||
func getQuestion(id: String) async throws -> QuestionRecord
|
||||
func resolveQuestion(id: String, answers: [String: [String]]) async throws
|
||||
func cancelQuestion(id: String) async throws
|
||||
@@ -772,6 +805,10 @@ extension OpenClawChatTransport {
|
||||
[]
|
||||
}
|
||||
|
||||
public func listTasks(sessionKey _: String, agentID _: String?) async throws -> [TaskSummary] {
|
||||
[]
|
||||
}
|
||||
|
||||
public func getQuestion(id _: String) async throws -> QuestionRecord {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
|
||||
@@ -500,6 +500,13 @@ public struct OpenClawChatView: View {
|
||||
.equatable()
|
||||
}
|
||||
|
||||
if self.displayOptions.contains(.toolActivity), !self.viewModel.subagentActivities.isEmpty {
|
||||
ChatSubagentActivityList(
|
||||
activities: self.viewModel.subagentActivities,
|
||||
hiddenWorkingCount: self.viewModel.hiddenWorkingSubagentCount)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if self.displayOptions.contains(.toolActivity), !self.viewModel.pendingToolCalls.isEmpty {
|
||||
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
|
||||
.equatable()
|
||||
@@ -819,6 +826,7 @@ public struct OpenClawChatView: View {
|
||||
|
||||
private var hasVisibleTransientContent: Bool {
|
||||
self.viewModel.hasBlockingRunActivity ||
|
||||
(self.displayOptions.contains(.toolActivity) && !self.viewModel.subagentActivities.isEmpty) ||
|
||||
(self.displayOptions.contains(.toolActivity) && !self.viewModel.pendingToolCalls.isEmpty) ||
|
||||
self.hasVisibleStreamingAssistantText ||
|
||||
!self.viewModel.visibleQuestionCards.isEmpty
|
||||
@@ -875,6 +883,7 @@ public struct OpenClawChatView: View {
|
||||
self.viewModel.messages.isEmpty &&
|
||||
!self.hasVisibleStreamingAssistantText &&
|
||||
!self.viewModel.hasBlockingRunActivity &&
|
||||
self.viewModel.subagentActivities.isEmpty &&
|
||||
self.viewModel.pendingToolCalls.isEmpty
|
||||
}
|
||||
|
||||
@@ -927,6 +936,7 @@ public struct OpenClawChatView: View {
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if self.viewModel.messages.isEmpty,
|
||||
!self.viewModel.hasBlockingRunActivity,
|
||||
self.viewModel.subagentActivities.isEmpty,
|
||||
self.viewModel.pendingToolCalls.isEmpty,
|
||||
self.viewModel.streamingAssistantText == nil
|
||||
{
|
||||
|
||||
@@ -3,6 +3,35 @@ import Foundation
|
||||
extension OpenClawChatViewModel {
|
||||
public static let verboseLevelOptions = ["off", "on", "full"]
|
||||
|
||||
public var modelPickerSections: ChatModelPickerSections {
|
||||
let defaultProvider = ChatModelPickerStore.resolvedDefaultProvider(
|
||||
provider: self.sessionDefaults?.modelProvider,
|
||||
model: self.sessionDefaults?.model)
|
||||
return ChatModelPickerStore.sections(
|
||||
choices: self.modelChoices,
|
||||
favorites: self.modelPickerFavorites,
|
||||
recents: self.modelPickerRecents,
|
||||
defaultProvider: defaultProvider)
|
||||
}
|
||||
|
||||
public func isDefaultModel(_ model: OpenClawChatModelChoice) -> Bool {
|
||||
ChatModelPickerStore.isDefaultModel(
|
||||
model,
|
||||
defaultProvider: self.sessionDefaults?.modelProvider,
|
||||
defaultModel: self.sessionDefaults?.model)
|
||||
}
|
||||
|
||||
public var isSelectedModelPinned: Bool {
|
||||
self.modelSelectionID != Self.defaultModelSelectionID &&
|
||||
self.modelPickerFavorites.contains(self.modelSelectionID)
|
||||
}
|
||||
|
||||
public func toggleSelectedModelPinned() {
|
||||
guard self.modelSelectionID != Self.defaultModelSelectionID else { return }
|
||||
self.modelPickerStore.toggleFavorite(self.modelSelectionID)
|
||||
self.modelPickerFavorites = self.modelPickerStore.favorites
|
||||
}
|
||||
|
||||
public var thinkingSelectionID: String {
|
||||
self.thinkingOverrideIsInherited ? Self.inheritedThinkingSelectionID : self.thinkingLevel
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ extension OpenClawChatViewModel {
|
||||
self.handleSessionMessageEvent(message)
|
||||
case let .agent(agent):
|
||||
self.handleAgentEvent(agent)
|
||||
case let .task(task):
|
||||
self.handleTaskEvent(task)
|
||||
case let .questionRequested(question):
|
||||
self.upsertQuestion(question)
|
||||
self.reconcileQuestionsAfterEvent()
|
||||
@@ -56,6 +58,8 @@ extension OpenClawChatViewModel {
|
||||
self.swarmEnabled = false
|
||||
self.resetSwarmProgress()
|
||||
Task { [weak self] in await self?.refreshSwarmCapability() }
|
||||
let session = self.currentSessionSnapshot()
|
||||
Task { [weak self] in await self?.refreshSubagentActivities(sessionSnapshot: session) }
|
||||
case .seqGap:
|
||||
self.errorText = nil
|
||||
self.swarmEnabled = false
|
||||
@@ -72,6 +76,7 @@ extension OpenClawChatViewModel {
|
||||
// Question refresh is best-effort and must not delay transcript
|
||||
// recovery behind a slow gateway round trip.
|
||||
Task { await self.refreshQuestions() }
|
||||
Task { await self.refreshSubagentActivities(sessionSnapshot: context.session) }
|
||||
Task {
|
||||
await self.refreshHistoryAfterRun(historyRequest: context)
|
||||
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
||||
@@ -649,7 +654,23 @@ extension OpenClawChatViewModel {
|
||||
name: name,
|
||||
args: args,
|
||||
startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000,
|
||||
isError: nil)
|
||||
isError: nil,
|
||||
diffStat: nil)
|
||||
} else if phase == "input_delta",
|
||||
let pending = self.pendingToolCallsById[toolCallId],
|
||||
let diff = evt.data["diff"]?.dictionaryValue,
|
||||
let added = diff["added"]?.intValue,
|
||||
let removed = diff["removed"]?.intValue,
|
||||
added >= 0,
|
||||
removed >= 0
|
||||
{
|
||||
self.pendingToolCallsById[toolCallId] = OpenClawChatPendingToolCall(
|
||||
toolCallId: pending.toolCallId,
|
||||
name: pending.name,
|
||||
args: pending.args,
|
||||
startedAt: pending.startedAt,
|
||||
isError: pending.isError,
|
||||
diffStat: ChatToolDiffStat(added: added, removed: removed))
|
||||
} else if phase == "result" {
|
||||
self.pendingToolCallsById[toolCallId] = nil
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ public final class OpenClawChatViewModel {
|
||||
var prefersExplicitVerboseLevel: Bool
|
||||
public private(set) var modelSelectionID: String = "__default__"
|
||||
public private(set) var modelChoices: [OpenClawChatModelChoice] = []
|
||||
private var modelPickerFavorites: [String]
|
||||
private var modelPickerRecents: [String]
|
||||
var modelPickerFavorites: [String]
|
||||
var modelPickerRecents: [String]
|
||||
/// Setters are module-internal for the sending extension's command catalog.
|
||||
public internal(set) var slashCommands: [OpenClawChatCommandChoice] = []
|
||||
public internal(set) var isLoadingSlashCommands = false
|
||||
@@ -111,6 +111,8 @@ public final class OpenClawChatViewModel {
|
||||
public private(set) var streamingAssistantText: String?
|
||||
|
||||
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
|
||||
var subagentActivities: [ChatSubagentActivity] = []
|
||||
var hiddenWorkingSubagentCount = 0
|
||||
public internal(set) var planSteps: [OpenClawChatPlanStep] = []
|
||||
public internal(set) var planExplanation: String?
|
||||
var planRunId: String?
|
||||
@@ -153,7 +155,7 @@ public final class OpenClawChatViewModel {
|
||||
let transcriptCache: (any OpenClawChatTranscriptCache)?
|
||||
let outbox: (any OpenClawChatCommandOutbox)?
|
||||
@ObservationIgnored
|
||||
private let modelPickerStore: ChatModelPickerStore
|
||||
let modelPickerStore: ChatModelPickerStore
|
||||
/// Per-message outbox display state; rows without an entry are normal
|
||||
/// transcript rows. Observable so bubbles update when flush progresses.
|
||||
public internal(set) var outboxStatesByMessageID: [UUID: OpenClawChatOutboxMessageState] = [:]
|
||||
@@ -444,6 +446,11 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
@ObservationIgnored
|
||||
var subagentActivityState = ChatSubagentActivityState()
|
||||
@ObservationIgnored
|
||||
var subagentActivityCleanupTask: Task<Void, Never>?
|
||||
|
||||
var lastHealthPollAt: Date?
|
||||
|
||||
public init(
|
||||
@@ -544,6 +551,7 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
self.outboxChangesTask?.cancel()
|
||||
self.activeSessionRunIndicatorTimeoutTask?.cancel()
|
||||
self.subagentActivityCleanupTask?.cancel()
|
||||
self.questionRefreshRetryTask?.cancel()
|
||||
for (_, task) in self.questionExpiryTasks {
|
||||
task.cancel()
|
||||
@@ -561,35 +569,6 @@ public final class OpenClawChatViewModel {
|
||||
startBootstrap()
|
||||
}
|
||||
|
||||
public var modelPickerSections: ChatModelPickerSections {
|
||||
let defaultProvider = ChatModelPickerStore.resolvedDefaultProvider(
|
||||
provider: self.sessionDefaults?.modelProvider,
|
||||
model: self.sessionDefaults?.model)
|
||||
return ChatModelPickerStore.sections(
|
||||
choices: self.modelChoices,
|
||||
favorites: self.modelPickerFavorites,
|
||||
recents: self.modelPickerRecents,
|
||||
defaultProvider: defaultProvider)
|
||||
}
|
||||
|
||||
public func isDefaultModel(_ model: OpenClawChatModelChoice) -> Bool {
|
||||
ChatModelPickerStore.isDefaultModel(
|
||||
model,
|
||||
defaultProvider: self.sessionDefaults?.modelProvider,
|
||||
defaultModel: self.sessionDefaults?.model)
|
||||
}
|
||||
|
||||
public var isSelectedModelPinned: Bool {
|
||||
self.modelSelectionID != Self.defaultModelSelectionID &&
|
||||
self.modelPickerFavorites.contains(self.modelSelectionID)
|
||||
}
|
||||
|
||||
public func toggleSelectedModelPinned() {
|
||||
guard self.modelSelectionID != Self.defaultModelSelectionID else { return }
|
||||
self.modelPickerStore.toggleFavorite(self.modelSelectionID)
|
||||
self.modelPickerFavorites = self.modelPickerStore.favorites
|
||||
}
|
||||
|
||||
public func resumeFromForeground() {
|
||||
Task { await self.refreshRunStateAfterForeground() }
|
||||
}
|
||||
@@ -957,6 +936,7 @@ extension OpenClawChatViewModel {
|
||||
|
||||
Task { [weak self] in await self?.refreshQuestions() }
|
||||
Task { [weak self] in await self?.refreshSwarmCapability(sessionSnapshot: context.session) }
|
||||
Task { [weak self] in await self?.refreshSubagentActivities(sessionSnapshot: context.session) }
|
||||
|
||||
let payload = try await transport.requestHistory(sessionKey: context.session.key)
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
@@ -1277,6 +1257,7 @@ extension OpenClawChatViewModel {
|
||||
resetOutboxPresentationForSessionSwitch()
|
||||
self.sessionId = nil
|
||||
self.pendingToolCallsById = [:]
|
||||
self.clearSubagentActivities()
|
||||
self.updateStreamingAssistantText(nil)
|
||||
clearPlan()
|
||||
self.updateActiveSessionRunWithoutChatSnapshot(false)
|
||||
|
||||
@@ -523,6 +523,34 @@ struct ChatGatewayPayloadCodecTests {
|
||||
#expect(digest.runid == "run-1")
|
||||
#expect(digest.revision == 2)
|
||||
|
||||
let task = EventFrame(
|
||||
type: "event",
|
||||
event: "task",
|
||||
payload: AnyCodable([
|
||||
"action": AnyCodable("upserted"),
|
||||
"task": AnyCodable([
|
||||
"id": AnyCodable("task-1"),
|
||||
"runtime": AnyCodable("subagent"),
|
||||
"status": AnyCodable("running"),
|
||||
"sessionKey": AnyCodable("agent:main:main"),
|
||||
"lastActivity": AnyCodable("Editing ChatView.swift"),
|
||||
"diffStat": AnyCodable([
|
||||
"files": AnyCodable(1),
|
||||
"added": AnyCodable(8),
|
||||
"removed": AnyCodable(2),
|
||||
]),
|
||||
]),
|
||||
]))
|
||||
guard case let .task(.upserted(summary)) = OpenClawChatGatewayPayloadCodec.event(from: task)
|
||||
else {
|
||||
Issue.record("expected task upsert")
|
||||
return
|
||||
}
|
||||
#expect(summary.id == "task-1")
|
||||
#expect(summary.sessionkey == "agent:main:main")
|
||||
#expect(summary.lastactivity == "Editing ChatView.swift")
|
||||
#expect(summary.diffstat?["added"]?.intValue == 8)
|
||||
|
||||
#expect(OpenClawChatGatewayPayloadCodec.event(from: EventFrame(
|
||||
type: "event",
|
||||
event: "unknown")) == nil)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("Chat subagent activity")
|
||||
struct ChatSubagentActivityTests {
|
||||
@Test func `terminal snapshot retains live fields and expires after sixty seconds`() throws {
|
||||
var state = ChatSubagentActivityState()
|
||||
state.upsert(
|
||||
self.task(
|
||||
id: "task-1",
|
||||
status: "running",
|
||||
lastActivity: "Applying patch",
|
||||
diffStat: ["files": 1, "added": 7, "removed": 2]),
|
||||
nowMilliseconds: 1000)
|
||||
state.upsert(
|
||||
self.task(
|
||||
id: "task-1",
|
||||
status: "completed",
|
||||
progressSummary: "Earlier milestone",
|
||||
terminalSummary: "Done",
|
||||
endedAt: 2000),
|
||||
nowMilliseconds: 2000)
|
||||
|
||||
let retained = try #require(state.presentation().rows.first)
|
||||
#expect(retained.status == .completed)
|
||||
#expect(retained.snippet == "Applying patch")
|
||||
#expect(retained.diffStat == ChatToolDiffStat(files: 1, added: 7, removed: 2))
|
||||
|
||||
state.removeExpired(nowMilliseconds: 61999)
|
||||
#expect(state.presentation().rows.count == 1)
|
||||
state.removeExpired(nowMilliseconds: 62000)
|
||||
#expect(state.presentation().rows.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `caps rows at five and counts only hidden working tasks`() {
|
||||
var state = ChatSubagentActivityState()
|
||||
for index in 0..<7 {
|
||||
state.upsert(
|
||||
self.task(id: "working-\(index)", status: "running", startedAt: Double(index)),
|
||||
nowMilliseconds: Double(index))
|
||||
}
|
||||
state.upsert(
|
||||
self.task(id: "finished", status: "completed", endedAt: 10),
|
||||
nowMilliseconds: 10)
|
||||
|
||||
let presentation = state.presentation()
|
||||
#expect(presentation.rows.map(\.id) == (2..<7).reversed().map { "working-\($0)" })
|
||||
#expect(presentation.hiddenWorkingCount == 2)
|
||||
}
|
||||
|
||||
private func task(
|
||||
id: String,
|
||||
status: String,
|
||||
lastActivity: String? = nil,
|
||||
progressSummary: String? = nil,
|
||||
terminalSummary: String? = nil,
|
||||
diffStat: [String: Int]? = nil,
|
||||
startedAt: Double = 0,
|
||||
updatedAt: Double? = nil,
|
||||
endedAt: Double? = nil) -> TaskSummary
|
||||
{
|
||||
TaskSummary(
|
||||
id: id,
|
||||
runtime: "subagent",
|
||||
status: AnyCodable(status),
|
||||
agentid: "main",
|
||||
sessionkey: "agent:main:main",
|
||||
updatedat: AnyCodable(updatedAt ?? startedAt),
|
||||
startedat: AnyCodable(startedAt),
|
||||
endedat: endedAt.map(AnyCodable.init),
|
||||
lastactivity: lastActivity,
|
||||
diffstat: diffStat?.mapValues(AnyCodable.init),
|
||||
progresssummary: progressSummary,
|
||||
terminalsummary: terminalSummary)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ struct ChatToolActivityTests {
|
||||
details: nil,
|
||||
resultText: "done",
|
||||
isError: false,
|
||||
isPending: false)])
|
||||
isPending: false,
|
||||
liveDiffStat: nil)])
|
||||
}
|
||||
|
||||
@Test func `appends orphan result`() {
|
||||
@@ -31,7 +32,8 @@ struct ChatToolActivityTests {
|
||||
details: nil,
|
||||
resultText: "orphaned",
|
||||
isError: false,
|
||||
isPending: false)])
|
||||
isPending: false,
|
||||
liveDiffStat: nil)])
|
||||
}
|
||||
|
||||
@Test func `preserves call order`() {
|
||||
@@ -61,7 +63,8 @@ struct ChatToolActivityTests {
|
||||
details: nil,
|
||||
resultText: nil,
|
||||
isError: false,
|
||||
isPending: false)])
|
||||
isPending: false,
|
||||
liveDiffStat: nil)])
|
||||
}
|
||||
|
||||
@Test func `threads paired result details`() {
|
||||
|
||||
@@ -96,6 +96,33 @@ private func usageEvent(runId: String, outputTokens: Int, seq: Int) -> OpenClawA
|
||||
data: ["outputTokens": AnyCodable(outputTokens)])
|
||||
}
|
||||
|
||||
private func subagentTaskSummary(
|
||||
id: String,
|
||||
status: String,
|
||||
sessionKey: String = "agent:main:main",
|
||||
lastActivity: String? = nil,
|
||||
progressSummary: String? = nil,
|
||||
terminalSummary: String? = nil,
|
||||
diffStat: [String: AnyCodable]? = nil,
|
||||
startedAt: Double = 1000,
|
||||
endedAt: Double? = nil) -> TaskSummary
|
||||
{
|
||||
TaskSummary(
|
||||
id: id,
|
||||
runtime: "subagent",
|
||||
status: AnyCodable(status),
|
||||
agentid: "main",
|
||||
sessionkey: sessionKey,
|
||||
childsessionkey: "agent:main:subagent:\(id)",
|
||||
updatedat: AnyCodable(endedAt ?? startedAt),
|
||||
startedat: AnyCodable(startedAt),
|
||||
endedat: endedAt.map(AnyCodable.init),
|
||||
lastactivity: lastActivity,
|
||||
diffstat: diffStat,
|
||||
progresssummary: progressSummary,
|
||||
terminalsummary: terminalSummary)
|
||||
}
|
||||
|
||||
private func lifecycleSessionEntry(
|
||||
key: String,
|
||||
updatedAt: Double,
|
||||
@@ -702,6 +729,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
private let swarmEnabledHook: (@Sendable (String) async throws -> Bool)?
|
||||
private let listChildSessionsHook: (@Sendable (String) async throws -> [OpenClawChatSessionEntry])?
|
||||
private let listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])?
|
||||
private let listTasksHook: (@Sendable (String, String?) async throws -> [TaskSummary])?
|
||||
private let getQuestionHook: (@Sendable (String) async throws -> QuestionRecord)?
|
||||
private let cancelQuestionHook: (@Sendable (String) async throws -> Void)?
|
||||
private let healthResponses: [Bool]
|
||||
@@ -738,6 +766,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
swarmEnabledHook: (@Sendable (String) async throws -> Bool)? = nil,
|
||||
listChildSessionsHook: (@Sendable (String) async throws -> [OpenClawChatSessionEntry])? = nil,
|
||||
listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])? = nil,
|
||||
listTasksHook: (@Sendable (String, String?) async throws -> [TaskSummary])? = nil,
|
||||
getQuestionHook: (@Sendable (String) async throws -> QuestionRecord)? = nil,
|
||||
cancelQuestionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
healthResponses: [Bool] = [true])
|
||||
@@ -768,6 +797,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
self.swarmEnabledHook = swarmEnabledHook
|
||||
self.listChildSessionsHook = listChildSessionsHook
|
||||
self.listQuestionsHook = listQuestionsHook
|
||||
self.listTasksHook = listTasksHook
|
||||
self.getQuestionHook = getQuestionHook
|
||||
self.cancelQuestionHook = cancelQuestionHook
|
||||
self.healthResponses = healthResponses
|
||||
@@ -1069,6 +1099,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
try await self.listQuestionsHook?() ?? []
|
||||
}
|
||||
|
||||
func listTasks(sessionKey: String, agentID: String?) async throws -> [TaskSummary] {
|
||||
try await self.listTasksHook?(sessionKey, agentID) ?? []
|
||||
}
|
||||
|
||||
func getQuestion(id: String) async throws -> QuestionRecord {
|
||||
guard let getQuestionHook else {
|
||||
throw NSError(
|
||||
@@ -1425,6 +1459,110 @@ private actor SwarmCapabilityScript {
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ChatViewModelTests {
|
||||
@Test func `bootstrap fills subagent activity from the current session task list`() async throws {
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: [historyPayload()],
|
||||
listTasksHook: { sessionKey, agentID in
|
||||
guard sessionKey == "main", agentID == "main" else { return [] }
|
||||
return [subagentTaskSummary(
|
||||
id: "listed",
|
||||
status: "running",
|
||||
progressSummary: "Restored from task list")]
|
||||
})
|
||||
let viewModel = await MainActor.run {
|
||||
OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
activeAgentId: "main")
|
||||
}
|
||||
|
||||
await MainActor.run { viewModel.load() }
|
||||
try await waitUntil("listed subagent activity") {
|
||||
await MainActor.run { viewModel.subagentActivities.map(\.id) == ["listed"] }
|
||||
}
|
||||
|
||||
#expect(await MainActor.run { viewModel.subagentActivities.first?.snippet } ==
|
||||
"Restored from task list")
|
||||
}
|
||||
|
||||
@Test @MainActor func `subagent task events filter by session and retain terminal activity`() {
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: TestChatTransport(historyResponses: []),
|
||||
activeAgentId: "main")
|
||||
let liveDiff = [
|
||||
"files": AnyCodable(2),
|
||||
"added": AnyCodable(9),
|
||||
"removed": AnyCodable(3),
|
||||
]
|
||||
|
||||
viewModel.handleTransportEvent(.task(.upserted(subagentTaskSummary(
|
||||
id: "foreign",
|
||||
status: "running",
|
||||
sessionKey: "agent:main:other",
|
||||
lastActivity: "Must stay hidden"))))
|
||||
viewModel.handleTransportEvent(.task(.upserted(subagentTaskSummary(
|
||||
id: "owned",
|
||||
status: "running",
|
||||
lastActivity: "Editing shared chat",
|
||||
diffStat: liveDiff))))
|
||||
|
||||
#expect(viewModel.subagentActivities.map(\.id) == ["owned"])
|
||||
#expect(viewModel.subagentActivities[0].snippet == "Editing shared chat")
|
||||
#expect(viewModel.subagentActivities[0].diffStat == ChatToolDiffStat(
|
||||
files: 2,
|
||||
added: 9,
|
||||
removed: 3))
|
||||
|
||||
viewModel.handleTransportEvent(.task(.upserted(subagentTaskSummary(
|
||||
id: "owned",
|
||||
status: "completed",
|
||||
progressSummary: "Older milestone",
|
||||
terminalSummary: "Finished cleanly",
|
||||
endedAt: Date().timeIntervalSince1970 * 1000))))
|
||||
|
||||
#expect(viewModel.subagentActivities[0].status == .completed)
|
||||
#expect(viewModel.subagentActivities[0].snippet == "Editing shared chat")
|
||||
#expect(viewModel.subagentActivities[0].terminalSummary == "Finished cleanly")
|
||||
#expect(viewModel.subagentActivities[0].diffStat?.added == 9)
|
||||
}
|
||||
|
||||
@Test @MainActor func `tool input delta updates the matching pending edit diff`() {
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: TestChatTransport(historyResponses: []))
|
||||
viewModel.sessionId = "run-1"
|
||||
viewModel.handleTransportEvent(.agent(OpenClawAgentEventPayload(
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: 1000,
|
||||
data: [
|
||||
"phase": AnyCodable("start"),
|
||||
"name": AnyCodable("apply_patch"),
|
||||
"toolCallId": AnyCodable("tool-1"),
|
||||
"args": AnyCodable(["patch": "*** Begin Patch"]),
|
||||
])))
|
||||
viewModel.handleTransportEvent(.agent(OpenClawAgentEventPayload(
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
stream: "tool",
|
||||
ts: 1001,
|
||||
data: [
|
||||
"phase": AnyCodable("input_delta"),
|
||||
"name": AnyCodable("apply_patch"),
|
||||
"toolCallId": AnyCodable("tool-1"),
|
||||
"diff": AnyCodable([
|
||||
"added": AnyCodable(12),
|
||||
"removed": AnyCodable(4),
|
||||
]),
|
||||
])))
|
||||
|
||||
#expect(viewModel.pendingToolCalls.first?.diffStat == ChatToolDiffStat(
|
||||
added: 12,
|
||||
removed: 4))
|
||||
}
|
||||
|
||||
@Test @MainActor func `transient Swarm capability failure preserves state and retries until explicit false`() async throws {
|
||||
let script = SwarmCapabilityScript([.value(true), .failure, .value(false)])
|
||||
var child = sessionEntry(key: "agent:main:child", updatedAt: 1)
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"session.typing": "Collaborative typing state is a Control UI-only ephemeral indicator; iOS does not render it.",
|
||||
"shutdown": "iOS relies on socket close plus reconnect/backoff instead of the shutdown notice.",
|
||||
"skills.changed": "Skills settings is a macOS operator surface; iOS does not expose skill management yet.",
|
||||
"task": "Background task activity is not surfaced in the iOS app.",
|
||||
"task.suggestion": "Task suggestion cards are a Control UI-only surface; iOS does not render them.",
|
||||
"terminal.data": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
|
||||
"terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
|
||||
|
||||
Reference in New Issue
Block a user