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:
Peter Steinberger
2026-08-10 21:50:13 -07:00
committed by GitHub
parent d7ea18e600
commit 4b46a14a54
23 changed files with 892 additions and 126 deletions
+71 -63
View File
@@ -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: &params, 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.",