Merge 1bb3a64dc1940705c6505e352223c5236705036c into 43513cd1df63af0704dfb351ee7864607f955dcc

This commit is contained in:
Eulices 2026-03-20 22:37:32 -07:00 committed by GitHub
commit 9f93eb9730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 503 additions and 73 deletions

View File

@ -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<OpenClawActivityAttributes>) -> some View {
HStack(spacing: 8) {
statusDot(state: context.state)
// MARK: - Lock Screen
private struct LockScreenView: View {
let context: ActivityViewContext<OpenClawActivityAttributes>
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
}

View File

@ -434,6 +434,7 @@ enum GatewayDiagnostics {
private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
private static func isoTimestamp() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

View File

@ -11,6 +11,11 @@ final class LiveActivityManager {
private var currentActivity: Activity<OpenClawActivityAttributes>?
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<OpenClawActivityAttributes>.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)
}
}

View File

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

View File

@ -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,13 +1641,23 @@ 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))
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
}
// Task complete return Dynamic Island to idle.
LiveActivityManager.shared.handleWorking(task: nil)
}
}
}
@ -1941,7 +1955,7 @@ private extension NodeAppModel {
liveActivity.handleConnecting()
} else {
liveActivity.startActivity(
agentName: self.selectedAgentId ?? "main",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
}

View File

@ -506,6 +506,12 @@ 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.
// 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))

View File

@ -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<Void, Never>?
@ -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)
}

View File

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

View File

@ -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 lowercase key convention (e.g. "talk.enabled").
/// Referenced by `RootCanvas` via `OpenTalkModeIntent.pendingTalkModeKey`.
static let pendingTalkModeKey = "talk.pending-talk-mode"
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"
)
}
}

View File

@ -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 1825.
/// 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 1825: 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(

View File

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

View File

@ -1,5 +1,34 @@
import SwiftUI
// MARK: - Status Capsule
/// Applies Liquid Glass (iOS 26+) or a manual dark capsule (iOS 1825).
/// 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 1825: 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 {

View File

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