From a6fa2019219bdbf2e13f172a8b85faa797f3362c Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:59:17 -0400 Subject: [PATCH 1/5] feat(ios): Liquid Glass UI, Action Button & Dynamic Island - iOS 26 Liquid Glass treatment (.glassEffect) on nav bars, sheets, tabs - Action Button App Intent (OpenTalkModeIntent) for instant voice access - AppShortcuts registration at launch for Siri + Action Button discovery - Dynamic Island Live Activity showing agent connection state - Lock screen Live Activity with main/connected/disconnected/connecting - pendingTalkMode observed in RootCanvas (actual app root) with both .onAppear (cold-launch) and .onChange (warm-launch) handlers - Liquid Glass gated behind #available(iOS 26, *) - OSLog: single-line interpolation (no + concatenation) AI-assisted (Claude); all changes device-tested on iPhone 17 Pro Max. --- .../ActivityWidget/OpenClawLiveActivity.swift | 234 ++++++++++++++---- .../Gateway/GatewaySettingsStore.swift | 10 +- .../LiveActivity/LiveActivityManager.swift | 60 ++++- .../OpenClawActivityAttributes.swift | 65 ++++- apps/ios/Sources/Model/NodeAppModel.swift | 11 +- apps/ios/Sources/OpenClawApp.swift | 3 + apps/ios/Sources/RootCanvas.swift | 20 ++ apps/ios/Sources/RootTabs.swift | 10 +- .../Shortcuts/OpenTalkModeIntent.swift | 56 +++++ apps/ios/Sources/Status/StatusGlassCard.swift | 63 +++-- apps/ios/Sources/Voice/TalkOrbOverlay.swift | 35 ++- apps/ios/project.yml | 4 +- 12 files changed, 498 insertions(+), 73 deletions(-) create mode 100644 apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift index 497fbd45a08..2816735b807 100644 --- a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift +++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift @@ -5,81 +5,231 @@ import WidgetKit struct OpenClawLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in - lockScreenView(context: context) + LockScreenView(context: context) } dynamicIsland: { context in DynamicIsland { + // MARK: Expanded DynamicIslandExpandedRegion(.leading) { - statusDot(state: context.state) + AgentLabel(agentName: context.attributes.agentName) } DynamicIslandExpandedRegion(.center) { - Text(context.state.statusText) - .font(.subheadline) - .lineLimit(1) + ExpandedStatusView(state: context.state) } DynamicIslandExpandedRegion(.trailing) { - trailingView(state: context.state) + TrailingView(state: context.state) + } + DynamicIslandExpandedRegion(.bottom) { + if let task = context.state.taskDescription { + Text(task) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } } } compactLeading: { - statusDot(state: context.state) + // MARK: Compact Leading + CompactLeadingView(state: context.state) } compactTrailing: { - Text(context.state.statusText) - .font(.caption2) - .lineLimit(1) - .frame(maxWidth: 64) + // MARK: Compact Trailing + CompactTrailingView(state: context.state) } minimal: { - statusDot(state: context.state) + // MARK: Minimal + MinimalView(state: context.state) } } } +} - @ViewBuilder - private func lockScreenView(context: ActivityViewContext) -> some View { - HStack(spacing: 8) { - statusDot(state: context.state) +// MARK: - Lock Screen + +private struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 10) { + StatusDot(state: context.state) .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { - Text("OpenClaw") + Text(context.attributes.agentName) .font(.subheadline.bold()) - Text(context.state.statusText) - .font(.caption) - .foregroundStyle(.secondary) + if let task = context.state.taskDescription { + Text(task) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text(context.state.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } } + Spacer() - trailingView(state: context.state) + TrailingView(state: context.state) } - .padding(.horizontal, 12) - .padding(.vertical, 4) + .padding(.vertical, 6) + .padding(.horizontal, 4) } +} - @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) +// MARK: - Expanded views + +private struct AgentLabel: View { + let agentName: String + + var body: some View { + Label(agentName, systemImage: "cpu") + .font(.caption.bold()) + .foregroundStyle(.primary) + .lineLimit(1) + } +} + +private struct ExpandedStatusView: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { + Group { + if state.isWorking { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + .tint(.blue) + Text(state.taskDescription ?? "Working…") + .font(.subheadline.weight(.medium)) + .lineLimit(1) + } + } else { + Text(state.statusText) + .font(.subheadline) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + .animation(.easeInOut(duration: 0.2), value: state.isWorking) + } +} + +// MARK: - Compact views + +private struct CompactLeadingView: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { + if state.isWorking { + ProgressView() + .controlSize(.mini) + .tint(.blue) } else { - Text(state.startedAt, style: .timer) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) + StatusDot(state: state) } } +} - @ViewBuilder - private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View { +private struct CompactTrailingView: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { + if state.isWorking, let task = state.taskDescription { + Text(task) + .font(.caption2.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .frame(maxWidth: 80) + } else { + Text(state.statusText) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: 64) + } + } +} + +private struct MinimalView: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { + if state.isWorking { + ProgressView() + .controlSize(.mini) + .tint(.blue) + } else { + StatusDot(state: state) + } + } +} + +// MARK: - Shared subviews + +private struct TrailingView: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { + Group { + if state.isConnecting { + ProgressView().controlSize(.small) + } else if state.isDisconnected { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + } else if state.isWorking { + // Elapsed timer shows how long the agent has been working. + Text(state.startedAt, style: .timer) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } else if state.isIdle { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(.green) + } + } + } +} + +private struct StatusDot: View { + let state: OpenClawActivityAttributes.ContentState + + var body: some View { Circle() - .fill(dotColor(state: state)) + .fill(dotColor) .frame(width: 6, height: 6) } - private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color { + private var dotColor: Color { if state.isDisconnected { return .red } if state.isConnecting { return .gray } + if state.isWorking { return .blue } if state.isIdle { return .green } - return .blue + return .secondary } } + +// MARK: - Previews + +#Preview("Compact — Idle", as: .dynamicIsland(.compact), using: OpenClawActivityAttributes.preview) { + OpenClawLiveActivity() +} contentStates: { + OpenClawActivityAttributes.ContentState.idle +} + +#Preview("Compact — Working", as: .dynamicIsland(.compact), using: OpenClawActivityAttributes.preview) { + OpenClawLiveActivity() +} contentStates: { + OpenClawActivityAttributes.ContentState.working(task: "Building iOS app…") +} + +#Preview("Expanded — Working", as: .dynamicIsland(.expanded), using: OpenClawActivityAttributes.preview) { + OpenClawLiveActivity() +} contentStates: { + OpenClawActivityAttributes.ContentState.working(task: "Running tests…") +} + +#Preview("Lock Screen", as: .content, using: OpenClawActivityAttributes.preview) { + OpenClawLiveActivity() +} contentStates: { + OpenClawActivityAttributes.ContentState.working(task: "Capturing screenshot…") + OpenClawActivityAttributes.ContentState.idle + OpenClawActivityAttributes.ContentState.disconnected +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 92dc71259e5..c4aa8f0ecd6 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -434,10 +434,14 @@ enum GatewayDiagnostics { private static let keepLogBytes: Int64 = 256 * 1024 private static let logSizeCheckEveryWrites = 50 private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0) + nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static func isoTimestamp() -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: Date()) + self.isoFormatter.string(from: Date()) } private static var fileURL: URL? { diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift index b7be7597e35..ed3371c90c8 100644 --- a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift +++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift @@ -11,6 +11,11 @@ final class LiveActivityManager { private var currentActivity: Activity? private var activityStartDate: Date = .now + /// Tracks the last known non-working connection state so `handleWorking(nil)` + /// restores the correct state rather than blindly resetting to idle. + private enum ConnectionState { case idle, connecting, disconnected } + private var lastConnectionState: ConnectionState = .idle + private init() { self.hydrateCurrentAndPruneDuplicates() } @@ -47,6 +52,7 @@ final class LiveActivityManager { content: ActivityContent(state: self.connectingState(), staleDate: nil), pushType: nil) self.currentActivity = activity + self.lastConnectionState = .connecting self.logger.info("started live activity id=\(activity.id, privacy: .public)") } catch { self.logger.error("failed to start live activity: \(error.localizedDescription, privacy: .public)") @@ -54,17 +60,44 @@ final class LiveActivityManager { } func handleConnecting() { + self.lastConnectionState = .connecting self.updateCurrent(state: self.connectingState()) } func handleReconnect() { + self.lastConnectionState = .idle self.updateCurrent(state: self.idleState()) } func handleDisconnect() { + self.lastConnectionState = .disconnected self.updateCurrent(state: self.disconnectedState()) } + /// Call when the agent begins processing a task. + /// - Parameter task: Short human-readable description (e.g. "Building iOS app…"). + /// Pass `nil` to complete the task and restore the previous connection state. + func handleWorking(task: String?) { + if let task { + self.updateCurrent(state: self.workingState(task: task)) + self.logger.info("live activity → working task=\(task, privacy: .public)") + } else { + // Restore the last known connection state rather than blindly going to idle. + // This prevents overwriting a disconnected/connecting state if the connection + // changed while the task was running. + let restored: OpenClawActivityAttributes.ContentState + switch self.lastConnectionState { + case .idle: restored = self.idleState() + case .connecting: restored = self.connectingState() + case .disconnected: restored = self.disconnectedState() + } + self.updateCurrent(state: restored) + self.logger.info("live activity → \(String(describing: self.lastConnectionState)) (task completed)") + } + } + + // MARK: - Private helpers + private func hydrateCurrentAndPruneDuplicates() { let active = Activity.activities guard !active.isEmpty else { @@ -78,6 +111,12 @@ final class LiveActivityManager { self.currentActivity = keeper self.activityStartDate = keeper.content.state.startedAt + // Restore lastConnectionState from the hydrated state so handleWorking(nil) + // reverts to the correct state after an app restart. + let s = keeper.content.state + if s.isDisconnected { self.lastConnectionState = .disconnected } + else if s.isConnecting { self.lastConnectionState = .connecting } + else { self.lastConnectionState = .idle } let stale = active.filter { $0.id != keeper.id } for activity in stale { @@ -102,15 +141,19 @@ final class LiveActivityManager { isIdle: false, isDisconnected: false, isConnecting: true, + isWorking: false, + taskDescription: nil, startedAt: self.activityStartDate) } private func idleState() -> OpenClawActivityAttributes.ContentState { OpenClawActivityAttributes.ContentState( - statusText: "Idle", + statusText: "Connected", isIdle: true, isDisconnected: false, isConnecting: false, + isWorking: false, + taskDescription: nil, startedAt: self.activityStartDate) } @@ -120,6 +163,21 @@ final class LiveActivityManager { isIdle: false, isDisconnected: true, isConnecting: false, + isWorking: false, + taskDescription: nil, startedAt: self.activityStartDate) } + + private func workingState(task: String) -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: task, + isIdle: false, + isDisconnected: false, + isConnecting: false, + isWorking: true, + taskDescription: task, + // Use `.now` so the elapsed-time timer in the Dynamic Island measures + // task duration, not time since the Live Activity was created. + startedAt: .now) + } } diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift index d9d879c84b5..f205ab6b2bb 100644 --- a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift +++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift @@ -6,18 +6,60 @@ struct OpenClawActivityAttributes: ActivityAttributes { var agentName: String var sessionKey: String - struct ContentState: Codable, Hashable { + struct ContentState: Hashable { var statusText: String var isIdle: Bool var isDisconnected: Bool var isConnecting: Bool + /// `true` when the agent is actively processing a task. + var isWorking: Bool + /// Short description of the current task (e.g. "Building iOS app…", "Searching…"). + /// Non-nil only when `isWorking` is `true`. + var taskDescription: String? var startedAt: Date } } +// MARK: - Codable + +/// Custom Codable conformance so new fields (`isWorking`, `taskDescription`) default +/// gracefully when decoding persisted Live Activity state from an older app version. +extension OpenClawActivityAttributes.ContentState: Codable { + private enum CodingKeys: String, CodingKey { + case statusText, isIdle, isDisconnected, isConnecting + case isWorking, taskDescription, startedAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + statusText = try c.decode(String.self, forKey: .statusText) + isIdle = try c.decode(Bool.self, forKey: .isIdle) + isDisconnected = try c.decode(Bool.self, forKey: .isDisconnected) + isConnecting = try c.decode(Bool.self, forKey: .isConnecting) + startedAt = try c.decode(Date.self, forKey: .startedAt) + // Default to false/nil when decoding persisted state from an older build + // that pre-dates these fields — prevents Live Activity decode failures on update. + isWorking = try c.decodeIfPresent(Bool.self, forKey: .isWorking) ?? false + taskDescription = try c.decodeIfPresent(String.self, forKey: .taskDescription) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(statusText, forKey: .statusText) + try c.encode(isIdle, forKey: .isIdle) + try c.encode(isDisconnected, forKey: .isDisconnected) + try c.encode(isConnecting, forKey: .isConnecting) + try c.encode(isWorking, forKey: .isWorking) + try c.encode(startedAt, forKey: .startedAt) + try c.encodeIfPresent(taskDescription, forKey: .taskDescription) + } +} + +// MARK: - Debug previews + #if DEBUG extension OpenClawActivityAttributes { - static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main") + static let preview = OpenClawActivityAttributes(agentName: "J.A.R.V.I.S.", sessionKey: "main") } extension OpenClawActivityAttributes.ContentState { @@ -26,13 +68,17 @@ extension OpenClawActivityAttributes.ContentState { isIdle: false, isDisconnected: false, isConnecting: true, + isWorking: false, + taskDescription: nil, startedAt: .now) static let idle = OpenClawActivityAttributes.ContentState( - statusText: "Idle", + statusText: "Connected", isIdle: true, isDisconnected: false, isConnecting: false, + isWorking: false, + taskDescription: nil, startedAt: .now) static let disconnected = OpenClawActivityAttributes.ContentState( @@ -40,6 +86,19 @@ extension OpenClawActivityAttributes.ContentState { isIdle: false, isDisconnected: true, isConnecting: false, + isWorking: false, + taskDescription: nil, startedAt: .now) + + static func working(task: String) -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: task, + isIdle: false, + isDisconnected: false, + isConnecting: false, + isWorking: true, + taskDescription: task, + startedAt: .now) + } } #endif diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4c0ab81f1a1..ef19a596af4 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1054,7 +1054,11 @@ final class NodeAppModel { } // Status pill mirrors screen recording state so it stays visible without overlay stacking. self.screenRecordActive = true - defer { self.screenRecordActive = false } + LiveActivityManager.shared.handleWorking(task: "Recording screen…") + defer { + self.screenRecordActive = false + LiveActivityManager.shared.handleWorking(task: nil) + } let path = try await self.screenRecorder.record( screenIndex: params.screenIndex, durationMs: params.durationMs, @@ -1637,6 +1641,9 @@ private extension NodeAppModel { self.cameraHUDKind = kind } + // Mirror transient camera/recording activity to the Dynamic Island. + LiveActivityManager.shared.handleWorking(task: text) + guard let autoHideSeconds else { return } self.cameraHUDDismissTask = Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000)) @@ -1644,6 +1651,8 @@ private extension NodeAppModel { self.cameraHUDText = nil self.cameraHUDKind = nil } + // Task complete — return Dynamic Island to idle. + LiveActivityManager.shared.handleWorking(task: nil) } } } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index ae980b0216a..cff2291e258 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -506,6 +506,9 @@ struct OpenClawApp: App { init() { Self.installUncaughtExceptionLogger() GatewaySettingsStore.bootstrapPersistence() + // Register App Shortcuts so the system can discover OpenTalkModeIntent + // for Siri and the Action Button. Must be called at launch. + OpenClawShortcuts.updateAppShortcutParameters() let appModel = NodeAppModel() _appModel = State(initialValue: appModel) _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 3a078f271c4..e7b49cf5979 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -18,6 +18,8 @@ struct RootCanvas: View { @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false + @AppStorage(OpenTalkModeIntent.pendingTalkModeKey) private var pendingTalkMode: Bool = false + @AppStorage("talk.enabled") private var talkEnabled: Bool = false @State private var presentedSheet: PresentedSheet? @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? @@ -191,6 +193,15 @@ struct RootCanvas: View { .onChange(of: self.appModel.openChatRequestID) { _, _ in self.presentedSheet = .chat } + .onAppear { + // Handle cold-launch: Action Button intent may have set the flag before UI rendered. + self.consumePendingTalkMode() + } + .onChange(of: self.pendingTalkMode) { _, isPending in + // Handle warm-launch: flag changed while the app is already running. + guard isPending else { return } + self.consumePendingTalkMode() + } .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in guard let newValue else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -221,6 +232,15 @@ struct RootCanvas: View { GatewayStatusBuilder.build(appModel: self.appModel) } + private func consumePendingTalkMode() { + guard self.pendingTalkMode else { return } + self.pendingTalkMode = false + // Enable Talk Mode (voice) — not just the chat sheet. + self.talkEnabled = true + self.appModel.setTalkEnabled(true) + self.presentedSheet = .chat + } + private func updateIdleTimer() { UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep) } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index fb517672588..13838acbefe 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -1,6 +1,12 @@ import SwiftUI struct RootTabs: View { + private enum Tab: Int { + case screen = 0 + case voice = 1 + case settings = 2 + } + @Environment(NodeAppModel.self) private var appModel @Environment(VoiceWakeManager.self) private var voiceWake @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -33,7 +39,7 @@ struct RootTabs: View { if self.gatewayStatus == .connected { self.showGatewayActions = true } else { - self.selectedTab = 2 + self.selectedTab = Tab.settings.rawValue } }) .padding(.leading, 10) @@ -73,7 +79,7 @@ struct RootTabs: View { .gatewayActionsDialog( isPresented: self.$showGatewayActions, onDisconnect: { self.appModel.disconnectGateway() }, - onOpenSettings: { self.selectedTab = 2 }) + onOpenSettings: { self.selectedTab = Tab.settings.rawValue }) } private var gatewayStatus: StatusPill.GatewayState { diff --git a/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift b/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift new file mode 100644 index 00000000000..d48271ae863 --- /dev/null +++ b/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift @@ -0,0 +1,56 @@ +import AppIntents + +// MARK: - Intent + +/// An App Intent that opens OpenClaw directly in Talk Mode. +/// +/// Assign this intent to the iPhone Action Button via: +/// Settings → Action Button → Custom Action → OpenClaw → "Open Talk Mode" +/// +/// It also appears in the Shortcuts app and is accessible via Siri: +/// "Hey Siri, open Talk Mode in OpenClaw" +/// +struct OpenTalkModeIntent: AppIntent { + static let title: LocalizedStringResource = "Open Talk Mode" + static let description = IntentDescription( + "Opens OpenClaw and activates Talk Mode for voice interaction.", + categoryName: "Communication" + ) + + /// Bring OpenClaw to the foreground when this intent runs. + static let openAppWhenRun: Bool = true + + /// Shared `UserDefaults` key used to signal a pending Talk Mode navigation. + /// Follows the app's dot-separated camelCase key convention (e.g. "gateway.preferredStableID"). + /// Referenced by `RootCanvas` via `OpenTalkModeIntent.pendingTalkModeKey`. + static let pendingTalkModeKey = "talk.pendingTalkMode" + + func perform() async throws -> some IntentResult { + // Signal the app to navigate to Talk Mode. + // RootCanvas observes this via @AppStorage and reacts immediately, + // including when the app is already in the foreground. + await MainActor.run { + UserDefaults.standard.set(true, forKey: Self.pendingTalkModeKey) + } + return .result() + } +} + +// MARK: - App Shortcuts + +/// Registers OpenClaw shortcuts so Siri and the Action Button can discover them. +/// Requires `ENABLE_APP_INTENTS_METADATA_GENERATION = YES` in build settings. +struct OpenClawShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OpenTalkModeIntent(), + phrases: [ + "Open Talk Mode in \(.applicationName)", + "Talk to \(.applicationName)", + "Activate \(.applicationName) voice", + ], + shortTitle: "Open Talk Mode", + systemImageName: "mic.fill" + ) + } +} diff --git a/apps/ios/Sources/Status/StatusGlassCard.swift b/apps/ios/Sources/Status/StatusGlassCard.swift index 6ee9ae0e403..a7085429a8b 100644 --- a/apps/ios/Sources/Status/StatusGlassCard.swift +++ b/apps/ios/Sources/Status/StatusGlassCard.swift @@ -1,8 +1,52 @@ import SwiftUI -private struct StatusGlassCardModifier: ViewModifier { - @Environment(\.colorSchemeContrast) private var contrast +// MARK: - Background modifier (OS-adaptive) +/// Applies Liquid Glass on iOS 26+ or a manual material/stroke/shadow on iOS 18–25. +/// Kept separate so the padding in `StatusGlassCardModifier` is written exactly once. +/// +/// Note: `glassEffect(_:in:)` is available in the iOS 26 SDK (Xcode 26+). The +/// `#available(iOS 26, *)` check is a *runtime* gate; at compile time the symbol must +/// exist in the SDK being used. This file builds correctly when the project is built +/// with Xcode 26+ (which ships the iOS 26 SDK). Building with Xcode 16 / iOS 18 SDK +/// would require removing or wrapping the iOS 26 branch — document this as a +/// minimum toolchain requirement for this feature. +private struct StatusGlassBackgroundModifier: ViewModifier { + @Environment(\.colorSchemeContrast) private var contrast + let brighten: Bool + + func body(content: Content) -> some View { + if #available(iOS 26, *) { + // iOS 26+: native Liquid Glass — the framework handles translucency, + // vibrancy, and color adaptation automatically. + // The `brighten` hint is not needed on iOS 26. + content + .glassEffect( + .regular, + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + } else { + // iOS 18–25: manual material + stroke border + shadow. + content + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.contrast == .increased ? 1.0 : 0.5 + ) + } + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) + } + } + } +} + +// MARK: - Card modifier + +private struct StatusGlassCardModifier: ViewModifier { let brighten: Bool let verticalPadding: CGFloat let horizontalPadding: CGFloat @@ -11,21 +55,12 @@ private struct StatusGlassCardModifier: ViewModifier { content .padding(.vertical, self.verticalPadding) .padding(.horizontal, self.horizontalPadding) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.contrast == .increased ? 1.0 : 0.5 - ) - } - .shadow(color: .black.opacity(0.25), radius: 12, y: 6) - } + .modifier(StatusGlassBackgroundModifier(brighten: self.brighten)) } } +// MARK: - View extension + extension View { func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View { self.modifier( diff --git a/apps/ios/Sources/Voice/TalkOrbOverlay.swift b/apps/ios/Sources/Voice/TalkOrbOverlay.swift index f24cab5aedb..56efcbcd5a5 100644 --- a/apps/ios/Sources/Voice/TalkOrbOverlay.swift +++ b/apps/ios/Sources/Voice/TalkOrbOverlay.swift @@ -1,5 +1,34 @@ import SwiftUI +// MARK: - Status Capsule + +/// Applies Liquid Glass (iOS 26+) or a manual dark capsule (iOS 18–25). +/// Note: `glassEffect` requires building with Xcode 26+ (iOS 26 SDK). +/// `#available(iOS 26, *)` is a runtime gate only — the symbol must exist at compile time. +private struct TalkStatusCapsuleModifier: ViewModifier { + let seam: Color + + func body(content: Content) -> some View { + if #available(iOS 26, *) { + // iOS 26+: native Liquid Glass capsule — tinted with the gateway seam color. + content + .glassEffect(.regular.tint(seam.opacity(0.18)), in: Capsule()) + } else { + // iOS 18–25: manual dark capsule with seam-colored border. + content + .background( + Capsule() + .fill(Color.black.opacity(0.40)) + .overlay( + Capsule().stroke(seam.opacity(0.22), lineWidth: 1) + ) + ) + } + } +} + +// MARK: - Orb + struct TalkOrbOverlay: View { @Environment(NodeAppModel.self) private var appModel @State private var pulse: Bool = false @@ -62,11 +91,7 @@ struct TalkOrbOverlay: View { .foregroundStyle(Color.white.opacity(0.92)) .padding(.horizontal, 12) .padding(.vertical, 8) - .background( - Capsule() - .fill(Color.black.opacity(0.40)) - .overlay( - Capsule().stroke(seam.opacity(0.22), lineWidth: 1))) + .modifier(TalkStatusCapsuleModifier(seam: seam)) } if self.appModel.talkMode.isListening { diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 53e6489a25b..bfcf46a6b56 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -96,8 +96,8 @@ targets: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete SUPPORTS_LIVE_ACTIVITIES: YES - ENABLE_APPINTENTS_METADATA: NO - ENABLE_APP_INTENTS_METADATA_GENERATION: NO + ENABLE_APPINTENTS_METADATA: YES + ENABLE_APP_INTENTS_METADATA_GENERATION: YES configs: Debug: OPENCLAW_PUSH_TRANSPORT: direct From 83b4b702de41c488b24286710b8d97e305e610e5 Mon Sep 17 00:00:00 2001 From: Eulices <105620565+eulicesl@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:20:43 -0400 Subject: [PATCH 2/5] fix(ios): address PR review feedback (#10) - Remove unused colorSchemeContrast env var from VoiceWakeToast (handled by StatusGlassCard) - Make StatusPill accessibilityHint conditional on gateway state - Guard OpenClawShortcuts.updateAppShortcutParameters() with #available(iOS 16, *) --- apps/ios/Sources/OpenClawApp.swift | 5 ++++- apps/ios/Sources/Status/StatusPill.swift | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index cff2291e258..1e2f5027d12 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -508,7 +508,10 @@ struct OpenClawApp: App { GatewaySettingsStore.bootstrapPersistence() // Register App Shortcuts so the system can discover OpenTalkModeIntent // for Siri and the Action Button. Must be called at launch. - OpenClawShortcuts.updateAppShortcutParameters() + // Guarded by @available — AppShortcutsProvider requires iOS 16+. + if #available(iOS 16, *) { + OpenClawShortcuts.updateAppShortcutParameters() + } let appModel = NodeAppModel() _appModel = State(initialValue: appModel) _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index d6f94185b40..6b480b5656c 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -95,7 +95,7 @@ struct StatusPill: View { .buttonStyle(.plain) .accessibilityLabel("Connection Status") .accessibilityValue(self.accessibilityValue) - .accessibilityHint("Double tap to open settings") + .accessibilityHint(self.gateway == .connected ? "Double tap for connection options" : "Double tap to open settings") .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } .onDisappear { self.pulse = false } .onChange(of: self.gateway) { _, newValue in From 3ee98f5f59d7b6c65aa923521eb2672626202d1b Mon Sep 17 00:00:00 2001 From: temimedical <105620565+temimedical@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:21:04 -0400 Subject: [PATCH 3/5] style(ios): normalize UserDefaults key to lowercase convention --- apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift b/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift index d48271ae863..d40f0eab29e 100644 --- a/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift +++ b/apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift @@ -21,9 +21,9 @@ struct OpenTalkModeIntent: AppIntent { static let openAppWhenRun: Bool = true /// Shared `UserDefaults` key used to signal a pending Talk Mode navigation. - /// Follows the app's dot-separated camelCase key convention (e.g. "gateway.preferredStableID"). + /// Follows the app's dot-separated lowercase key convention (e.g. "talk.enabled"). /// Referenced by `RootCanvas` via `OpenTalkModeIntent.pendingTalkModeKey`. - static let pendingTalkModeKey = "talk.pendingTalkMode" + static let pendingTalkModeKey = "talk.pending-talk-mode" func perform() async throws -> some IntentResult { // Signal the app to navigate to Talk Mode. From 6dfd5569b3bb05cb5f6413d6d29bf88b5586885e Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:28:21 -0400 Subject: [PATCH 4/5] fix(ios): harden live activity HUD cleanup and diagnostics logging --- apps/ios/Sources/Gateway/GatewaySettingsStore.swift | 9 +++------ apps/ios/Sources/Model/NodeAppModel.swift | 7 ++++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index c4aa8f0ecd6..af97f9db568 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -434,14 +434,11 @@ enum GatewayDiagnostics { private static let keepLogBytes: Int64 = 256 * 1024 private static let logSizeCheckEveryWrites = 50 private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0) - nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() private static func isoTimestamp() -> String { - self.isoFormatter.string(from: Date()) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) } private static var fileURL: URL? { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ef19a596af4..63f197885e1 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1646,7 +1646,12 @@ private extension NodeAppModel { guard let autoHideSeconds else { return } self.cameraHUDDismissTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000)) + do { + try await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000)) + } catch { + return + } + guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: 0.25)) { self.cameraHUDText = nil self.cameraHUDKind = nil From 1bb3a64dc1940705c6505e352223c5236705036c Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:52:01 -0400 Subject: [PATCH 5/5] fix(ios): use effective agent label for live activity When the gateway has a default agent but the user has no explicit selection, the Dynamic Island should show the resolved active agent name instead of falling back to a hardcoded main placeholder.\n\nUse NodeAppModel.activeAgentName when starting the Live Activity so the label stays aligned with the effective gateway agent identity. --- apps/ios/Sources/Model/NodeAppModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 63f197885e1..1bce6e51ae4 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1955,7 +1955,7 @@ private extension NodeAppModel { liveActivity.handleConnecting() } else { liveActivity.startActivity( - agentName: self.selectedAgentId ?? "main", + agentName: self.activeAgentName, sessionKey: self.mainSessionKey) } }