From b823a5a26626ee4637975cd923dfd12df063baf0 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 18 May 2026 16:11:54 +0300 Subject: [PATCH] fix(ios): improve live activity lifecycle (#83597) Merged via squash. Prepared head SHA: 6bd991dafbea3ada3b9eb2b88b6fead0bfa9f452 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 1 + .../ActivityWidget/OpenClawLiveActivity.swift | 65 ++++++---- .../LiveActivity/LiveActivityManager.swift | 116 +++++++++++++++--- .../OpenClawActivityAttributes.swift | 7 ++ apps/ios/Sources/Model/NodeAppModel.swift | 28 +++-- 5 files changed, 161 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7abcbd89596..5f9e913f958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,7 @@ Docs: https://docs.openclaw.ai - Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg. - Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman. - WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman. +- iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman. ## 2026.5.17 diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift index d076dc82d00..7f3738c8ab5 100644 --- a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift +++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift @@ -13,8 +13,9 @@ struct OpenClawLiveActivity: Widget { } DynamicIslandExpandedRegion(.center) { Text(context.state.statusText) - .font(.subheadline) + .font(.subheadline.weight(.semibold)) .lineLimit(1) + .minimumScaleFactor(0.8) } DynamicIslandExpandedRegion(.trailing) { self.trailingView(state: context.state) @@ -22,10 +23,7 @@ struct OpenClawLiveActivity: Widget { } compactLeading: { self.statusDot(state: context.state) } compactTrailing: { - Text(context.state.statusText) - .font(.caption2) - .lineLimit(1) - .frame(maxWidth: 64) + self.compactStatusIcon(state: context.state) } minimal: { self.statusDot(state: context.state) } @@ -33,39 +31,32 @@ struct OpenClawLiveActivity: Widget { } private func lockScreenView(context: ActivityViewContext) -> some View { - HStack(spacing: 8) { - self.statusDot(state: context.state) - .frame(width: 10, height: 10) + HStack(spacing: 10) { + self.statusIcon(state: context.state) + .frame(width: 30, height: 30) + .background(.thinMaterial, in: Circle()) VStack(alignment: .leading, spacing: 2) { Text("OpenClaw") .font(.subheadline.bold()) + .lineLimit(1) Text(context.state.statusText) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } Spacer() self.trailingView(state: context.state) } .padding(.horizontal, 12) - .padding(.vertical, 4) + .padding(.vertical, 8) } @ViewBuilder private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View { - if state.isConnecting { - ProgressView().controlSize(.small) - } else if state.isDisconnected { - Image(systemName: "wifi.slash") - .foregroundStyle(.red) - } else if state.isIdle { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(.green) - } else { - Text(state.startedAt, style: .timer) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - } + self.statusIcon(state: state) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 28, height: 28) } private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View { @@ -74,10 +65,34 @@ struct OpenClawLiveActivity: Widget { .frame(width: 6, height: 6) } + @ViewBuilder + private func compactStatusIcon(state: OpenClawActivityAttributes.ContentState) -> some View { + self.statusIcon(state: state) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 18, height: 18) + } + + @ViewBuilder + private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View { + if state.isConnecting { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.cyan) + } else if state.isDisconnected { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + } else if state.isIdle { + Image(systemName: "checkmark") + .foregroundStyle(.green) + } else { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color { if state.isDisconnected { return .red } - if state.isConnecting { return .gray } + if state.isConnecting { return .cyan } if state.isIdle { return .green } - return .blue + return .orange } } diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift index 35dfd0b2591..d459c6a76c4 100644 --- a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift +++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift @@ -8,6 +8,8 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity") + private let connectingStaleSeconds: TimeInterval = 120 + private let hydrationStaleSeconds: TimeInterval = 300 private var currentActivity: Activity? private var activityStartDate: Date = .now @@ -24,11 +26,11 @@ final class LiveActivityManager { return true } - func startActivity(agentName: String, sessionKey: String) { + func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) { self.hydrateCurrentAndPruneDuplicates() if self.currentActivity != nil { - self.handleConnecting() + self.handleConnecting(statusText: statusText) return } @@ -40,11 +42,14 @@ final class LiveActivityManager { self.activityStartDate = .now let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey) + let state = self.connectingState(statusText: statusText) do { let activity = try Activity.request( attributes: attributes, - content: ActivityContent(state: self.connectingState(), staleDate: nil), + content: ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(self.connectingStaleSeconds)), pushType: nil) self.currentActivity = activity self.logger.info("started live activity id=\(activity.id, privacy: .public)") @@ -53,16 +58,57 @@ final class LiveActivityManager { } } - func handleConnecting() { - self.updateCurrent(state: self.connectingState()) + func showAttention(statusText: String, agentName: String, sessionKey: String) { + self.hydrateCurrentAndPruneDuplicates() + + if self.currentActivity == nil { + let authInfo = ActivityAuthorizationInfo() + guard authInfo.areActivitiesEnabled else { + self.logger.info("Live Activities disabled; skipping attention state") + return + } + self.activityStartDate = .now + let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey) + do { + let activity = try Activity.request( + attributes: attributes, + content: ActivityContent(state: self.attentionState(statusText: statusText), staleDate: nil), + pushType: nil) + self.currentActivity = activity + self.logger.info("started attention live activity id=\(activity.id, privacy: .public)") + } catch { + self.logger.error( + "failed to start attention live activity: \(error.localizedDescription, privacy: .public)") + } + return + } + + self.updateCurrent(state: self.attentionState(statusText: statusText), staleDate: nil) + } + + func handleConnecting(statusText: String = "Connecting...") { + self.updateCurrent( + state: self.connectingState(statusText: statusText), + staleDate: Date().addingTimeInterval(self.connectingStaleSeconds)) } func handleReconnect() { - self.updateCurrent(state: self.idleState()) + self.endActivity(reason: "connected") } func handleDisconnect() { - self.updateCurrent(state: self.disconnectedState()) + self.endActivity(reason: "disconnected") + } + + func endActivity(reason: String) { + guard let activity = self.currentActivity else { return } + self.currentActivity = nil + self.logger.info("ending live activity reason=\(reason, privacy: .public)") + Task { + await activity.end( + ActivityContent(state: self.disconnectedState(), staleDate: nil), + dismissalPolicy: .immediate) + } } private func hydrateCurrentAndPruneDuplicates() { @@ -72,39 +118,71 @@ final class LiveActivityManager { return } - let keeper = active.max { lhs, rhs in + let now = Date() + let candidates = active.filter { activity in + let state = activity.content.state + guard activity.activityState == .active else { return false } + guard !state.isIdle, !state.isDisconnected else { return false } + return now.timeIntervalSince(state.startedAt) < self.hydrationStaleSeconds + } + + guard !candidates.isEmpty else { + self.currentActivity = nil + for activity in active { + self.end(activity: activity) + } + return + } + + let keeper = candidates.max { lhs, rhs in lhs.content.state.startedAt < rhs.content.state.startedAt - } ?? active[0] + } ?? candidates[0] self.currentActivity = keeper self.activityStartDate = keeper.content.state.startedAt let stale = active.filter { $0.id != keeper.id } for activity in stale { - Task { - await activity.end( - ActivityContent(state: self.disconnectedState(), staleDate: nil), - dismissalPolicy: .immediate) - } + self.end(activity: activity) } } - private func updateCurrent(state: OpenClawActivityAttributes.ContentState) { - guard let activity = self.currentActivity else { return } + private func updateCurrent(state: OpenClawActivityAttributes.ContentState, staleDate: Date? = nil) { + guard let activity = self.currentActivity, activity.activityState == .active else { + self.currentActivity = nil + return + } Task { - await activity.update(ActivityContent(state: state, staleDate: nil)) + await activity.update(ActivityContent(state: state, staleDate: staleDate)) } } - private func connectingState() -> OpenClawActivityAttributes.ContentState { + private func end(activity: Activity) { + Task { + await activity.end( + ActivityContent(state: self.disconnectedState(), staleDate: nil), + dismissalPolicy: .immediate) + } + } + + private func connectingState(statusText: String = "Connecting...") -> OpenClawActivityAttributes.ContentState { OpenClawActivityAttributes.ContentState( - statusText: "Connecting...", + statusText: statusText, isIdle: false, isDisconnected: false, isConnecting: true, startedAt: self.activityStartDate) } + private func attentionState(statusText: String) -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: statusText, + isIdle: false, + isDisconnected: false, + isConnecting: false, + startedAt: self.activityStartDate) + } + private func idleState() -> OpenClawActivityAttributes.ContentState { OpenClawActivityAttributes.ContentState( statusText: "Idle", diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift index d9d879c84b5..81d03f7b377 100644 --- a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift +++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift @@ -41,5 +41,12 @@ extension OpenClawActivityAttributes.ContentState { isDisconnected: true, isConnecting: false, startedAt: .now) + + static let attention = OpenClawActivityAttributes.ContentState( + statusText: "Approval needed", + isIdle: false, + isDisconnected: false, + isConnecting: false, + startedAt: .now) } #endif diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index badccc01a84..bdeb51c39bb 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -546,6 +546,7 @@ final class NodeAppModel { self.talkMode.updateGatewayConnected(false) if self.isBackgrounded { self.gatewayStatusText = "Background idle" + LiveActivityManager.shared.endActivity(reason: "background_idle") self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.showLocalCanvasOnDisconnect() @@ -1839,7 +1840,7 @@ extension NodeAppModel { self.operatorGatewayTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - LiveActivityManager.shared.handleDisconnect() + LiveActivityManager.shared.endActivity(reason: "manual_disconnect") self.gatewayHealthMonitor.stop() Task { await self.operatorGateway.disconnect() @@ -1877,7 +1878,7 @@ extension NodeAppModel { self.operatorConnected = false self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - LiveActivityManager.shared.handleDisconnect() + LiveActivityManager.shared.endActivity(reason: "new_gateway_connect") self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) @@ -1908,6 +1909,12 @@ extension NodeAppModel { self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil } + if problem.needsPairingApproval || problem.pauseReconnect { + LiveActivityManager.shared.showAttention( + statusText: problem.needsPairingApproval ? "Approval needed" : "Action required", + agentName: self.activeAgentName, + sessionKey: self.mainSessionKey) + } } private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool { @@ -2112,7 +2119,6 @@ extension NodeAppModel { await self.refreshShareRouteFromGateway() await self.registerAPNsTokenIfNeeded() await self.startVoiceWakeSync() - await MainActor.run { LiveActivityManager.shared.handleReconnect() } await MainActor.run { self.startGatewayHealthMonitor() } }, onDisconnected: { [weak self] reason in @@ -2120,7 +2126,7 @@ extension NodeAppModel { await MainActor.run { self.operatorConnected = false self.talkMode.updateGatewayConnected(false) - LiveActivityManager.shared.handleDisconnect() + LiveActivityManager.shared.endActivity(reason: "operator_disconnected") } GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)") await MainActor.run { self.stopGatewayHealthMonitor() } @@ -2186,14 +2192,10 @@ extension NodeAppModel { self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" self.gatewayServerName = nil self.gatewayRemoteAddress = nil - let liveActivity = LiveActivityManager.shared - if liveActivity.isActive { - liveActivity.handleConnecting() - } else { - liveActivity.startActivity( - agentName: self.selectedAgentId ?? "main", - sessionKey: self.mainSessionKey) - } + LiveActivityManager.shared.showConnecting( + statusText: (attempt == 0) ? "Connecting..." : "Reconnecting...", + agentName: self.activeAgentName, + sessionKey: self.mainSessionKey) } do { @@ -2220,6 +2222,7 @@ extension NodeAppModel { self.gatewayConnected = true self.screen.errorText = nil UserDefaults.standard.set(true, forKey: "gateway.autoconnect") + LiveActivityManager.shared.handleReconnect() } let usedBootstrapToken = reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && @@ -2360,6 +2363,7 @@ extension NodeAppModel { await MainActor.run { self.lastGatewayProblem = nil self.gatewayStatusText = "Offline" + LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped") self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil