From 4b46a14a54032dcccd356c4477416f2147cec0a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 21:50:13 -0700 Subject: [PATCH] feat(apple): show live subagent activity in chat (#121815) * feat(apple): show live subagent activity in chat * chore(apple): refresh native localization inventory --- apps/.i18n/native-source.json | 134 ++++----- .../Chat/IOSGatewayChatTransport.swift | 7 + .../Design/BackgroundTasksScreen.swift | 5 +- .../Tests/BackgroundTasksScreenTests.swift | 16 ++ .../Sources/OpenClaw/WebChatSwiftUI.swift | 7 + .../ChatGatewayPayloadCodec.swift | 25 +- .../OpenClawChatUI/ChatGatewayRequest.swift | 16 ++ .../OpenClawChatUI/ChatMessageViews.swift | 6 +- .../Sources/OpenClawChatUI/ChatModels.swift | 1 + .../OpenClawChatUI/ChatSubagentActivity.swift | 256 ++++++++++++++++++ .../ChatSubagentActivityViews.swift | 101 +++++++ .../ChatToolActivityViews.swift | 37 ++- .../Sources/OpenClawChatUI/ChatToolDiff.swift | 9 +- .../OpenClawChatUI/ChatTransport.swift | 37 +++ .../Sources/OpenClawChatUI/ChatView.swift | 10 + .../ChatViewModel+ModelControls.swift | 29 ++ .../ChatViewModel+TransportEvents.swift | 23 +- .../OpenClawChatUI/ChatViewModel.swift | 45 +-- .../ChatGatewayRequestTests.swift | 28 ++ .../ChatSubagentActivityTests.swift | 78 ++++++ .../ChatToolActivityTests.swift | 9 +- .../OpenClawKitTests/ChatViewModelTests.swift | 138 ++++++++++ .../protocol-event-coverage.allowlist.json | 1 - 23 files changed, 892 insertions(+), 126 deletions(-) create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivityViews.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSubagentActivityTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 09ac49b0a53e..59b59ce11b75 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 2e518ff7ffac..35e45fc1d686 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -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 diff --git a/apps/ios/Sources/Design/BackgroundTasksScreen.swift b/apps/ios/Sources/Design/BackgroundTasksScreen.swift index 620448b07b25..07b8a5fe5921 100644 --- a/apps/ios/Sources/Design/BackgroundTasksScreen.swift +++ b/apps/ios/Sources/Design/BackgroundTasksScreen.swift @@ -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 } diff --git a/apps/ios/Tests/BackgroundTasksScreenTests.swift b/apps/ios/Tests/BackgroundTasksScreenTests.swift index 141666a6aad8..42bb28f46e6f 100644 --- a/apps/ios/Tests/BackgroundTasksScreenTests.swift +++ b/apps/ios/Tests/BackgroundTasksScreenTests.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 98f3eb32e977..62152483b940 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 41620a9f293d..8bb2acad42c1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index e83e5789c184..99838e5bc7dd 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 37cdade0e353..2c0ec412c133 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -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) } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index c1747cab613b..fb798cae2a0b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift new file mode 100644 index 000000000000..5cbd2ca0d41a --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift @@ -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) + } + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivityViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivityViews.swift new file mode 100644 index 000000000000..980cb7d851e1 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivityViews.swift @@ -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) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift index 321280a381bd..175b528e91fc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift @@ -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] diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift index be7495d94cfe..dbaea52a70be 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index e57607de2e87..6cfb1a6aec0a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 6e955441003f..b93f3a1a6853 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ModelControls.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ModelControls.swift index 067b3499a2e1..478f9b519b9f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ModelControls.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ModelControls.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index 517206bff21c..94852b0e1135 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 7dd92e00228b..5a462125a50f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -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? + 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) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index a2ec26d6051a..037484fc4f6f 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -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) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSubagentActivityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSubagentActivityTests.swift new file mode 100644 index 000000000000..1a884b76b158 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSubagentActivityTests.swift @@ -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) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift index 31cb15522d0e..a65815a01feb 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift @@ -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`() { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 05ebd1ca8c8e..858d750162f8 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -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) diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 0f11f43203c0..59e2d5f597b9 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -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.",