mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-25 01:33:03 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user