import SwiftUI import WatchKit struct WatchInboxView: View { var store: WatchInboxStore var onAction: ((WatchPromptAction) -> Void)? var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? var onRefreshAppSnapshot: (() -> Void)? var onAppCommand: ((WatchAppCommand) -> Void)? var onSendChatMessage: ((String) -> Void)? var body: some View { NavigationStack { WatchControlSurfaceView( store: self.store, onAction: self.onAction, onExecApprovalDecision: self.onExecApprovalDecision, onRefreshExecApprovalReview: self.onRefreshExecApprovalReview, onRefreshAppSnapshot: self.onRefreshAppSnapshot, onAppCommand: self.onAppCommand, onSendChatMessage: self.onSendChatMessage) .toolbar(.hidden, for: .navigationBar) } } } private struct WatchControlSurfaceView: View { var store: WatchInboxStore var onAction: ((WatchPromptAction) -> Void)? var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? var onRefreshAppSnapshot: (() -> Void)? var onAppCommand: ((WatchAppCommand) -> Void)? var onSendChatMessage: ((String) -> Void)? @State private var selectedFace = 0 var body: some View { TabView(selection: self.$selectedFace) { self.nowFace .tag(0) self.stackFace .tag(1) self.approvalsFace .tag(2) } .tabViewStyle(.page(indexDisplayMode: .never)) .background(WatchClawStyle.background.ignoresSafeArea()) .navigationTitle("") } private var faceCount: Int { 3 } private var pageRail: some View { WatchPageRail(selectedIndex: self.selectedFace, pageCount: self.faceCount) .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, -5) .allowsHitTesting(false) } private var avatarImageSource: String? { WatchAvatarSource.normalized(self.store.appSnapshot?.agentAvatarURL) } private var avatarText: String? { WatchAvatarSource.normalized(self.store.appSnapshot?.agentAvatarText) } private var nowFace: some View { WatchFaceScroll { self.pageRail WatchFaceHeader( section: "Now", title: self.greetingText, subtitle: self.connectionLine, isOnline: self.store.appSnapshot?.gatewayConnected == true, avatarImageSource: self.avatarImageSource, avatarText: self.avatarText) NavigationLink { self.primaryDestination } label: { WatchHeroCard( label: self.primaryLabel, title: self.primaryTitle, subtitle: self.primarySubtitle, accessory: self.store.talkSummaryText) } .buttonStyle(.plain) NavigationLink { self.chatTimelineDestination } label: { WatchPrimaryLabel(title: "Talk to Claw") } .buttonStyle(.plain) if self.chatCount > 0 || self.approvalCount > 0 { WatchCompactStatusStrip( inboxCount: self.chatCountText, approvalCount: self.approvalCountText, status: self.statusLine) } } } private var stackFace: some View { WatchFaceScroll { self.pageRail WatchFaceHeader( section: "Inbox", title: "What needs you", subtitle: self.inboxSubtitle, isOnline: self.chatCount > 0 || self.approvalCount > 0, avatarImageSource: self.avatarImageSource, avatarText: self.avatarText) if self.inboxHasItems { if self.approvalCount > 0 { self.inboxApprovalsLink self.inboxChatLink } else { self.inboxChatLink self.inboxApprovalsLink } self.inboxPromptBlock } else { WatchHeroCard( label: "Clear", title: "Caught up", subtitle: self.store.hasAppSnapshot ? "No chats or approvals need you" : "Waiting for iPhone sync", accessory: "Ready") } Button { self.onAppCommand?(.openChat) } label: { WatchSecondaryLabel(title: "Continue on iPhone") } .buttonStyle(.plain) } } @ViewBuilder private var inboxChatLink: some View { if self.chatCount > 0 { NavigationLink { self.chatTimelineDestination } label: { WatchStackCard( label: "Chat", title: self.chatPreviewTitle, subtitle: self.chatPreviewSubtitle, badge: "\(self.chatCount)", isProminent: self.approvalCount == 0) } .buttonStyle(.plain) } } @ViewBuilder private var inboxApprovalsLink: some View { if self.approvalCount > 0 { NavigationLink { WatchExecApprovalListView(store: self.store, onDecision: self.onExecApprovalDecision) } label: { WatchStackCard( label: "Approvals", title: self.approvalHeadline, subtitle: self.approvalSubtitle, badge: "\(self.approvalCount)", isProminent: true) } .buttonStyle(.plain) } } @ViewBuilder private var inboxPromptBlock: some View { if self.store.hasMessagePrompt { WatchHeroCard( label: self.store.kind ?? "Latest", title: self.store.title, subtitle: self.store.body, accessory: self.updatedText) if let details = self.promptDetails { WatchDetailText(text: details) } ForEach(self.store.actions) { action in WatchActionCard( title: action.label, subtitle: self.actionSubtitle(action)) { self.onAction?(action) } .disabled(self.store.isReplySending) } if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty { WatchTinyStatus(text: replyStatusText) } } } private var promptDetails: String? { let details = self.store.details?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return details.isEmpty ? nil : details } private var inboxHasItems: Bool { self.chatCount > 0 || self.approvalCount > 0 || self.store.hasMessagePrompt } private var inboxSubtitle: String { if self.approvalCount > 0 { return "Approval waiting" } if self.chatCount > 0 { return self.chatStatusText } if self.store.hasMessagePrompt { return self.store.kind ?? "Latest update" } return self.store.hasAppSnapshot ? "Nothing waiting" : "Waiting for iPhone" } private var approvalsFace: some View { WatchFaceScroll { self.pageRail WatchFaceHeader( section: "Approvals", title: self.approvalHeadline, subtitle: self.approvalHeaderSubtitle, isOnline: self.approvalCount > 0, avatarImageSource: self.avatarImageSource, avatarText: self.avatarText) if let record = self.store.activeExecApproval { WatchHeroCard( label: "Approval needed", title: record.approval.commandPreview ?? record.approval.commandText, subtitle: self.approvalDecisionSubtitle(record), accessory: self.approvalAccessory(record)) if record.isResolving { WatchTinyStatus(text: record.statusText ?? "Sending decision...") } else { HStack(spacing: 8) { if record.approval.allowedDecisions.contains(.allowOnce) { WatchDecisionButton(title: "Approve", color: .green) { self.onExecApprovalDecision?(record.id, .allowOnce) } } if record.approval.allowedDecisions.contains(.deny) { WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) { self.onExecApprovalDecision?(record.id, .deny) } } } } if let statusText = record.statusText, !statusText.isEmpty, !record.isResolving { WatchTinyStatus(text: statusText) } } else { WatchHeroCard( label: "Clear", title: "No approvals waiting", subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up", accessory: "Ready") if self.store.shouldShowExecApprovalReviewStatus { WatchSecondaryButton(title: "Review again") { self.onRefreshExecApprovalReview?() } } } if self.approvalCount > 1 { NavigationLink { WatchExecApprovalListView(store: self.store, onDecision: self.onExecApprovalDecision) } label: { WatchSecondaryLabel(title: "Open all approvals") } .buttonStyle(.plain) } } } private var chatItems: [WatchChatItem] { self.store.appSnapshot?.chatItems ?? [] } private var chatTimelineDestination: some View { WatchChatTimelineView( items: self.chatItems, statusText: self.chatStatusText, sendStatusText: self.chatSendStatusText, avatarImageSource: self.avatarImageSource, avatarText: self.avatarText, onRefresh: self.onRefreshAppSnapshot, onSendMessage: self.onSendChatMessage) } @ViewBuilder private var primaryDestination: some View { if let record = self.store.activeExecApproval { WatchExecApprovalDetailView( store: self.store, record: record, onDecision: self.onExecApprovalDecision) } else { self.chatTimelineDestination } } private var chatCount: Int { self.chatItems.count } private var approvalCount: Int { max(self.store.sortedExecApprovals.count, self.store.appSnapshot?.pendingApprovalCount ?? 0) } private var chatCountText: String { self.chatCount == 0 ? "0" : "\(self.chatCount)" } private var approvalCountText: String { self.approvalCount == 0 ? "0" : "\(self.approvalCount)" } private var connectionLine: String { if let snapshot = self.store.appSnapshot { return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone" } return "Pair iPhone" } private var primaryLabel: String { if self.store.activeExecApproval != nil { return "Next up" } return self.store.appSnapshot?.gatewayConnected == true ? "Running" : "Pairing" } private var primaryTitle: String { if let record = self.store.activeExecApproval { return record.approval.commandPreview ?? record.approval.commandText } if self.chatCount > 0 { return self.chatItems.last?.text ?? self.store.gatewaySummaryText } return self.store.gatewaySummaryText } private var primarySubtitle: String { if self.store.activeExecApproval != nil { return "Approval waiting on your wrist" } if self.chatCount > 0 { return self.chatStatusText } return self.store.hasAppSnapshot ? "Ready for quick actions" : "Waiting for iPhone sync" } private var approvalHeadline: String { self.approvalCount == 1 ? "1 approval waiting" : "\(self.approvalCount) approvals" } private var approvalSubtitle: String { guard let record = self.store.activeExecApproval else { return "No approvals waiting" } return record.approval.commandPreview ?? record.approval.commandText } private var approvalHeaderSubtitle: String { self.approvalCount > 0 ? "Decide from watch" : "No approvals" } private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String { var parts: [String] = [] if let expiresText = self.expiryText(record.approval.expiresAtMs) { parts.append("Expires in \(expiresText)") } if let host = record.approval.host, !host.isEmpty { parts.append(host) } if parts.isEmpty { parts.append("Review before it runs") } return parts.joined(separator: " · ") } private func approvalAccessory(_ record: WatchExecApprovalRecord) -> String { if record.isResolving { return "Sending" } if let risk = self.approvalRiskText(record.approval.risk) { return risk } return "Review" } private func approvalRiskText(_ risk: WatchRiskLevel?) -> String? { switch risk { case .high: "High risk" case .medium: "Medium risk" case .low: "Low risk" case nil: nil } } private var chatPreviewTitle: String { guard let item = self.chatItems.last else { return "No chat synced" } return self.roleTitle(item.role) } private var chatPreviewSubtitle: String { self.chatItems.last?.text ?? self.chatStatusText } private var chatStatusText: String { if let status = self.store.appSnapshot?.chatStatusText, !status.isEmpty { return status } if self.chatCount > 0 { return self.chatCount == 1 ? "1 recent message" : "\(self.chatCount) recent messages" } return self.store.hasAppSnapshot ? "No messages synced" : "Waiting for iPhone" } private var chatSendStatusText: String? { guard let status = self.store.appCommandStatusText, status.hasPrefix("Chat:") else { return nil } return status } private var greetingText: String { if let greetingTextOverride = self.store.greetingTextOverride { return greetingTextOverride } let hour = Calendar.current.component(.hour, from: Date()) if hour < 12 { return "Good morning" } if hour < 18 { return "Good afternoon" } return "Good evening" } private var statusLine: String { if let status = self.store.appSnapshotStatusText, !status.isEmpty { return status } if let commandStatus = self.store.appCommandStatusText, !commandStatus.isEmpty { return commandStatus } if let replyStatus = self.store.replyStatusText, !replyStatus.isEmpty { return replyStatus } return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone" } private var updatedText: String { guard let updatedAt = self.store.updatedAt else { return "Just now" } return updatedAt.formatted(date: .omitted, time: .shortened) } private func roleTitle(_ role: String) -> String { switch role.lowercased() { case "user": "You" case "system": "System" default: "OpenClaw" } } private func actionSubtitle(_ action: WatchPromptAction) -> String { switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "destructive": "Requires confirmation" case "cancel": "Dismiss this update" default: "Send from watch" } } private func expiryText(_ expiresAtMs: Int?) -> String? { guard let expiresAtMs else { return nil } let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "<1m" } return "\(deltaSeconds / 60)m" } } private enum WatchClawStyle { static let accent = Color(red: 1.0, green: 0.2, blue: 0.22) static let background = Color(red: 0.015, green: 0.015, blue: 0.02) static let surface = Color.white.opacity(0.075) static let raised = Color.white.opacity(0.115) static let border = Color.white.opacity(0.10) static let hotGradient = LinearGradient( colors: [Self.accent, Color(red: 0.78, green: 0.05, blue: 0.08)], startPoint: .topLeading, endPoint: .bottomTrailing) } private struct WatchFaceScroll: View { @ViewBuilder var content: Content var body: some View { ScrollView { VStack(alignment: .leading, spacing: 8) { self.content } .padding(.horizontal, 8) .padding(.top, 0) .padding(.bottom, 40) .frame(maxWidth: .infinity, alignment: .topLeading) } .background(WatchClawStyle.background.ignoresSafeArea()) .scrollIndicators(.hidden) } } private enum WatchAvatarSource { static func normalized(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } static func dataImage(from source: String?) -> UIImage? { guard let source = self.normalized(source), source.lowercased().hasPrefix("data:image/"), let commaIndex = source.firstIndex(of: ",") else { return nil } let header = source[.. URL? { guard let source = self.normalized(source), let url = URL(string: source), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return nil } return url } } private struct WatchClawAvatar: View { var size: CGFloat var imageSource: String? var text: String? @State private var dataImage: UIImage? var body: some View { ZStack { Circle() .fill(Color.black.opacity(0.30)) self.avatarContent .padding(self.contentPadding) } .frame(width: self.size, height: self.size) .clipShape(Circle()) .overlay { Circle() .strokeBorder(WatchClawStyle.accent.opacity(0.32), lineWidth: 1) } .shadow(color: WatchClawStyle.accent.opacity(0.30), radius: 5, y: 2) .task(id: WatchAvatarSource.normalized(self.imageSource)) { self.dataImage = WatchAvatarSource.dataImage(from: self.imageSource) } } @ViewBuilder private var avatarContent: some View { if let image = self.dataImage { Image(uiImage: image) .resizable() .scaledToFill() } else if let url = WatchAvatarSource.remoteURL(from: self.imageSource) { AsyncImage(url: url) { phase in switch phase { case let .success(image): image .resizable() .scaledToFill() default: self.fallbackContent } } } else { self.fallbackContent } } @ViewBuilder private var fallbackContent: some View { if let text = WatchAvatarSource.normalized(self.text) { Text(String(text.prefix(3))) .font(.system(size: self.size * 0.42, weight: .bold, design: .rounded)) .foregroundStyle(.white) .minimumScaleFactor(0.6) .lineLimit(1) } else { Image("OpenClawIcon") .resizable() .scaledToFit() } } private var contentPadding: CGFloat { WatchAvatarSource.normalized(self.imageSource) == nil ? self.size * 0.04 : 0 } } private struct WatchFaceHeader: View { let section: String let title: String let subtitle: String let isOnline: Bool var avatarImageSource: String? var avatarText: String? var body: some View { HStack(alignment: .center, spacing: 7) { WatchClawAvatar( size: 23, imageSource: self.avatarImageSource, text: self.avatarText) VStack(alignment: .leading, spacing: 1) { Text(self.section) .font(.system(size: 10, weight: .bold)) .foregroundStyle(WatchClawStyle.accent) .lineLimit(1) Text(self.title) .font(.system(size: 18, weight: .semibold)) .lineLimit(1) .minimumScaleFactor(0.8) Text(self.subtitle) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } } } private struct WatchHeroCard: View { let label: String let title: String let subtitle: String let accessory: String var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center) { Text(self.label) .font(.system(size: 10, weight: .bold)) .foregroundStyle(WatchClawStyle.accent) .lineLimit(1) Spacer(minLength: 4) Text(self.accessory) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .lineLimit(1) } Text(self.title) .font(.system(size: 19, weight: .semibold)) .lineLimit(3) .minimumScaleFactor(0.75) Text(self.subtitle) .font(.system(size: 13)) .foregroundStyle(.secondary) .lineLimit(2) } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 17, style: .continuous) .fill(WatchClawStyle.raised) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .strokeBorder(WatchClawStyle.border, lineWidth: 1) } } } } private struct WatchDetailText: View { let text: String var body: some View { Text(self.text) .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(5) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 8) .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.white.opacity(0.055)) } } } private struct WatchCompactStatusStrip: View { let inboxCount: String let approvalCount: String let status: String var body: some View { HStack(spacing: 5) { WatchCompactMetric(label: "Inbox", value: self.inboxCount) WatchCompactMetric(label: "Approvals", value: self.approvalCount) Text(self.status) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.75) .frame(maxWidth: .infinity, alignment: .trailing) } .padding(.horizontal, 8) .padding(.vertical, 6) .background { Capsule(style: .continuous) .fill(Color.white.opacity(0.06)) } } } private struct WatchCompactMetric: View { let label: String let value: String var body: some View { HStack(spacing: 3) { Text(self.label) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) Text(self.value) .font(.system(size: 10, weight: .bold)) } .lineLimit(1) } } private struct WatchPrimaryLabel: View { let title: String var body: some View { HStack(spacing: 7) { WatchVoiceGlyph() Text(self.title) .font(.caption.weight(.bold)) .lineLimit(1) } .frame(maxWidth: .infinity) .padding(.vertical, 7) .background { Capsule(style: .continuous) .fill(WatchClawStyle.hotGradient) } } } private struct WatchVoiceGlyph: View { var body: some View { HStack(alignment: .center, spacing: 2) { ForEach([7.0, 13.0, 18.0, 12.0, 8.0], id: \.self) { height in Capsule(style: .continuous) .fill(.white.opacity(0.82)) .frame(width: 2, height: height) } } .frame(width: 20, height: 20) } } private struct WatchPageRail: View { let selectedIndex: Int let pageCount: Int var body: some View { HStack(spacing: 5) { ForEach(0.. Void var body: some View { Button(action: self.action) { WatchSecondaryLabel(title: self.title) } .buttonStyle(.plain) } } private struct WatchStackCard: View { let label: String let title: String let subtitle: String let badge: String? var isProminent = false var body: some View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { Text(self.label) .font(.system(size: 10, weight: .bold)) .foregroundStyle(WatchClawStyle.accent) .lineLimit(1) Text(self.title) .font(.system(size: 17, weight: .semibold)) .lineLimit(1) Text(self.subtitle) .font(.system(size: 13)) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 2) HStack(spacing: 5) { if let badge { Text(badge) .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .frame(minWidth: 18, minHeight: 18) .background { Circle() .fill(WatchClawStyle.accent) } } Image(systemName: "chevron.right") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.secondary) } } .padding(.horizontal, 8) .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 17, style: .continuous) .fill(self.isProminent ? WatchClawStyle.raised : WatchClawStyle.surface) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .strokeBorder(WatchClawStyle.border, lineWidth: 1) } } } } private struct WatchActionCard: View { let title: String let subtitle: String let action: () -> Void var body: some View { Button(action: self.action) { WatchStackCard( label: "OpenClaw", title: self.title, subtitle: self.subtitle, badge: nil) } .buttonStyle(.plain) } } private struct WatchDecisionButton: View { let title: String let color: Color let action: () -> Void var body: some View { Button(action: self.action) { Text(self.title) .font(.caption.weight(.bold)) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background { Capsule(style: .continuous) .fill(self.color) } } .buttonStyle(.plain) } } private struct WatchTinyStatus: View { let text: String var body: some View { Text(self.text) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } } private struct WatchChatBubble: View { let item: WatchChatItem var avatarImageSource: String? var avatarText: String? var body: some View { HStack(alignment: .bottom, spacing: 6) { if !self.isUser { WatchClawAvatar( size: 18, imageSource: self.avatarImageSource, text: self.avatarText) } else { Spacer(minLength: 20) } VStack(alignment: self.isUser ? .trailing : .leading, spacing: 3) { Text(self.roleTitle) .font(.system(size: 9, weight: .bold)) .foregroundStyle(self.isUser ? .secondary : WatchClawStyle.accent) Text(self.item.text) .font(.system(size: 13)) .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 9) .padding(.vertical, 7) .frame(maxWidth: 132, alignment: self.isUser ? .trailing : .leading) .background { RoundedRectangle(cornerRadius: 15, style: .continuous) .fill(self.isUser ? WatchClawStyle.accent.opacity(0.88) : WatchClawStyle.surface) } if self.isUser { WatchMiniUserDot() } else { Spacer(minLength: 20) } } .frame(maxWidth: .infinity, alignment: .leading) } private var isUser: Bool { self.item.role.lowercased() == "user" } private var roleTitle: String { switch self.item.role.lowercased() { case "user": "You" case "system": "System" default: "OpenClaw" } } } private struct WatchChatTimelineView: View { let items: [WatchChatItem] let statusText: String let sendStatusText: String? var avatarImageSource: String? var avatarText: String? var onRefresh: (() -> Void)? var onSendMessage: ((String) -> Void)? var body: some View { VStack(spacing: 7) { ScrollView { VStack(alignment: .leading, spacing: 8) { if self.items.isEmpty { WatchChatEmptyState(statusText: self.statusText) } else { ForEach(self.items) { item in WatchChatBubble( item: item, avatarImageSource: self.avatarImageSource, avatarText: self.avatarText) } } if let sendStatusText, !sendStatusText.isEmpty { WatchTinyStatus(text: sendStatusText) } WatchSecondaryButton(title: "Refresh") { self.onRefresh?() } } .padding(.horizontal, 8) .padding(.top, 8) .padding(.bottom, 4) .frame(maxWidth: .infinity, alignment: .topLeading) } .scrollIndicators(.hidden) WatchChatComposer( onSendMessage: { text in self.sendMessage(text) }) .padding(.horizontal, 7) .padding(.bottom, 5) } .background(WatchClawStyle.background.ignoresSafeArea()) .navigationTitle("Chat") } private func sendMessage(_ text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } self.onSendMessage?(trimmed) } } private struct WatchChatEmptyState: View { let statusText: String var body: some View { VStack(alignment: .leading, spacing: 4) { Text("No chat synced") .font(.system(size: 16, weight: .semibold)) .lineLimit(2) Text(self.statusText) .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(3) Text("Tap the message pill below to start from your watch.") .font(.system(size: 11, weight: .medium)) .foregroundStyle(WatchClawStyle.accent) .lineLimit(2) } .padding(.horizontal, 10) .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 17, style: .continuous) .fill(WatchClawStyle.surface) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .strokeBorder(WatchClawStyle.border, lineWidth: 1) } } } } private struct WatchMiniUserDot: View { var body: some View { Text("You") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white.opacity(0.86)) .frame(width: 22, height: 18) .background { Capsule(style: .continuous) .fill(Color.white.opacity(0.10)) } } } private struct WatchChatComposer: View { let onSendMessage: (String) -> Void var body: some View { Button { WatchNativeTextInput.present( suggestions: [], onSubmit: self.onSendMessage) } label: { HStack(spacing: 6) { Text("Message OpenClaw") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) .lineLimit(1) Spacer(minLength: 0) WatchVoiceGlyph() .frame(width: 18, height: 18) .padding(5) .background { Circle() .fill(WatchClawStyle.hotGradient) } } .padding(.leading, 12) .padding(.trailing, 5) .padding(.vertical, 5) .background { Capsule(style: .continuous) .fill(Color.white.opacity(0.09)) .overlay { Capsule(style: .continuous) .strokeBorder(Color.white.opacity(0.16), lineWidth: 1) } } } .buttonStyle(.plain) } } private enum WatchNativeTextInput { @MainActor static func present( suggestions: [String], onSubmit: @escaping (String) -> Void) { WKExtension.shared().visibleInterfaceController?.presentTextInputController( withSuggestions: suggestions, allowedInputMode: .allowEmoji) { results in guard let text = results?.compactMap(stringValue).first? .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return } onSubmit(text) } } private static func stringValue(_ result: Any) -> String? { if let string = result as? String { return string } if let attributed = result as? NSAttributedString { return attributed.string } return nil } } private struct WatchExecApprovalListView: View { var store: WatchInboxStore var onDecision: ((String, WatchExecApprovalDecision) -> Void)? var body: some View { WatchDetailScroll(title: "Approvals") { if self.store.sortedExecApprovals.isEmpty { WatchHeroCard( label: "Clear", title: "No approvals waiting", subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up", accessory: "Ready") } else { ForEach(self.store.sortedExecApprovals) { record in NavigationLink { WatchExecApprovalDetailView( store: self.store, record: record, onDecision: self.onDecision) } label: { WatchStackCard( label: "Approval", title: record.approval.commandPreview ?? record.approval.commandText, subtitle: self.metadataLine(for: record), badge: nil) } .buttonStyle(.plain) } } if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty { WatchTinyStatus(text: outcome) } } } private func metadataLine(for record: WatchExecApprovalRecord) -> String { var parts: [String] = [] if let host = record.approval.host, !host.isEmpty { parts.append(host) } if let nodeId = record.approval.nodeId, !nodeId.isEmpty { parts.append(nodeId) } if let expiresText = Self.expiresText(record.approval.expiresAtMs) { parts.append(expiresText) } if let statusText = record.statusText, !statusText.isEmpty { parts.append(statusText) } return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ") } private static func expiresText(_ expiresAtMs: Int?) -> String? { guard let expiresAtMs else { return nil } let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "Expires in <1m" } return "Expires in \(deltaSeconds / 60)m" } } private struct WatchExecApprovalDetailView: View { var store: WatchInboxStore let record: WatchExecApprovalRecord var onDecision: ((String, WatchExecApprovalDecision) -> Void)? var body: some View { WatchDetailScroll(title: "Approval") { WatchHeroCard( label: self.riskText(self.currentRecord?.approval.risk ?? self.record.approval.risk) ?? "Review", title: self.currentRecord?.approval.commandText ?? self.record.approval.commandText, subtitle: self.metadataSummary, accessory: Self .expiresText(self.currentRecord?.approval.expiresAtMs ?? self.record.approval.expiresAtMs) ?? "Now") if let statusText = self.currentRecord?.statusText, !statusText.isEmpty { WatchTinyStatus(text: statusText) } if let currentRecord { if currentRecord.isResolving { WatchTinyStatus(text: "Sending decision...") } else { HStack(spacing: 8) { if currentRecord.approval.allowedDecisions.contains(.allowOnce) { WatchDecisionButton(title: "Approve", color: .green) { self.onDecision?(currentRecord.id, .allowOnce) } } if currentRecord.approval.allowedDecisions.contains(.deny) { WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) { self.onDecision?(currentRecord.id, .deny) } } } } } } .onAppear { self.store.selectExecApproval(id: self.record.id) } } private var currentRecord: WatchExecApprovalRecord? { self.store.execApprovals.first(where: { $0.id == self.record.id }) } private var metadataSummary: String { let approval = self.currentRecord?.approval ?? self.record.approval var parts: [String] = [] if let host = approval.host, !host.isEmpty { parts.append(host) } if let nodeId = approval.nodeId, !nodeId.isEmpty { parts.append(nodeId) } if let agentId = approval.agentId, !agentId.isEmpty { parts.append(agentId) } return parts.isEmpty ? "Tap to decide" : parts.joined(separator: " · ") } private func riskText(_ risk: WatchRiskLevel?) -> String? { switch risk { case .high: "High risk" case .medium: "Medium risk" case .low: "Low risk" case nil: nil } } private static func expiresText(_ expiresAtMs: Int?) -> String? { guard let expiresAtMs else { return nil } let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "<1 minute" } return "\(deltaSeconds / 60) minutes" } } private struct WatchDetailScroll: View { let title: String @ViewBuilder var content: Content var body: some View { ScrollView { VStack(alignment: .leading, spacing: 9) { self.content } .padding(.horizontal, 8) .padding(.vertical, 9) .padding(.bottom, 18) .frame(maxWidth: .infinity, alignment: .topLeading) } .background(WatchClawStyle.background.ignoresSafeArea()) .navigationTitle(self.title) } }