fix(ios): improve live activity lifecycle (#83597)

Merged via squash.

Prepared head SHA: 6bd991dafb
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
Nimrod Gutman
2026-05-18 16:11:54 +03:00
committed by GitHub
parent 29f39db857
commit b823a5a266
5 changed files with 161 additions and 56 deletions

View File

@@ -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

View File

@@ -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<OpenClawActivityAttributes>) -> 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
}
}

View File

@@ -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<OpenClawActivityAttributes>?
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<OpenClawActivityAttributes>) {
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",

View File

@@ -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

View File

@@ -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